@zgeoff/oxlint-config 0.2.12 → 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 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, and
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
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.12",
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,6 +19,7 @@
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",
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
  };
@@ -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;