oxlint-plugin-arch 0.3.4 โ†’ 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
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
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
+
5
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.
6
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.
7
9
  - Fix `declaration-name` `pattern` with `g` or `y` flags rejecting every name after the first match.
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,7 @@ 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
78
 
79
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.
80
80
 
@@ -117,6 +117,7 @@ Options that take one regex string (`pattern`, `allowPattern`, `forbid`, `requir
117
117
  | `no-type-declarations` | No type aliases or interfaces in matched files, so types live in one place. |
118
118
  | `no-runtime-in-types` | Type modules stay free of runtime code. Opt in to also ban runtime imports and re-exports. |
119
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) |
120
121
 
121
122
  ### ๐Ÿงช Schemas & APIs
122
123
 
@@ -200,6 +201,31 @@ Use `no-type-declarations` separately to control where named types may live. Wit
200
201
 
201
202
  </details>
202
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
+
203
229
  ### `no-type-declarations`
204
230
 
205
231
  Rejects type aliases and interfaces in matched files, so types live in one place.
package/dist/index.js CHANGED
@@ -4023,9 +4023,142 @@ var paddingBetweenStatements = defineRule36({
4023
4023
  }
4024
4024
  });
4025
4025
 
4026
- // src/rules/require-file-factory.ts
4026
+ // src/rules/prefer-namespace-type-import.ts
4027
4027
  import { defineRule as defineRule37 } from "@oxlint/plugins";
4028
- 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({
4029
4162
  meta: {
4030
4163
  type: "problem",
4031
4164
  schema: [{
@@ -4071,8 +4204,8 @@ var requireFileFactory = defineRule37({
4071
4204
  });
4072
4205
 
4073
4206
  // src/rules/require-object-params.ts
4074
- import { defineRule as defineRule38 } from "@oxlint/plugins";
4075
- var requireObjectParams = defineRule38({
4207
+ import { defineRule as defineRule39 } from "@oxlint/plugins";
4208
+ var requireObjectParams = defineRule39({
4076
4209
  meta: {
4077
4210
  type: "problem",
4078
4211
  schema: [{
@@ -4120,8 +4253,8 @@ var requireObjectParams = defineRule38({
4120
4253
  });
4121
4254
 
4122
4255
  // src/rules/require-orpc-output.ts
4123
- import { defineRule as defineRule39 } from "@oxlint/plugins";
4124
- var requireOrpcOutput = defineRule39({
4256
+ import { defineRule as defineRule40 } from "@oxlint/plugins";
4257
+ var requireOrpcOutput = defineRule40({
4125
4258
  meta: {
4126
4259
  type: "problem",
4127
4260
  schema: [{
@@ -4163,8 +4296,8 @@ var requireOrpcOutput = defineRule39({
4163
4296
  });
4164
4297
 
4165
4298
  // src/rules/require-paired-call.ts
4166
- import { defineRule as defineRule40 } from "@oxlint/plugins";
4167
- var requirePairedCall = defineRule40({
4299
+ import { defineRule as defineRule41 } from "@oxlint/plugins";
4300
+ var requirePairedCall = defineRule41({
4168
4301
  meta: {
4169
4302
  type: "problem",
4170
4303
  schema: [{
@@ -4239,8 +4372,8 @@ var requirePairedCall = defineRule40({
4239
4372
  });
4240
4373
 
4241
4374
  // src/rules/route-surface.ts
4242
- import { defineRule as defineRule41 } from "@oxlint/plugins";
4243
- var routeSurface = defineRule41({
4375
+ import { defineRule as defineRule42 } from "@oxlint/plugins";
4376
+ var routeSurface = defineRule42({
4244
4377
  meta: {
4245
4378
  type: "problem",
4246
4379
  schema: [{
@@ -4282,7 +4415,7 @@ var routeSurface = defineRule41({
4282
4415
  });
4283
4416
 
4284
4417
  // src/rules/test-title-pattern.ts
4285
- import { defineRule as defineRule42 } from "@oxlint/plugins";
4418
+ import { defineRule as defineRule43 } from "@oxlint/plugins";
4286
4419
  var TEST_MODIFIERS = new Set(["only", "skip", "concurrent", "todo", "each"]);
4287
4420
  function isConfiguredName(name, callees) {
4288
4421
  if (!name) {
@@ -4322,7 +4455,7 @@ function regexMatches(regex, value) {
4322
4455
  function titlePatterns(pattern, flags) {
4323
4456
  return pattern === undefined ? [] : [pattern].flat().map((source) => ({ regex: new RegExp(source, flags), pattern: source }));
4324
4457
  }
4325
- var testTitlePattern = defineRule42({
4458
+ var testTitlePattern = defineRule43({
4326
4459
  meta: {
4327
4460
  type: "suggestion",
4328
4461
  schema: [{
@@ -4433,6 +4566,7 @@ var src_default = eslintCompatPlugin({
4433
4566
  "only-export-components": onlyExportComponents,
4434
4567
  "only-export-constants": onlyExportConstants,
4435
4568
  "padding-between-statements": paddingBetweenStatements,
4569
+ "prefer-namespace-type-import": preferNamespaceTypeImport,
4436
4570
  "require-file-factory": requireFileFactory,
4437
4571
  "require-object-params": requireObjectParams,
4438
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.4",
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",