@zgeoff/oxlint-config 0.2.0 → 0.2.1
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 +64 -2
- package/find-repo-root.js +20 -0
- package/import-zones-schema.js +50 -0
- package/import-zones.js +295 -0
- package/load-zone-options.js +64 -0
- package/make-glob-matcher.js +36 -0
- package/oxlintrc.json +3 -1
- package/package.json +7 -1
- package/plugin.js +2 -0
- package/resolve-import-target.js +177 -0
- package/test-beside-module.js +4 -19
package/README.md
CHANGED
|
@@ -6,8 +6,9 @@ restriction-rule cherry-picks, comment-style enforcement via `@stylistic`, and a
|
|
|
6
6
|
function declarations, bare named exports, destructured and inline-typed parameters, ternary and
|
|
7
7
|
await arguments, awaits hidden inside a control-flow condition or a `&&`/`||`/`??` chain, every
|
|
8
8
|
JSDoc block, and any run of more than three line comments. `zgeoff/function-verb` enforces the
|
|
9
|
-
function-naming taxonomy from the shared agent guidelines,
|
|
10
|
-
|
|
9
|
+
function-naming taxonomy from the shared agent guidelines, `zgeoff/test-beside-module` keeps each
|
|
10
|
+
test file beside the module it tests, and `zgeoff/import-zones` fails an import that crosses a
|
|
11
|
+
module boundary the repo declares.
|
|
11
12
|
|
|
12
13
|
## Usage
|
|
13
14
|
|
|
@@ -88,6 +89,67 @@ first statement, so put the comment above it:
|
|
|
88
89
|
A second test file for a module (`<name>.host.test.ts`) needs its own `<name>.host.<ext>`, so the
|
|
89
90
|
rule reports it beside only `<name>.ts`.
|
|
90
91
|
|
|
92
|
+
## `zgeoff/import-zones`
|
|
93
|
+
|
|
94
|
+
Off unless a repo configures it. The options name zones (path globs, relative to the repo root) and
|
|
95
|
+
the boundaries between them. A file belongs to every zone its path matches, so zones may overlap.
|
|
96
|
+
Each entry has a `name`, which starts its report, an optional `reason`, which ends it, and optional
|
|
97
|
+
`from` zones and `ignore` globs that limit which importing files it judges.
|
|
98
|
+
|
|
99
|
+
- `edges` judge an import that resolves to a file: relative, or the package's own name resolved
|
|
100
|
+
through its `exports` under `conditions` plus `default`. A target in a `to` zone fails unless it
|
|
101
|
+
is also in an `allow` zone; with `allow` alone, a target in none of the `allow` zones fails, a
|
|
102
|
+
target in no zone included. `allowTypeOnly` exempts type-only imports (`true`, or globs the target
|
|
103
|
+
must match), and `allowWithin: "src/*"` exempts an import inside the importer's own `src/<name>/`.
|
|
104
|
+
- `modules` restrict a package or builtin (`node:` prefix optional, subpaths included) to the
|
|
105
|
+
`allow` zones; `allowTypeOnly: true` exempts type-only imports.
|
|
106
|
+
- `globals` restrict a call to a global, bare or through `globalThis`, `self` or `window`.
|
|
107
|
+
- `unzoned` reports a file in a `from` zone that belongs to none of `zones`.
|
|
108
|
+
- `unresolved` reports a relative or self-referencing import that resolves to no file, and a dynamic
|
|
109
|
+
`import()` or `require()` whose specifier is computed.
|
|
110
|
+
|
|
111
|
+
```jsonc
|
|
112
|
+
{
|
|
113
|
+
"rules": {
|
|
114
|
+
"zgeoff/import-zones": [
|
|
115
|
+
"error",
|
|
116
|
+
{
|
|
117
|
+
"zones": {
|
|
118
|
+
"core": ["src/**"],
|
|
119
|
+
"support": ["src/{config,state}/**"],
|
|
120
|
+
"process": ["src/process/**"],
|
|
121
|
+
"tooling": ["scripts/**"],
|
|
122
|
+
},
|
|
123
|
+
"edges": [
|
|
124
|
+
{ "name": "core-no-tooling", "from": ["core"], "to": ["tooling"] },
|
|
125
|
+
{
|
|
126
|
+
"name": "support",
|
|
127
|
+
"from": ["support"],
|
|
128
|
+
"allow": ["support"],
|
|
129
|
+
"allowTypeOnly": ["**/types.ts"],
|
|
130
|
+
"reason": "support modules sit below the stages",
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
"modules": [
|
|
134
|
+
{
|
|
135
|
+
"name": "child-process",
|
|
136
|
+
"modules": ["node:child_process"],
|
|
137
|
+
"from": ["core"],
|
|
138
|
+
"allow": ["process"],
|
|
139
|
+
"allowTypeOnly": true,
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
"unresolved": [{ "name": "unresolved", "from": ["core"] }],
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Exempt a known violation with a next-line disable comment that names the ticket that removes it. An
|
|
150
|
+
unzoned report sits on the first statement, so its comment goes above that. Run lint with
|
|
151
|
+
`--report-unused-disable-directives-severity error` so a comment outlives no import.
|
|
152
|
+
|
|
91
153
|
## Notes
|
|
92
154
|
|
|
93
155
|
- The config enables the `typescript`, `unicorn`, `oxc`, `import`, and `promise` plugins and loads
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// the nearest ancestor holding `.git` (a directory, or a worktree's file), or
|
|
5
|
+
// null outside a repository
|
|
6
|
+
export default function findRepoRoot(filename) {
|
|
7
|
+
let dir = dirname(filename);
|
|
8
|
+
|
|
9
|
+
while (!existsSync(join(dir, '.git'))) {
|
|
10
|
+
const parent = dirname(dir);
|
|
11
|
+
|
|
12
|
+
if (parent === dir) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
dir = parent;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// the options of zgeoff/import-zones; oxlint rejects a config that breaks it
|
|
2
|
+
const stringList = { type: 'array', items: { type: 'string' } };
|
|
3
|
+
|
|
4
|
+
function buildEntrySchema(properties, required, anyOf) {
|
|
5
|
+
return {
|
|
6
|
+
type: 'array',
|
|
7
|
+
items: {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
name: { type: 'string' },
|
|
11
|
+
from: stringList,
|
|
12
|
+
ignore: stringList,
|
|
13
|
+
reason: { type: 'string' },
|
|
14
|
+
...properties,
|
|
15
|
+
},
|
|
16
|
+
required: ['name', ...required],
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
...(anyOf === undefined ? {} : { anyOf }),
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const importZonesSchema = {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
zones: { type: 'object', additionalProperties: stringList },
|
|
27
|
+
extensions: stringList,
|
|
28
|
+
conditions: stringList,
|
|
29
|
+
edges: buildEntrySchema(
|
|
30
|
+
{
|
|
31
|
+
to: stringList,
|
|
32
|
+
allow: stringList,
|
|
33
|
+
allowTypeOnly: { anyOf: [{ type: 'boolean' }, stringList] },
|
|
34
|
+
allowWithin: { type: 'string' },
|
|
35
|
+
},
|
|
36
|
+
['from'],
|
|
37
|
+
[{ required: ['to'] }, { required: ['allow'] }],
|
|
38
|
+
),
|
|
39
|
+
modules: buildEntrySchema(
|
|
40
|
+
{ modules: stringList, allow: stringList, allowTypeOnly: { type: 'boolean' } },
|
|
41
|
+
['modules', 'allow'],
|
|
42
|
+
),
|
|
43
|
+
globals: buildEntrySchema({ globals: stringList, allow: stringList }, ['globals', 'allow']),
|
|
44
|
+
unzoned: buildEntrySchema({ zones: stringList }, ['from', 'zones']),
|
|
45
|
+
unresolved: buildEntrySchema({}, []),
|
|
46
|
+
},
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export default importZonesSchema;
|
package/import-zones.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { relative, sep } from 'node:path';
|
|
2
|
+
import findRepoRoot from './find-repo-root.js';
|
|
3
|
+
import importZonesSchema from './import-zones-schema.js';
|
|
4
|
+
import loadZoneOptions from './load-zone-options.js';
|
|
5
|
+
import resolveImportTarget from './resolve-import-target.js';
|
|
6
|
+
|
|
7
|
+
// Judges each import against named zones: path globs relative to the repo
|
|
8
|
+
// root, which may overlap. A file belongs to every zone its path matches.
|
|
9
|
+
|
|
10
|
+
function buildFileState(context) {
|
|
11
|
+
const options = loadZoneOptions(context.options[0] ?? {});
|
|
12
|
+
const filename = context.physicalFilename;
|
|
13
|
+
const root = findRepoRoot(filename) ?? context.cwd;
|
|
14
|
+
const path = toRootPath(root, filename);
|
|
15
|
+
|
|
16
|
+
return { context, options, filename, root, path, zones: collectZones(options.zones, path) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function toRootPath(root, path) {
|
|
20
|
+
return relative(root, path).split(sep).join('/');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function collectZones(zones, path) {
|
|
24
|
+
return zones.filter((zone) => zone.match(path)).map((zone) => zone.name);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isInAny(fileZones, names) {
|
|
28
|
+
return names.some((name) => fileZones.includes(name)) === true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// an entry without `from` applies to every file
|
|
32
|
+
function collectEntries(file, entries) {
|
|
33
|
+
return entries.filter(
|
|
34
|
+
(entry) =>
|
|
35
|
+
(entry.from === undefined || isInAny(file.zones, entry.from)) && !entry.ignore(file.path),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// `at` is `{ node }` or `{ loc }`
|
|
40
|
+
function emitReport(file, entry, report) {
|
|
41
|
+
const reason = entry.reason === undefined ? '' : ` — ${entry.reason}`;
|
|
42
|
+
|
|
43
|
+
file.context.report({
|
|
44
|
+
...report.at,
|
|
45
|
+
messageId: report.messageId,
|
|
46
|
+
data: { rule: entry.name, reason, ...report.data },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatZones(names) {
|
|
51
|
+
return names.length === 0 ? 'no zone' : names.join(', ');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// `imported` is `{ node, source, typeOnly }`, one per import of any form
|
|
55
|
+
function checkImport(file, imported) {
|
|
56
|
+
const specifier = findStaticSpecifier(imported.source);
|
|
57
|
+
|
|
58
|
+
if (specifier === null) {
|
|
59
|
+
emitUnresolved(file, imported.node, { messageId: 'computed', data: {} });
|
|
60
|
+
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const target = resolveImportTarget(file.filename, specifier, {
|
|
65
|
+
root: file.root,
|
|
66
|
+
extensions: file.options.extensions,
|
|
67
|
+
conditions: file.options.conditions,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (target.kind === 'module') {
|
|
71
|
+
checkModule(file, imported, target.specifier);
|
|
72
|
+
} else if (target.kind === 'missing') {
|
|
73
|
+
emitUnresolved(file, imported.node, { messageId: 'unresolved', data: { specifier } });
|
|
74
|
+
} else {
|
|
75
|
+
checkEdge(file, imported, toRootPath(file.root, target.path));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// a string literal, or a template literal with nothing interpolated
|
|
80
|
+
function findStaticSpecifier(node) {
|
|
81
|
+
if (node?.type === 'Literal' && typeof node.value === 'string') {
|
|
82
|
+
return node.value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (node?.type === 'TemplateLiteral' && node.expressions.length === 0) {
|
|
86
|
+
return node.quasis.map((quasi) => quasi.value.cooked ?? '').join('');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function emitUnresolved(file, node, report) {
|
|
93
|
+
for (const entry of collectEntries(file, file.options.unresolved)) {
|
|
94
|
+
emitReport(file, entry, { at: { node }, ...report });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function checkModule(file, imported, specifier) {
|
|
99
|
+
for (const entry of collectEntries(file, file.options.modules)) {
|
|
100
|
+
const isAllowed =
|
|
101
|
+
isInAny(file.zones, entry.allow) || (imported.typeOnly && entry.allowTypeOnly === true);
|
|
102
|
+
|
|
103
|
+
if (!isAllowed && isRestrictedModule(entry, specifier)) {
|
|
104
|
+
emitReport(file, entry, {
|
|
105
|
+
at: { node: imported.node },
|
|
106
|
+
messageId: 'module',
|
|
107
|
+
data: { module: specifier, allow: formatZones(entry.allow) },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isRestrictedModule(entry, specifier) {
|
|
114
|
+
const name = toModuleName(specifier);
|
|
115
|
+
|
|
116
|
+
return entry.modules.some((module) => {
|
|
117
|
+
const restricted = toModuleName(module);
|
|
118
|
+
|
|
119
|
+
return name === restricted || name.startsWith(`${restricted}/`);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function toModuleName(specifier) {
|
|
124
|
+
return specifier.replace(/^node:/u, '');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function checkEdge(file, imported, target) {
|
|
128
|
+
const targetZones = collectZones(file.options.zones, target);
|
|
129
|
+
|
|
130
|
+
for (const entry of collectEntries(file, file.options.edges)) {
|
|
131
|
+
// `to` picks the targets judged, every target when absent; `allow`
|
|
132
|
+
// exempts some of them
|
|
133
|
+
const isForbidden =
|
|
134
|
+
(entry.to === undefined || isInAny(targetZones, entry.to)) &&
|
|
135
|
+
(entry.allow === undefined || !isInAny(targetZones, entry.allow));
|
|
136
|
+
const isTypeExempt =
|
|
137
|
+
imported.typeOnly &&
|
|
138
|
+
(entry.allowTypeOnly === true || entry.typeOnlyTargets?.(target) === true);
|
|
139
|
+
|
|
140
|
+
if (isForbidden && !isTypeExempt && entry.within?.(file.path, target) !== true) {
|
|
141
|
+
emitReport(file, entry, {
|
|
142
|
+
at: { node: imported.node },
|
|
143
|
+
messageId: 'edge',
|
|
144
|
+
data: {
|
|
145
|
+
from: formatZones(entry.from.filter((name) => file.zones.includes(name))),
|
|
146
|
+
target,
|
|
147
|
+
to: formatZones(targetZones),
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function checkUnzoned(file, program) {
|
|
155
|
+
// anchor on the first statement, as test-beside-module does, so a disable
|
|
156
|
+
// comment at the top of the file covers the report
|
|
157
|
+
const start = program.body[0]?.loc.start ?? { line: 1, column: 0 };
|
|
158
|
+
|
|
159
|
+
for (const entry of collectEntries(file, file.options.unzoned)) {
|
|
160
|
+
if (!isInAny(file.zones, entry.zones)) {
|
|
161
|
+
emitReport(file, entry, {
|
|
162
|
+
at: { loc: { start, end: start } },
|
|
163
|
+
messageId: 'unzoned',
|
|
164
|
+
data: { zones: formatZones(entry.zones) },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function checkGlobalCall(file, node) {
|
|
171
|
+
for (const entry of collectEntries(file, file.options.globals)) {
|
|
172
|
+
const global = findGlobalCallName(file, node, entry.globals);
|
|
173
|
+
|
|
174
|
+
if (global !== null && !isInAny(file.zones, entry.allow)) {
|
|
175
|
+
emitReport(file, entry, {
|
|
176
|
+
at: { node },
|
|
177
|
+
messageId: 'global',
|
|
178
|
+
data: { global, allow: formatZones(entry.allow) },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const globalObjects = new Set(['globalThis', 'self', 'window']);
|
|
185
|
+
|
|
186
|
+
// a call of the global itself, so a local binding of the same name passes
|
|
187
|
+
function findGlobalCallName(file, node, names) {
|
|
188
|
+
const callee = node.callee;
|
|
189
|
+
let name = null;
|
|
190
|
+
let binding = null;
|
|
191
|
+
|
|
192
|
+
if (callee.type === 'Identifier' && names.includes(callee.name)) {
|
|
193
|
+
name = callee.name;
|
|
194
|
+
binding = callee.name;
|
|
195
|
+
} else if (
|
|
196
|
+
callee.type === 'MemberExpression' &&
|
|
197
|
+
!callee.computed &&
|
|
198
|
+
callee.object.type === 'Identifier' &&
|
|
199
|
+
globalObjects.has(callee.object.name) &&
|
|
200
|
+
callee.property.type === 'Identifier' &&
|
|
201
|
+
names.includes(callee.property.name)
|
|
202
|
+
) {
|
|
203
|
+
name = callee.property.name;
|
|
204
|
+
binding = callee.object.name;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return name === null || isLocalBinding(file, node, binding) ? null : name;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isLocalBinding(file, node, name) {
|
|
211
|
+
let scope = file.context.sourceCode.getScope(node);
|
|
212
|
+
|
|
213
|
+
while (scope !== null) {
|
|
214
|
+
if ((scope.set.get(name)?.defs.length ?? 0) > 0) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
scope = scope.upper;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isTypeOnlyImport(node) {
|
|
225
|
+
return (
|
|
226
|
+
node.importKind === 'type' ||
|
|
227
|
+
(node.specifiers.length > 0 && node.specifiers.every((entry) => entry.importKind === 'type'))
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isTypeOnlyExport(node) {
|
|
232
|
+
return (
|
|
233
|
+
node.exportKind === 'type' ||
|
|
234
|
+
(node.specifiers.length > 0 && node.specifiers.every((entry) => entry.exportKind === 'type'))
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const importZones = {
|
|
239
|
+
meta: {
|
|
240
|
+
type: 'problem',
|
|
241
|
+
messages: {
|
|
242
|
+
edge: '{{rule}}: {{from}} must not import {{target}} ({{to}}){{reason}}',
|
|
243
|
+
module: '{{rule}}: {{module}} is allowed only in {{allow}}{{reason}}',
|
|
244
|
+
global: '{{rule}}: {{global}}() is allowed only in {{allow}}{{reason}}',
|
|
245
|
+
unzoned: '{{rule}}: this file belongs to none of {{zones}}; add its path to one{{reason}}',
|
|
246
|
+
unresolved: '{{rule}}: cannot resolve {{specifier}}{{reason}}',
|
|
247
|
+
computed: '{{rule}}: cannot check a computed import specifier{{reason}}',
|
|
248
|
+
},
|
|
249
|
+
schema: [importZonesSchema],
|
|
250
|
+
},
|
|
251
|
+
create(context) {
|
|
252
|
+
const file = buildFileState(context);
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
Program(node) {
|
|
256
|
+
checkUnzoned(file, node);
|
|
257
|
+
},
|
|
258
|
+
ImportDeclaration(node) {
|
|
259
|
+
checkImport(file, { node, source: node.source, typeOnly: isTypeOnlyImport(node) });
|
|
260
|
+
},
|
|
261
|
+
ExportNamedDeclaration(node) {
|
|
262
|
+
if (node.source !== null) {
|
|
263
|
+
checkImport(file, { node, source: node.source, typeOnly: isTypeOnlyExport(node) });
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
ExportAllDeclaration(node) {
|
|
267
|
+
checkImport(file, { node, source: node.source, typeOnly: node.exportKind === 'type' });
|
|
268
|
+
},
|
|
269
|
+
ImportExpression(node) {
|
|
270
|
+
checkImport(file, { node, source: node.source, typeOnly: false });
|
|
271
|
+
},
|
|
272
|
+
TSImportType(node) {
|
|
273
|
+
checkImport(file, { node, source: node.source ?? node.argument?.literal, typeOnly: true });
|
|
274
|
+
},
|
|
275
|
+
TSImportEqualsDeclaration(node) {
|
|
276
|
+
const reference = node.moduleReference;
|
|
277
|
+
|
|
278
|
+
if (reference.type === 'TSExternalModuleReference') {
|
|
279
|
+
const typeOnly = node.importKind === 'type';
|
|
280
|
+
|
|
281
|
+
checkImport(file, { node, source: reference.expression, typeOnly });
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
CallExpression(node) {
|
|
285
|
+
checkGlobalCall(file, node);
|
|
286
|
+
|
|
287
|
+
if (node.callee.type === 'Identifier' && node.callee.name === 'require') {
|
|
288
|
+
checkImport(file, { node, source: node.arguments[0], typeOnly: false });
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
export default importZones;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import makeGlobMatcher from './make-glob-matcher.js';
|
|
2
|
+
|
|
3
|
+
const defaultExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
|
|
4
|
+
|
|
5
|
+
const loadedOptions = new Map();
|
|
6
|
+
|
|
7
|
+
// oxlint hands each file its own copy of the options, so the compiled form is
|
|
8
|
+
// keyed by content
|
|
9
|
+
export default function loadZoneOptions(options) {
|
|
10
|
+
const key = JSON.stringify(options);
|
|
11
|
+
let loaded = loadedOptions.get(key);
|
|
12
|
+
|
|
13
|
+
if (loaded === undefined) {
|
|
14
|
+
loaded = buildZoneOptions(options);
|
|
15
|
+
|
|
16
|
+
loadedOptions.set(key, loaded);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return loaded;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildZoneOptions(options) {
|
|
23
|
+
const globsByZone = options.zones ?? {};
|
|
24
|
+
const zones = Object.keys(globsByZone).map((name) => ({
|
|
25
|
+
name,
|
|
26
|
+
match: makeGlobMatcher(globsByZone[name]),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const buildEntries = (entries) => (entries ?? []).map((entry) => buildEntry(entry));
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
zones,
|
|
33
|
+
extensions: options.extensions ?? defaultExtensions,
|
|
34
|
+
conditions: new Set([...(options.conditions ?? []), 'default']),
|
|
35
|
+
edges: buildEntries(options.edges),
|
|
36
|
+
modules: buildEntries(options.modules),
|
|
37
|
+
globals: buildEntries(options.globals),
|
|
38
|
+
unzoned: buildEntries(options.unzoned),
|
|
39
|
+
unresolved: buildEntries(options.unresolved),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildEntry(entry) {
|
|
44
|
+
return {
|
|
45
|
+
...entry,
|
|
46
|
+
ignore: makeGlobMatcher(entry.ignore ?? []),
|
|
47
|
+
typeOnlyTargets: Array.isArray(entry.allowTypeOnly)
|
|
48
|
+
? makeGlobMatcher(entry.allowTypeOnly)
|
|
49
|
+
: null,
|
|
50
|
+
within: entry.allowWithin === undefined ? null : makeWithinMatcher(entry.allowWithin),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// `src/*` keeps an import inside the importer's own `src/<name>/` directory
|
|
55
|
+
function makeWithinMatcher(glob) {
|
|
56
|
+
const depth = glob.split('/').length;
|
|
57
|
+
const match = makeGlobMatcher([glob]);
|
|
58
|
+
|
|
59
|
+
return (from, to) => {
|
|
60
|
+
const prefix = from.split('/').slice(0, depth).join('/');
|
|
61
|
+
|
|
62
|
+
return match(prefix) && to.split('/').slice(0, depth).join('/') === prefix;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// supports `**`, `*`, `?` and one level of `{a,b}`; every other character is
|
|
2
|
+
// literal. Paths and globs use `/`.
|
|
3
|
+
export default function makeGlobMatcher(globs) {
|
|
4
|
+
const patterns = globs.map((glob) => toGlobRegExp(glob));
|
|
5
|
+
|
|
6
|
+
return (path) => patterns.some((pattern) => pattern.test(path));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const braceGroup = /(?<group>\{[^{}]*\})/u;
|
|
10
|
+
|
|
11
|
+
function toGlobRegExp(glob) {
|
|
12
|
+
const source = glob
|
|
13
|
+
.split(braceGroup)
|
|
14
|
+
.map((part) => (braceGroup.test(part) ? toAlternativesSource(part) : toGlobSource(part)))
|
|
15
|
+
.join('');
|
|
16
|
+
|
|
17
|
+
return new RegExp(`^${source}$`, 'u');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function toAlternativesSource(group) {
|
|
21
|
+
const alternatives = group
|
|
22
|
+
.slice(1, -1)
|
|
23
|
+
.split(',')
|
|
24
|
+
.map((part) => toGlobSource(part));
|
|
25
|
+
|
|
26
|
+
return `(?:${alternatives.join('|')})`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const globTokenSources = { '**/': '(?:.*/)?', '**': '.*', '*': '[^/]*', '?': '[^/]' };
|
|
30
|
+
|
|
31
|
+
function toGlobSource(text) {
|
|
32
|
+
return text.replaceAll(
|
|
33
|
+
/\*\*\/|\*\*|\*|\?|[$()+.[\\\]^{|}]/gu,
|
|
34
|
+
(token) => globTokenSources[token] ?? `\\${token}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
package/oxlintrc.json
CHANGED
|
@@ -38,10 +38,12 @@
|
|
|
38
38
|
],
|
|
39
39
|
|
|
40
40
|
// own-line comments get a blank line above so they don't crowd the
|
|
41
|
-
// previous statement; comments opening a block/object/class/etc are exempt
|
|
41
|
+
// previous statement; comments opening a block/object/class/etc are exempt,
|
|
42
|
+
// and so are oxlint directives, which oxfmt's import sort packs tight
|
|
42
43
|
"@stylistic/lines-around-comment": [
|
|
43
44
|
"error",
|
|
44
45
|
{
|
|
46
|
+
"ignorePattern": "^\\s*oxlint-",
|
|
45
47
|
"beforeLineComment": true,
|
|
46
48
|
"beforeBlockComment": true,
|
|
47
49
|
"allowBlockStart": true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zgeoff/oxlint-config",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Shareable oxlint config: strict categories, house-style rules, and a custom JS plugin",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
"max-consecutive-line-comments.js",
|
|
16
16
|
"no-jsdoc.js",
|
|
17
17
|
"test-beside-module.js",
|
|
18
|
+
"import-zones.js",
|
|
19
|
+
"import-zones-schema.js",
|
|
20
|
+
"find-repo-root.js",
|
|
21
|
+
"load-zone-options.js",
|
|
22
|
+
"make-glob-matcher.js",
|
|
23
|
+
"resolve-import-target.js",
|
|
18
24
|
"index.js"
|
|
19
25
|
],
|
|
20
26
|
"type": "module",
|
package/plugin.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import functionVerb from './function-verb.js';
|
|
2
|
+
import importZones from './import-zones.js';
|
|
2
3
|
import maxConsecutiveLineComments from './max-consecutive-line-comments.js';
|
|
3
4
|
import noJSDoc from './no-jsdoc.js';
|
|
4
5
|
import testBesideModule from './test-beside-module.js';
|
|
@@ -97,6 +98,7 @@ const plugin = {
|
|
|
97
98
|
'max-consecutive-line-comments': maxConsecutiveLineComments,
|
|
98
99
|
'function-verb': functionVerb,
|
|
99
100
|
'test-beside-module': testBesideModule,
|
|
101
|
+
'import-zones': importZones,
|
|
100
102
|
},
|
|
101
103
|
};
|
|
102
104
|
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// a `.js` specifier in TypeScript source names the `.ts` file beside it
|
|
5
|
+
const sourceExtensions = { '.js': ['.ts', '.tsx'], '.mjs': ['.mts'], '.cjs': ['.cts'] };
|
|
6
|
+
|
|
7
|
+
// `module` with the specifier for a package or builtin, `file` with the
|
|
8
|
+
// absolute path when the import resolves, `missing` otherwise. The package's
|
|
9
|
+
// own name resolves through its `exports`, and `#` names through its `imports`.
|
|
10
|
+
export default function resolveImportTarget(filename, specifier, settings) {
|
|
11
|
+
if (isPathSpecifier(specifier)) {
|
|
12
|
+
return toTarget(findImportedFile(filename, specifier, settings.extensions));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const manifest = findPackageManifest(dirname(filename), settings.root);
|
|
16
|
+
|
|
17
|
+
return resolveManifestTarget(manifest, specifier, settings.conditions);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveManifestTarget(manifest, specifier, conditions) {
|
|
21
|
+
const name = manifest?.name;
|
|
22
|
+
|
|
23
|
+
if (specifier.startsWith('#')) {
|
|
24
|
+
const mapped = findMappedTarget(manifest?.imports, specifier, conditions);
|
|
25
|
+
|
|
26
|
+
return mapped !== null && !isPathSpecifier(mapped)
|
|
27
|
+
? { kind: 'module', specifier: mapped }
|
|
28
|
+
: toMappedTarget(manifest, mapped);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (typeof name === 'string' && (specifier === name || specifier.startsWith(`${name}/`))) {
|
|
32
|
+
const subpath = `.${specifier.slice(name.length)}`;
|
|
33
|
+
|
|
34
|
+
return toMappedTarget(
|
|
35
|
+
manifest,
|
|
36
|
+
findMappedTarget(toSubpathMap(manifest.exports), subpath, conditions),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { kind: 'module', specifier };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toTarget(path) {
|
|
44
|
+
return path === null ? { kind: 'missing' } : { kind: 'file', path };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function toMappedTarget(manifest, mapped) {
|
|
48
|
+
return mapped === null
|
|
49
|
+
? { kind: 'missing' }
|
|
50
|
+
: { kind: 'file', path: resolve(manifest.dir, mapped) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isPathSpecifier(specifier) {
|
|
54
|
+
return /^[./]/u.test(specifier);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const manifests = new Map();
|
|
58
|
+
|
|
59
|
+
function findPackageManifest(dir, root) {
|
|
60
|
+
if (manifests.has(dir)) {
|
|
61
|
+
return manifests.get(dir);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const path = join(dir, 'package.json');
|
|
65
|
+
const parent = dirname(dir);
|
|
66
|
+
let manifest = null;
|
|
67
|
+
|
|
68
|
+
if (existsSync(path)) {
|
|
69
|
+
manifest = { dir, ...JSON.parse(readFileSync(path, 'utf8')) };
|
|
70
|
+
} else if (dir !== root && parent !== dir) {
|
|
71
|
+
manifest = findPackageManifest(parent, root);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
manifests.set(dir, manifest);
|
|
75
|
+
|
|
76
|
+
return manifest;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// `exports` without `.` keys is the target of the package's own name alone
|
|
80
|
+
function toSubpathMap(exports) {
|
|
81
|
+
const isSubpathMap =
|
|
82
|
+
typeof exports === 'object' &&
|
|
83
|
+
exports !== null &&
|
|
84
|
+
!Array.isArray(exports) &&
|
|
85
|
+
Object.keys(exports).some((key) => key.startsWith('.'));
|
|
86
|
+
|
|
87
|
+
return isSubpathMap || exports === undefined ? exports : { '.': exports };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// an exact key wins; otherwise the `*` pattern with the longest prefix, then
|
|
91
|
+
// the longest key, as node picks it, its match substituted into the target
|
|
92
|
+
function findMappedTarget(map, key, conditions) {
|
|
93
|
+
if (typeof map !== 'object' || map === null) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (Object.hasOwn(map, key)) {
|
|
98
|
+
return findConditionTarget(map[key], conditions);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const [pattern] = Object.keys(map)
|
|
102
|
+
.filter((candidate) => isPatternMatch(candidate, key))
|
|
103
|
+
.toSorted((left, right) => {
|
|
104
|
+
const byPrefix = right.indexOf('*') - left.indexOf('*');
|
|
105
|
+
|
|
106
|
+
return byPrefix === 0 ? right.length - left.length : byPrefix;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return pattern === undefined ? null : findPatternTarget(map, pattern, { key, conditions });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function findPatternTarget(map, pattern, lookup) {
|
|
113
|
+
const star = pattern.indexOf('*');
|
|
114
|
+
const match = lookup.key.slice(star, lookup.key.length - (pattern.length - star - 1));
|
|
115
|
+
const target = findConditionTarget(map[pattern], lookup.conditions);
|
|
116
|
+
|
|
117
|
+
return target === null ? null : target.replaceAll('*', match);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isPatternMatch(pattern, key) {
|
|
121
|
+
const star = pattern.indexOf('*');
|
|
122
|
+
|
|
123
|
+
if (star === -1 || pattern.includes('*', star + 1)) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const prefix = pattern.slice(0, star);
|
|
128
|
+
const suffix = pattern.slice(star + 1);
|
|
129
|
+
|
|
130
|
+
return key.length >= pattern.length && key.startsWith(prefix) && key.endsWith(suffix);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// node's rule: the first key, in object order, that names an active
|
|
134
|
+
// condition; in an array, the first entry that yields a target
|
|
135
|
+
function findConditionTarget(value, conditions) {
|
|
136
|
+
if (typeof value === 'string') {
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (typeof value !== 'object' || value === null) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const candidates = Array.isArray(value)
|
|
145
|
+
? value
|
|
146
|
+
: Object.keys(value)
|
|
147
|
+
.filter((condition) => conditions.has(condition))
|
|
148
|
+
.map((condition) => value[condition]);
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
candidates
|
|
152
|
+
.map((candidate) => findConditionTarget(candidate, conditions))
|
|
153
|
+
.find((target) => target !== null) ?? null
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function findImportedFile(filename, specifier, extensions) {
|
|
158
|
+
const base = resolve(dirname(filename), specifier);
|
|
159
|
+
const swapped = Object.keys(sourceExtensions).flatMap((ext) =>
|
|
160
|
+
base.endsWith(ext)
|
|
161
|
+
? sourceExtensions[ext].map((other) => `${base.slice(0, -ext.length)}${other}`)
|
|
162
|
+
: [],
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const candidates = [
|
|
166
|
+
base,
|
|
167
|
+
...swapped,
|
|
168
|
+
...extensions.map((ext) => `${base}${ext}`),
|
|
169
|
+
...extensions.map((ext) => join(base, `index${ext}`)),
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
return candidates.find((candidate) => isFile(candidate)) ?? null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isFile(path) {
|
|
176
|
+
return statSync(path, { throwIfNoEntry: false })?.isFile() ?? false;
|
|
177
|
+
}
|
package/test-beside-module.js
CHANGED
|
@@ -1,35 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, join, relative, sep } from 'node:path';
|
|
3
|
+
import findRepoRoot from './find-repo-root.js';
|
|
3
4
|
|
|
4
5
|
const testFilePattern = /^(?<base>.+)\.test\.tsx?$/u;
|
|
5
6
|
|
|
6
7
|
// suites that run the whole program have no single module to sit beside
|
|
7
8
|
const exemptRootDirs = new Set(['e2e', 'evals']);
|
|
8
9
|
|
|
10
|
+
// the repo root, not oxlint's working directory, so a per-package lint
|
|
11
|
+
// script neither exempts a package's own e2e/ nor flags the root one
|
|
9
12
|
function isUnderExemptRoot(cwd, filename) {
|
|
10
13
|
const [first] = relative(findRepoRoot(filename) ?? cwd, filename).split(sep);
|
|
11
14
|
|
|
12
15
|
return exemptRootDirs.has(first);
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
// the repo root, not oxlint's working directory, so a per-package lint
|
|
16
|
-
// script neither exempts a package's own e2e/ nor flags the root one
|
|
17
|
-
function findRepoRoot(filename) {
|
|
18
|
-
let dir = dirname(filename);
|
|
19
|
-
|
|
20
|
-
while (!existsSync(join(dir, '.git'))) {
|
|
21
|
-
const parent = dirname(dir);
|
|
22
|
-
|
|
23
|
-
if (parent === dir) {
|
|
24
|
-
return null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
dir = parent;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return dir;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
18
|
// a module is `<name>.<ext>` with a single-segment extension, so a
|
|
34
19
|
// declaration file never stands in for one, and neither does another test
|
|
35
20
|
// (`foo.test.ts` beside `foo.test.test.ts`)
|