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 +2 -0
- package/README.md +29 -3
- package/dist/index.js +146 -12
- package/package.json +1 -1
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.**
|
|
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
|
|
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
|
-
|
|
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/
|
|
4026
|
+
// src/rules/prefer-namespace-type-import.ts
|
|
4027
4027
|
import { defineRule as defineRule37 } from "@oxlint/plugins";
|
|
4028
|
-
var
|
|
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
|
|
4075
|
-
var requireObjectParams =
|
|
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
|
|
4124
|
-
var requireOrpcOutput =
|
|
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
|
|
4167
|
-
var requirePairedCall =
|
|
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
|
|
4243
|
-
var routeSurface =
|
|
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
|
|
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 =
|
|
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,
|