@zgeoff/oxlint-config 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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, and `zgeoff/test-beside-module` keeps
10
- each test file beside the module it tests.
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;
@@ -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,
@@ -120,6 +122,27 @@
120
122
  // this config targets node/bun tooling — node builtins are the platform
121
123
  "import/no-nodejs-modules": "off",
122
124
 
125
+ // a file past 500 non-blank lines holds more than one concept, and a fifth
126
+ // parameter wants an options object
127
+ "max-lines": ["error", { "max": 500, "skipBlankLines": true }],
128
+ "max-params": ["error", { "max": 4 }],
129
+
130
+ // counts that fire on code already in its clearest form
131
+ "max-statements": "off",
132
+ "max-lines-per-function": "off",
133
+ "import/max-dependencies": "off",
134
+
135
+ // the fix reads worse than the code: tsc checks assignment before use, a
136
+ // guard `continue` is an early return, awaits in a loop keep order, and
137
+ // an event or timer needs a bare `new Promise`
138
+ "init-declarations": "off",
139
+ "no-continue": "off",
140
+ "no-await-in-loop": "off",
141
+ "promise/avoid-new": "off",
142
+
143
+ // a library or platform handle has no readonly form
144
+ "typescript/prefer-readonly-parameter-types": "off",
145
+
123
146
  // preload-style modules (matcher registration, type augmentation) are
124
147
  // side-effect imports by design
125
148
  "import/no-unassigned-import": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/oxlint-config",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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
+ }
@@ -1,35 +1,20 @@
1
- import { existsSync, readdirSync, statSync } from 'node:fs';
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`)