@zgeoff/oxlint-config 0.2.11 → 0.2.12

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
@@ -142,19 +142,26 @@ Each entry has a `name`, which starts its report, an optional `reason`, which en
142
142
 
143
143
  - `edges` judge an import that resolves to a file: relative, with any bundler query such as `?url`
144
144
  dropped, or the package's own name resolved through its `exports` under `conditions` plus
145
- `default`. A target in a `to` zone fails unless it is also in an `allow` zone; with `allow` alone,
146
- a target in none of the `allow` zones fails, a target in no zone included. `allowTypeOnly` exempts
147
- type-only imports (`true`, or globs the target must match), and `allowWithin: "src/*"` exempts an
148
- import inside the importer's own `src/<name>/`.
145
+ `default`. They also judge the name of each package whose directory a `workspaces` glob matches,
146
+ such as `packages/*`, with one directory level per segment. That name maps to the package folder
147
+ joined with the subpath, never to an export's target: `@demo/store/testing` is judged as
148
+ `packages/store/testing`, so a zone names the folder path, such as `packages/store{,/**}`. A
149
+ target in a `to` zone fails unless it is also in an `allow` zone; with `allow` alone, a target in
150
+ none of the `allow` zones fails, a target in no zone included. `allowTypeOnly` exempts type-only
151
+ imports (`true`, or globs the target must match), and `allowWithin: "src/*"` exempts an import
152
+ inside the importer's own `src/<name>/`.
149
153
  - `modules` restrict a package or builtin (`node:` prefix optional, subpaths included) to the
150
154
  `allow` zones; `allowTypeOnly: true` exempts type-only imports. A name that ends in `*` restricts
151
155
  every specifier that starts with the text before it, such as `@scope/*`, and `node:*` restricts
152
156
  every builtin.
153
157
  - `globals` restrict a call to a global, bare or through `globalThis`, `self` or `window`.
154
158
  - `unzoned` reports a file in a `from` zone that belongs to none of `zones`.
155
- - `unresolved` reports a relative or self-referencing import that resolves to no file, and a dynamic
159
+ - `unresolved` reports a relative or self-referencing import that resolves to no file, a workspace
160
+ import whose subpath no `exports` key lists, or one that a `null` key blocks, and a dynamic
156
161
  `import()`, `require()` or `import.meta.require()` whose specifier is computed. Bun's
157
162
  `import.meta.require` used as a value counts as computed too.
163
+ - `selfImports` reports an import of the importer's own package by name, which a relative path
164
+ reaches without the `exports` map.
158
165
 
159
166
  ```jsonc
160
167
  {
@@ -26,6 +26,7 @@ const importZonesSchema = {
26
26
  zones: { type: 'object', additionalProperties: stringList },
27
27
  extensions: stringList,
28
28
  conditions: stringList,
29
+ workspaces: stringList,
29
30
  edges: buildEntrySchema(
30
31
  {
31
32
  to: stringList,
@@ -43,6 +44,7 @@ const importZonesSchema = {
43
44
  globals: buildEntrySchema({ globals: stringList, allow: stringList }, ['globals', 'allow']),
44
45
  unzoned: buildEntrySchema({ zones: stringList }, ['from', 'zones']),
45
46
  unresolved: buildEntrySchema({}, []),
47
+ selfImports: buildEntrySchema({}, []),
46
48
  },
47
49
  additionalProperties: false,
48
50
  };
package/import-zones.js CHANGED
@@ -2,6 +2,7 @@ import { isBuiltin } from 'node:module';
2
2
  import { relative, sep } from 'node:path';
3
3
  import findRepoRoot from './find-repo-root.js';
4
4
  import importZonesSchema from './import-zones-schema.js';
5
+ import loadWorkspaceManifests from './load-workspace-manifests.js';
5
6
  import loadZoneOptions from './load-zone-options.js';
6
7
  import resolveImportTarget from './resolve-import-target.js';
7
8
 
@@ -14,7 +15,17 @@ function buildFileState(context) {
14
15
  const root = findRepoRoot(filename) ?? context.cwd;
15
16
  const path = toRootPath(root, filename);
16
17
 
17
- return { context, options, filename, root, path, zones: collectZones(options.zones, path) };
18
+ const workspaces = loadWorkspaceManifests(root, options.workspaces);
19
+
20
+ return {
21
+ context,
22
+ options,
23
+ filename,
24
+ root,
25
+ path,
26
+ workspaces,
27
+ zones: collectZones(options.zones, path),
28
+ };
18
29
  }
19
30
 
20
31
  function toRootPath(root, path) {
@@ -66,8 +77,13 @@ function checkImport(file, imported) {
66
77
  root: file.root,
67
78
  extensions: file.options.extensions,
68
79
  conditions: file.options.conditions,
80
+ workspaces: file.workspaces,
69
81
  });
70
82
 
83
+ if (target.self === true) {
84
+ emitSelfImport(file, imported.node, specifier);
85
+ }
86
+
71
87
  if (target.kind === 'module') {
72
88
  checkModule(file, imported, target.specifier);
73
89
  } else if (target.kind === 'missing') {
@@ -96,6 +112,12 @@ function emitUnresolved(file, node, report) {
96
112
  }
97
113
  }
98
114
 
115
+ function emitSelfImport(file, node, specifier) {
116
+ for (const entry of collectEntries(file, file.options.selfImports)) {
117
+ emitReport(file, entry, { at: { node }, messageId: 'self', data: { specifier } });
118
+ }
119
+ }
120
+
99
121
  function checkModule(file, imported, specifier) {
100
122
  for (const entry of collectEntries(file, file.options.modules)) {
101
123
  const isAllowed =
@@ -270,6 +292,7 @@ const importZones = {
270
292
  global: '{{rule}}: {{global}}() is allowed only in {{allow}}{{reason}}',
271
293
  unzoned: '{{rule}}: this file belongs to none of {{zones}}; add its path to one{{reason}}',
272
294
  unresolved: '{{rule}}: cannot resolve {{specifier}}{{reason}}',
295
+ self: '{{rule}}: {{specifier}} names the package this file belongs to; import it by a relative path{{reason}}',
273
296
  computed: '{{rule}}: cannot check a computed import specifier{{reason}}',
274
297
  },
275
298
  schema: [importZonesSchema],
@@ -0,0 +1,64 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import makeGlobMatcher from './make-glob-matcher.js';
4
+
5
+ // The manifest of every package directory the globs match, relative to the
6
+ // root. Each glob segment matches one directory level, as a workspaces
7
+ // field's globs do.
8
+ export default function loadWorkspaceManifests(root, globs) {
9
+ return withManifestCache(JSON.stringify([root, globs]), () =>
10
+ globs
11
+ .flatMap((glob) => readGlobDirectories(root, splitGlob(glob), ''))
12
+ .map((directory) => loadManifest(join(root, directory)))
13
+ .filter((manifest) => typeof manifest?.name === 'string'),
14
+ );
15
+ }
16
+
17
+ const loadedManifests = new Map();
18
+
19
+ // each linted file asks again, so the manifests load once per root and globs
20
+ function withManifestCache(key, load) {
21
+ if (!loadedManifests.has(key)) {
22
+ loadedManifests.set(key, load());
23
+ }
24
+
25
+ return loadedManifests.get(key);
26
+ }
27
+
28
+ function splitGlob(glob) {
29
+ return glob.split('/').filter((segment) => segment !== '' && segment !== '.');
30
+ }
31
+
32
+ function readGlobDirectories(root, segments, prefix) {
33
+ const [segment, ...rest] = segments;
34
+
35
+ if (segment === undefined) {
36
+ return [prefix];
37
+ }
38
+
39
+ const isMatch = makeGlobMatcher([segment]);
40
+ const parent = join(root, prefix);
41
+ const names = existsSync(parent)
42
+ ? readdirSync(parent, { withFileTypes: true })
43
+ .filter((entry) => entry.isDirectory() && entry.name !== 'node_modules')
44
+ .map((entry) => entry.name)
45
+ .filter((name) => isMatch(name))
46
+ : [];
47
+
48
+ return names.flatMap((name) => readGlobDirectories(root, rest, join(prefix, name)));
49
+ }
50
+
51
+ // `dir` is the field the resolver reads from every manifest it loads
52
+ function loadManifest(directory) {
53
+ const path = join(directory, 'package.json');
54
+
55
+ if (!existsSync(path)) {
56
+ return null;
57
+ }
58
+
59
+ const manifest = JSON.parse(readFileSync(path, 'utf8'));
60
+
61
+ manifest.dir = directory;
62
+
63
+ return manifest;
64
+ }
@@ -30,11 +30,13 @@ function buildZoneOptions(options) {
30
30
  zones,
31
31
  extensions: options.extensions ?? defaultExtensions,
32
32
  conditions: new Set([...(options.conditions ?? []), 'default']),
33
+ workspaces: options.workspaces ?? [],
33
34
  edges: buildEntries(options.edges),
34
35
  modules: buildEntries(options.modules),
35
36
  globals: buildEntries(options.globals),
36
37
  unzoned: buildEntries(options.unzoned),
37
38
  unresolved: buildEntries(options.unresolved),
39
+ selfImports: buildEntries(options.selfImports),
38
40
  };
39
41
  }
40
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/oxlint-config",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "description": "Shareable oxlint config: strict categories, house-style rules, and a custom JS plugin",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,6 +22,7 @@
22
22
  "import-zones.js",
23
23
  "import-zones-schema.js",
24
24
  "find-repo-root.js",
25
+ "load-workspace-manifests.js",
25
26
  "load-zone-options.js",
26
27
  "make-glob-matcher.js",
27
28
  "resolve-import-target.js",
@@ -1,12 +1,13 @@
1
1
  import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { isBuiltin } from 'node:module';
2
3
  import { dirname, join, resolve } from 'node:path';
3
4
 
4
5
  // a `.js` specifier in TypeScript source names the `.ts` file beside it
5
6
  const sourceExtensions = { '.js': ['.ts', '.tsx'], '.mjs': ['.mts'], '.cjs': ['.cts'] };
6
7
 
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`.
8
+ // `module` for a package or builtin, `file` with the absolute path, or
9
+ // `missing`. The own name, which `self` marks, resolves through `exports`, `#`
10
+ // names through `imports`, and a workspace name to a path in its folder.
10
11
  export default function resolveImportTarget(filename, specifier, settings) {
11
12
  if (isPathSpecifier(specifier)) {
12
13
  return toTarget(findImportedFile(filename, toFileSpecifier(specifier), settings.extensions));
@@ -14,30 +15,73 @@ export default function resolveImportTarget(filename, specifier, settings) {
14
15
 
15
16
  const manifest = findPackageManifest(dirname(filename), settings.root);
16
17
 
17
- return resolveManifestTarget(manifest, specifier, settings.conditions);
18
+ return resolveManifestTarget(manifest, specifier, settings);
18
19
  }
19
20
 
20
- function resolveManifestTarget(manifest, specifier, conditions) {
21
- const name = manifest?.name;
21
+ function resolveManifestTarget(manifest, specifier, settings) {
22
+ if (!specifier.startsWith('#')) {
23
+ return resolveBareTarget(manifest, specifier, settings);
24
+ }
22
25
 
23
- if (specifier.startsWith('#')) {
24
- const mapped = findMappedTarget(manifest?.imports, specifier, conditions);
26
+ const mapped = findMappedTarget(manifest?.imports, specifier, settings.conditions);
27
+
28
+ return mapped !== null && !isPathSpecifier(mapped)
29
+ ? resolveBareTarget(manifest, mapped, settings)
30
+ : toMappedTarget(manifest, mapped);
31
+ }
25
32
 
26
- return mapped !== null && !isPathSpecifier(mapped)
27
- ? { kind: 'module', specifier: mapped }
28
- : toMappedTarget(manifest, mapped);
33
+ // a builtin keeps its name even when a package shares it
34
+ function resolveBareTarget(manifest, specifier, settings) {
35
+ const name = manifest?.name;
36
+
37
+ if (isBuiltin(specifier)) {
38
+ return { kind: 'module', specifier };
29
39
  }
30
40
 
31
- if (typeof name === 'string' && (specifier === name || specifier.startsWith(`${name}/`))) {
41
+ if (isPackageSpecifier(name, specifier)) {
32
42
  const subpath = `.${specifier.slice(name.length)}`;
43
+ const mapped = findMappedTarget(toSubpathMap(manifest.exports), subpath, settings.conditions);
44
+
45
+ return { ...toMappedTarget(manifest, mapped), self: true };
46
+ }
47
+
48
+ return resolveWorkspaceTarget(settings.workspaces, specifier);
49
+ }
33
50
 
34
- return toMappedTarget(
35
- manifest,
36
- findMappedTarget(toSubpathMap(manifest.exports), subpath, conditions),
37
- );
51
+ function isPackageSpecifier(name, specifier) {
52
+ return typeof name === 'string' && (specifier === name || specifier.startsWith(`${name}/`));
53
+ }
54
+
55
+ // the edges judge the package folder joined with the subpath, such as
56
+ // `packages/store/testing`, so the exports map's keys matter and not its targets
57
+ function resolveWorkspaceTarget(workspaces, specifier) {
58
+ const workspace = workspaces.find((candidate) => isPackageSpecifier(candidate.name, specifier));
59
+
60
+ if (workspace === undefined) {
61
+ return { kind: 'module', specifier };
38
62
  }
39
63
 
40
- return { kind: 'module', specifier };
64
+ const subpath = `.${specifier.slice(workspace.name.length)}`;
65
+
66
+ return isExportedSubpath(workspace.exports, subpath)
67
+ ? { kind: 'file', path: join(workspace.dir, subpath) }
68
+ : { kind: 'missing' };
69
+ }
70
+
71
+ // with no exports map, every subpath is public, and an empty one exports none
72
+ function isExportedSubpath(exports, subpath) {
73
+ if (exports === undefined) {
74
+ return true;
75
+ }
76
+
77
+ if (typeof exports === 'object' && exports !== null && Object.keys(exports).length === 0) {
78
+ return false;
79
+ }
80
+
81
+ const map = toSubpathMap(exports);
82
+ const key = Object.hasOwn(map, subpath) ? subpath : findPatternKey(map, subpath);
83
+
84
+ return key !== undefined && map[key] !== null;
41
85
  }
42
86
 
43
87
  // a bundler query such as Vite's `./styles.css?url` names the file before it
@@ -105,6 +149,12 @@ function findMappedTarget(map, key, conditions) {
105
149
  return findConditionTarget(map[key], conditions);
106
150
  }
107
151
 
152
+ const pattern = findPatternKey(map, key);
153
+
154
+ return pattern === undefined ? null : findPatternTarget(map, pattern, { key, conditions });
155
+ }
156
+
157
+ function findPatternKey(map, key) {
108
158
  const [pattern] = Object.keys(map)
109
159
  .filter((candidate) => isPatternMatch(candidate, key))
110
160
  .toSorted((left, right) => {
@@ -113,7 +163,7 @@ function findMappedTarget(map, key, conditions) {
113
163
  return byPrefix === 0 ? right.length - left.length : byPrefix;
114
164
  });
115
165
 
116
- return pattern === undefined ? null : findPatternTarget(map, pattern, { key, conditions });
166
+ return pattern;
117
167
  }
118
168
 
119
169
  function findPatternTarget(map, pattern, lookup) {