@zgeoff/oxlint-config 0.2.11 → 0.2.13
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 +38 -7
- package/import-zones-schema.js +2 -0
- package/import-zones.js +24 -1
- package/load-workspace-manifests.js +64 -0
- package/load-zone-options.js +2 -0
- package/oxlintrc.json +1 -0
- package/package.json +3 -1
- package/plugin.js +2 -0
- package/resolve-import-target.js +68 -18
- package/setup-test-only.js +269 -0
package/README.md
CHANGED
|
@@ -10,8 +10,9 @@ that loosens either comment rule. `zgeoff/function-verb` enforces the function-n
|
|
|
10
10
|
the shared agent guidelines, `zgeoff/test-beside-module` keeps each test file beside the module it
|
|
11
11
|
tests, `zgeoff/import-zones` fails an import that crosses a module boundary the repo declares,
|
|
12
12
|
`zgeoff/no-restricted-words` keeps the words in its `words` option out of a path's text,
|
|
13
|
-
`zgeoff/no-abbreviated-names` reports each abbreviated value name it lists with its full word,
|
|
14
|
-
`zgeoff/no-file-snapshot` bans file snapshots
|
|
13
|
+
`zgeoff/no-abbreviated-names` reports each abbreviated value name it lists with its full word,
|
|
14
|
+
`zgeoff/no-file-snapshot` bans file snapshots, and `zgeoff/setup-test-only` keeps a test file's
|
|
15
|
+
module level to `setupTest()` and its tests.
|
|
15
16
|
|
|
16
17
|
## Usage
|
|
17
18
|
|
|
@@ -88,6 +89,29 @@ 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/setup-test-only`
|
|
93
|
+
|
|
94
|
+
A `<name>.test.ts` or `<name>.test.tsx` file declares one function, `setupTest()`, and nothing else,
|
|
95
|
+
as the testing skill requires. At module level the rule allows imports, `type` and `interface`
|
|
96
|
+
declarations, one `setupTest` function declaration, and statements that call `test`, `test.each`,
|
|
97
|
+
`test.skipIf` or `test.if`, or a chain of them, with `test` under its local alias when the
|
|
98
|
+
`bun:test` import renames it. It reports every other statement: a `let`, a `var`, a class, an enum,
|
|
99
|
+
a second function, an export, and a call such as `beforeEach(...)`. The message holds the three ways
|
|
100
|
+
out of a helper: inline it, swap it for a registered matcher, or move it to the shared test utils
|
|
101
|
+
with tests of its own.
|
|
102
|
+
|
|
103
|
+
A module-level `const` passes only as a skip gate: a declarator that binds at least one name, each
|
|
104
|
+
binding read, and read only at collection time, inside an argument of the test runner's `skipIf` or
|
|
105
|
+
inside the initializer of another gate. The runner is `test` as `bun:test` exports it, under its
|
|
106
|
+
local alias if the import renames it, or a chain of its modifiers, so `matrix.skipIf(rows)` is not a
|
|
107
|
+
gate read. A file that imports no `test` uses the global one, unless an import or a module-level
|
|
108
|
+
declaration of its own takes the name `test`. A read inside a function never counts, so a `skipIf`
|
|
109
|
+
in `setupTest()` or a test body leaves its `const` reported. A gate derived from an awaited probe
|
|
110
|
+
passes beside the probe, in the same declaration or its own, but consts that only read each other
|
|
111
|
+
never reach a `skipIf` and report. A `const` that nothing reads reports too, and so does a
|
|
112
|
+
declarator that binds nothing, such as `const {} = registerHooks()`. The rule does not check the
|
|
113
|
+
comment that the skill asks a gate to carry.
|
|
114
|
+
|
|
91
115
|
## `zgeoff/no-restricted-words`
|
|
92
116
|
|
|
93
117
|
Off unless a repo configures it. The `words` option lists the words to keep out of the text of a
|
|
@@ -142,19 +166,26 @@ Each entry has a `name`, which starts its report, an optional `reason`, which en
|
|
|
142
166
|
|
|
143
167
|
- `edges` judge an import that resolves to a file: relative, with any bundler query such as `?url`
|
|
144
168
|
dropped, or the package's own name resolved through its `exports` under `conditions` plus
|
|
145
|
-
`default`.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
169
|
+
`default`. They also judge the name of each package whose directory a `workspaces` glob matches,
|
|
170
|
+
such as `packages/*`, with one directory level per segment. That name maps to the package folder
|
|
171
|
+
joined with the subpath, never to an export's target: `@demo/store/testing` is judged as
|
|
172
|
+
`packages/store/testing`, so a zone names the folder path, such as `packages/store{,/**}`. A
|
|
173
|
+
target in a `to` zone fails unless it is also in an `allow` zone; with `allow` alone, a target in
|
|
174
|
+
none of the `allow` zones fails, a target in no zone included. `allowTypeOnly` exempts type-only
|
|
175
|
+
imports (`true`, or globs the target must match), and `allowWithin: "src/*"` exempts an import
|
|
176
|
+
inside the importer's own `src/<name>/`.
|
|
149
177
|
- `modules` restrict a package or builtin (`node:` prefix optional, subpaths included) to the
|
|
150
178
|
`allow` zones; `allowTypeOnly: true` exempts type-only imports. A name that ends in `*` restricts
|
|
151
179
|
every specifier that starts with the text before it, such as `@scope/*`, and `node:*` restricts
|
|
152
180
|
every builtin.
|
|
153
181
|
- `globals` restrict a call to a global, bare or through `globalThis`, `self` or `window`.
|
|
154
182
|
- `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,
|
|
183
|
+
- `unresolved` reports a relative or self-referencing import that resolves to no file, a workspace
|
|
184
|
+
import whose subpath no `exports` key lists, or one that a `null` key blocks, and a dynamic
|
|
156
185
|
`import()`, `require()` or `import.meta.require()` whose specifier is computed. Bun's
|
|
157
186
|
`import.meta.require` used as a value counts as computed too.
|
|
187
|
+
- `selfImports` reports an import of the importer's own package by name, which a relative path
|
|
188
|
+
reaches without the `exports` map.
|
|
158
189
|
|
|
159
190
|
```jsonc
|
|
160
191
|
{
|
package/import-zones-schema.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/load-zone-options.js
CHANGED
|
@@ -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/oxlintrc.json
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"zgeoff/function-verb": "error",
|
|
33
33
|
"zgeoff/test-beside-module": "error",
|
|
34
34
|
"zgeoff/no-file-snapshot": "error",
|
|
35
|
+
"zgeoff/setup-test-only": "error",
|
|
35
36
|
|
|
36
37
|
// name a value for what it holds, in full words: one letter only for a
|
|
37
38
|
// coordinate, zod's `z`, Bun's shell `$` or an unread `_` placeholder, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zgeoff/oxlint-config",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
4
4
|
"description": "Shareable oxlint config: strict categories, house-style rules, and a custom JS plugin",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -19,9 +19,11 @@
|
|
|
19
19
|
"no-comment-rule-override.js",
|
|
20
20
|
"test-beside-module.js",
|
|
21
21
|
"no-file-snapshot.js",
|
|
22
|
+
"setup-test-only.js",
|
|
22
23
|
"import-zones.js",
|
|
23
24
|
"import-zones-schema.js",
|
|
24
25
|
"find-repo-root.js",
|
|
26
|
+
"load-workspace-manifests.js",
|
|
25
27
|
"load-zone-options.js",
|
|
26
28
|
"make-glob-matcher.js",
|
|
27
29
|
"resolve-import-target.js",
|
package/plugin.js
CHANGED
|
@@ -6,6 +6,7 @@ import noCommentRuleOverride from './no-comment-rule-override.js';
|
|
|
6
6
|
import noFileSnapshot from './no-file-snapshot.js';
|
|
7
7
|
import noJSDoc from './no-jsdoc.js';
|
|
8
8
|
import noRestrictedWords from './no-restricted-words.js';
|
|
9
|
+
import setupTestOnly from './setup-test-only.js';
|
|
9
10
|
import testBesideModule from './test-beside-module.js';
|
|
10
11
|
|
|
11
12
|
// custom rules loaded through oxlint's jsPlugins (ESLint v9 rule API): each
|
|
@@ -111,6 +112,7 @@ const plugin = {
|
|
|
111
112
|
'no-abbreviated-names': noAbbreviatedNames,
|
|
112
113
|
'test-beside-module': testBesideModule,
|
|
113
114
|
'no-file-snapshot': noFileSnapshot,
|
|
115
|
+
'setup-test-only': setupTestOnly,
|
|
114
116
|
'import-zones': importZones,
|
|
115
117
|
},
|
|
116
118
|
};
|
package/resolve-import-target.js
CHANGED
|
@@ -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`
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
18
|
+
return resolveManifestTarget(manifest, specifier, settings);
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
function resolveManifestTarget(manifest, specifier,
|
|
21
|
-
|
|
21
|
+
function resolveManifestTarget(manifest, specifier, settings) {
|
|
22
|
+
if (!specifier.startsWith('#')) {
|
|
23
|
+
return resolveBareTarget(manifest, specifier, settings);
|
|
24
|
+
}
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 (
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
166
|
+
return pattern;
|
|
117
167
|
}
|
|
118
168
|
|
|
119
169
|
function findPatternTarget(map, pattern, lookup) {
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
|
|
3
|
+
const testFilePattern = /\.test\.tsx?$/u;
|
|
4
|
+
|
|
5
|
+
// the statements a test file holds besides its tests and `setupTest()`:
|
|
6
|
+
// imports and type-only declarations, which run nothing at module level
|
|
7
|
+
const allowedStatementTypes = new Set([
|
|
8
|
+
'ImportDeclaration',
|
|
9
|
+
'TSTypeAliasDeclaration',
|
|
10
|
+
'TSInterfaceDeclaration',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
// the members a `test` call may chain: `test.each(rows)(…)`,
|
|
14
|
+
// `test.skipIf(gate)(…)`, `test.if(gate)(…)`, and chains of them
|
|
15
|
+
const testModifiers = new Set(['each', 'skipIf', 'if']);
|
|
16
|
+
|
|
17
|
+
const setupTestOnly = {
|
|
18
|
+
meta: {
|
|
19
|
+
type: 'problem',
|
|
20
|
+
messages: {
|
|
21
|
+
declaration:
|
|
22
|
+
'Remove the module-level `{{name}}`: a test file declares one function, `setupTest()`, and nothing else. Inline it where it is used, swap it for a registered matcher, or move it to the shared test utils with tests of its own. A module-level `const` stays only as a skip gate that `skipIf(...)` arguments alone read.',
|
|
23
|
+
statement:
|
|
24
|
+
'Move this statement into a test or `setupTest()`: at module level a test file holds only imports, types, `setupTest()` and `test` calls.',
|
|
25
|
+
},
|
|
26
|
+
schema: [],
|
|
27
|
+
},
|
|
28
|
+
create(context) {
|
|
29
|
+
return {
|
|
30
|
+
Program(program) {
|
|
31
|
+
if (!testFilePattern.test(basename(context.physicalFilename))) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const testNames = collectTestNames(context, program);
|
|
36
|
+
const constants = [];
|
|
37
|
+
let hasSetupTest = false;
|
|
38
|
+
|
|
39
|
+
for (const statement of program.body) {
|
|
40
|
+
if (allowedStatementTypes.has(statement.type) || isTestCall(statement, testNames)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (
|
|
45
|
+
statement.type === 'FunctionDeclaration' &&
|
|
46
|
+
statement.id?.name === 'setupTest' &&
|
|
47
|
+
!hasSetupTest
|
|
48
|
+
) {
|
|
49
|
+
hasSetupTest = true;
|
|
50
|
+
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (statement.type === 'VariableDeclaration' && statement.kind === 'const') {
|
|
55
|
+
constants.push(...statement.declarations);
|
|
56
|
+
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
emitReport(context, statement);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const gates = collectSkipGates(context, constants, testNames);
|
|
64
|
+
const declarations = new Set(constants.map((declarator) => declarator.parent));
|
|
65
|
+
|
|
66
|
+
for (const declaration of declarations) {
|
|
67
|
+
const leaks = declaration.declarations.filter((declarator) => !gates.has(declarator));
|
|
68
|
+
|
|
69
|
+
if (leaks.length > 0) {
|
|
70
|
+
emitReport(context, declaration, leaks);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// the local names that `test` from `bun:test` goes by, so an aliased import
|
|
79
|
+
// such as `import { test as check }` counts; a file that imports no `test`
|
|
80
|
+
// reads the global one, unless a module binding of its own takes the name
|
|
81
|
+
function collectTestNames(context, program) {
|
|
82
|
+
const names = program.body
|
|
83
|
+
.filter(
|
|
84
|
+
(statement) =>
|
|
85
|
+
statement.type === 'ImportDeclaration' &&
|
|
86
|
+
statement.source.value === 'bun:test' &&
|
|
87
|
+
statement.importKind !== 'type',
|
|
88
|
+
)
|
|
89
|
+
.flatMap((statement) => statement.specifiers)
|
|
90
|
+
.filter(
|
|
91
|
+
(specifier) =>
|
|
92
|
+
specifier.type === 'ImportSpecifier' &&
|
|
93
|
+
specifier.importKind !== 'type' &&
|
|
94
|
+
getImportedName(specifier) === 'test',
|
|
95
|
+
)
|
|
96
|
+
.map((specifier) => specifier.local.name);
|
|
97
|
+
|
|
98
|
+
if (names.length === 0 && !hasModuleBinding(context, program, 'test')) {
|
|
99
|
+
return new Set(['test']);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return new Set(names);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// every import form and every module-level declaration defines a binding in
|
|
106
|
+
// the module scope, which ESM nests under the global one
|
|
107
|
+
function hasModuleBinding(context, program, name) {
|
|
108
|
+
const scope = context.sourceCode.getScope(program);
|
|
109
|
+
const scopes = [scope, ...scope.childScopes.filter((child) => child.type === 'module')];
|
|
110
|
+
|
|
111
|
+
return scopes.some((candidate) => (candidate.set.get(name)?.defs.length ?? 0) > 0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getImportedName(specifier) {
|
|
115
|
+
return specifier.imported.type === 'Identifier'
|
|
116
|
+
? specifier.imported.name
|
|
117
|
+
: specifier.imported.value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isTestCall(statement, testNames) {
|
|
121
|
+
return (
|
|
122
|
+
statement.type === 'ExpressionStatement' &&
|
|
123
|
+
statement.expression.type === 'CallExpression' &&
|
|
124
|
+
isTestRunner(statement.expression.callee, testNames)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// `test` itself or a chain of its modifiers, such as `test.skipIf(gate).each(rows)`
|
|
129
|
+
function isTestRunner(node, testNames) {
|
|
130
|
+
let current = node;
|
|
131
|
+
|
|
132
|
+
while (current.type !== 'Identifier') {
|
|
133
|
+
if (current.type === 'CallExpression') {
|
|
134
|
+
current = current.callee;
|
|
135
|
+
} else if (
|
|
136
|
+
current.type === 'MemberExpression' &&
|
|
137
|
+
!current.computed &&
|
|
138
|
+
testModifiers.has(current.property.name)
|
|
139
|
+
) {
|
|
140
|
+
current = current.object;
|
|
141
|
+
} else {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return testNames.has(current.name);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function emitReport(context, statement, declarators) {
|
|
150
|
+
const name = findDeclaredName(context, statement, declarators);
|
|
151
|
+
|
|
152
|
+
if (name === undefined) {
|
|
153
|
+
context.report({ node: statement, messageId: 'statement' });
|
|
154
|
+
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
context.report({ node: statement, messageId: 'declaration', data: { name } });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function findDeclaredName(context, statement, declarators) {
|
|
162
|
+
if (statement.type === 'VariableDeclaration') {
|
|
163
|
+
return (declarators ?? statement.declarations)
|
|
164
|
+
.flatMap((declarator) => context.sourceCode.getDeclaredVariables(declarator))
|
|
165
|
+
.map((variable) => variable.name)
|
|
166
|
+
.join('`, `');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return statement.id?.name;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// a gate is a declarator whose every binding is read, only in a `skipIf(...)`
|
|
173
|
+
// argument or another gate's initializer; the set grows out from the `skipIf`
|
|
174
|
+
// reads, so consts that only read each other never join it
|
|
175
|
+
function collectSkipGates(context, constants, testNames) {
|
|
176
|
+
const gates = new Set();
|
|
177
|
+
let changed = true;
|
|
178
|
+
|
|
179
|
+
while (changed) {
|
|
180
|
+
changed = false;
|
|
181
|
+
|
|
182
|
+
for (const declarator of constants) {
|
|
183
|
+
if (!gates.has(declarator) && isSkipGate(context, declarator, gates, testNames)) {
|
|
184
|
+
gates.add(declarator);
|
|
185
|
+
changed = true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return gates;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isSkipGate(context, declarator, gates, testNames) {
|
|
194
|
+
const variables = context.sourceCode.getDeclaredVariables(declarator);
|
|
195
|
+
|
|
196
|
+
// a declarator that binds nothing, such as `const {} = registerHooks()`, is
|
|
197
|
+
// a module-level call, not a gate
|
|
198
|
+
return (
|
|
199
|
+
variables.length > 0 &&
|
|
200
|
+
variables.every((variable) => {
|
|
201
|
+
const reads = variable.references.filter(
|
|
202
|
+
(reference) => !reference.init && reference.isRead(),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
reads.length > 0 &&
|
|
207
|
+
reads.every(
|
|
208
|
+
(reference) =>
|
|
209
|
+
isInSkipIfArgument(reference.identifier, testNames) ||
|
|
210
|
+
isInGateInit(reference.identifier, gates),
|
|
211
|
+
)
|
|
212
|
+
);
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const functionTypes = new Set([
|
|
218
|
+
'FunctionDeclaration',
|
|
219
|
+
'FunctionExpression',
|
|
220
|
+
'ArrowFunctionExpression',
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
// a read counts only at collection time, so no function may enclose it: a
|
|
224
|
+
// `skipIf` inside `setupTest()` or a test runs later
|
|
225
|
+
function isInSkipIfArgument(identifier, testNames) {
|
|
226
|
+
let child = identifier;
|
|
227
|
+
let isSkipIfArgument = false;
|
|
228
|
+
|
|
229
|
+
for (let node = identifier.parent; node !== null && node !== undefined; node = node.parent) {
|
|
230
|
+
if (functionTypes.has(node.type)) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (
|
|
235
|
+
node.type === 'CallExpression' &&
|
|
236
|
+
node.arguments.includes(child) &&
|
|
237
|
+
node.callee.type === 'MemberExpression' &&
|
|
238
|
+
!node.callee.computed &&
|
|
239
|
+
node.callee.property.name === 'skipIf' &&
|
|
240
|
+
isTestRunner(node.callee.object, testNames)
|
|
241
|
+
) {
|
|
242
|
+
isSkipIfArgument = true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
child = node;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return isSkipIfArgument;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isInGateInit(identifier, gates) {
|
|
252
|
+
let child = identifier;
|
|
253
|
+
|
|
254
|
+
for (let node = identifier.parent; isOutsideFunction(node); node = node.parent) {
|
|
255
|
+
if (node.type === 'VariableDeclarator') {
|
|
256
|
+
return node.init === child && gates.has(node);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
child = node;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isOutsideFunction(node) {
|
|
266
|
+
return node !== null && node !== undefined && !functionTypes.has(node.type);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export default setupTestOnly;
|