@zgeoff/oxlint-config 0.1.0 → 0.2.0

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,7 +6,8 @@ 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.
9
+ function-naming taxonomy from the shared agent guidelines, and `zgeoff/test-beside-module` keeps
10
+ each test file beside the module it tests.
10
11
 
11
12
  ## Usage
12
13
 
@@ -71,6 +72,22 @@ whitespace change.
71
72
  }
72
73
  ```
73
74
 
75
+ ## `zgeoff/test-beside-module`
76
+
77
+ A `<name>.test.ts` or `<name>.test.tsx` file needs a non-test file `<name>.<ext>` in the same
78
+ directory — `.ts`, `.tsx`, `.sh`, `.json`, or any other single extension, so `install.test.ts` may
79
+ test `install.sh`. Files under an `e2e/` or `evals/` directory at the repo root (the nearest
80
+ ancestor holding `.git`, else oxlint's working directory) are exempt; no other directory is. Exempt
81
+ any other test with a file-level disable comment that states the reason. The rule reports at the
82
+ first statement, so put the comment above it:
83
+
84
+ ```ts
85
+ // oxlint-disable zgeoff/test-beside-module -- tests base.json and react.json together
86
+ ```
87
+
88
+ A second test file for a module (`<name>.host.test.ts`) needs its own `<name>.host.<ext>`, so the
89
+ rule reports it beside only `<name>.ts`.
90
+
74
91
  ## Notes
75
92
 
76
93
  - The config enables the `typescript`, `unicorn`, `oxc`, `import`, and `promise` plugins and loads
package/oxlintrc.json CHANGED
@@ -28,6 +28,7 @@
28
28
  "zgeoff/no-jsdoc": "error",
29
29
  "zgeoff/max-consecutive-line-comments": ["error", { "max": 3 }],
30
30
  "zgeoff/function-verb": "error",
31
+ "zgeoff/test-beside-module": "error",
31
32
 
32
33
  // enforced comment style — line comments unless JSDoc or exclamation
33
34
  "@stylistic/multiline-comment-style": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/oxlint-config",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Shareable oxlint config: strict categories, house-style rules, and a custom JS plugin",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,6 +14,7 @@
14
14
  "function-verb.js",
15
15
  "max-consecutive-line-comments.js",
16
16
  "no-jsdoc.js",
17
+ "test-beside-module.js",
17
18
  "index.js"
18
19
  ],
19
20
  "type": "module",
package/plugin.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import functionVerb from './function-verb.js';
2
2
  import maxConsecutiveLineComments from './max-consecutive-line-comments.js';
3
3
  import noJSDoc from './no-jsdoc.js';
4
+ import testBesideModule from './test-beside-module.js';
4
5
 
5
6
  // custom rules loaded through oxlint's jsPlugins (ESLint v9 rule API): each
6
7
  // bans a shape no native oxlint rule can express, as an esquery selector or a
@@ -95,6 +96,7 @@ const plugin = {
95
96
  'no-jsdoc': noJSDoc,
96
97
  'max-consecutive-line-comments': maxConsecutiveLineComments,
97
98
  'function-verb': functionVerb,
99
+ 'test-beside-module': testBesideModule,
98
100
  },
99
101
  };
100
102
 
@@ -0,0 +1,93 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { basename, dirname, join, relative, sep } from 'node:path';
3
+
4
+ const testFilePattern = /^(?<base>.+)\.test\.tsx?$/u;
5
+
6
+ // suites that run the whole program have no single module to sit beside
7
+ const exemptRootDirs = new Set(['e2e', 'evals']);
8
+
9
+ function isUnderExemptRoot(cwd, filename) {
10
+ const [first] = relative(findRepoRoot(filename) ?? cwd, filename).split(sep);
11
+
12
+ return exemptRootDirs.has(first);
13
+ }
14
+
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
+ // a module is `<name>.<ext>` with a single-segment extension, so a
34
+ // declaration file never stands in for one, and neither does another test
35
+ // (`foo.test.ts` beside `foo.test.test.ts`)
36
+ function hasModuleSibling(dir, name) {
37
+ return readdirSync(dir).some((entry) => {
38
+ const dot = entry.lastIndexOf('.');
39
+
40
+ return (
41
+ dot > 0 &&
42
+ entry.slice(0, dot) === name &&
43
+ !testFilePattern.test(entry) &&
44
+ isFile(join(dir, entry))
45
+ );
46
+ });
47
+ }
48
+
49
+ // follows a symlink, so a linked module counts as the file it points at
50
+ function isFile(path) {
51
+ return statSync(path, { throwIfNoEntry: false })?.isFile() ?? false;
52
+ }
53
+
54
+ const testBesideModule = {
55
+ meta: {
56
+ type: 'problem',
57
+ messages: {
58
+ missing:
59
+ 'Move this test beside the module it tests: no `{{name}}.<ext>` sits in this directory. Whole-program suites belong in a root `e2e/` or `evals/` directory.',
60
+ },
61
+ schema: [],
62
+ },
63
+ create(context) {
64
+ return {
65
+ Program() {
66
+ const filename = context.physicalFilename;
67
+ const match = testFilePattern.exec(basename(filename));
68
+
69
+ if (match === null || isUnderExemptRoot(context.cwd, filename)) {
70
+ return;
71
+ }
72
+
73
+ const name = match.groups.base;
74
+
75
+ if (hasModuleSibling(dirname(filename), name)) {
76
+ return;
77
+ }
78
+
79
+ // anchor on the first statement, not offset 0, so a disable comment
80
+ // at the top of the file sits before the report and covers it
81
+ const start = context.sourceCode.ast.body[0]?.loc.start ?? { line: 1, column: 0 };
82
+
83
+ context.report({
84
+ loc: { start, end: start },
85
+ messageId: 'missing',
86
+ data: { name },
87
+ });
88
+ },
89
+ };
90
+ },
91
+ };
92
+
93
+ export default testBesideModule;