@zgeoff/oxlint-config 0.0.6 → 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
@@ -4,9 +4,10 @@ Shareable [oxlint](https://oxc.rs/docs/guide/usage/linter.html) config: every ca
4
4
  restriction-rule cherry-picks, comment-style enforcement via `@stylistic`, and a bundled JS plugin
5
5
  (`zgeoff/*` rules) banning shapes no native oxlint rule can express — top-level arrows, nested
6
6
  function declarations, bare named exports, destructured and inline-typed parameters, ternary and
7
- await arguments, awaits hidden inside a control-flow condition or a `&&`/`||`/`??` chain, and
8
- single-line `/** … */` blocks (auto-fixed to multi-line; inline `@type`/`@lends` casts exempt).
9
- `zgeoff/function-verb` enforces the function-naming taxonomy from the shared agent guidelines.
7
+ await arguments, awaits hidden inside a control-flow condition or a `&&`/`||`/`??` chain, every
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.
10
11
 
11
12
  ## Usage
12
13
 
@@ -54,6 +55,39 @@ Options extend the shipped set per repo:
54
55
  }
55
56
  ```
56
57
 
58
+ ## `zgeoff/no-jsdoc` and `zgeoff/max-consecutive-line-comments`
59
+
60
+ A comment holds only what neither the code, a type, a test name, nor the subsystem doc can hold: the
61
+ reason a line does the non-obvious thing. `no-jsdoc` reports every `/** … */` block (inline
62
+ `@type`/`@lends` casts exempt). `max-consecutive-line-comments` reports a run of own-line `//`
63
+ comments longer than `max` (default 3); tool directives (`oxlint-`, `eslint-`, `@ts-`, …) neither
64
+ count nor join the prose around them. Neither rule fixes: a deleted comment is a decision, not a
65
+ whitespace change.
66
+
67
+ ```jsonc
68
+ {
69
+ "rules": {
70
+ "zgeoff/max-consecutive-line-comments": ["error", { "max": 3 }],
71
+ },
72
+ }
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
+
57
91
  ## Notes
58
92
 
59
93
  - The config enables the `typescript`, `unicorn`, `oxc`, `import`, and `promise` plugins and loads
package/function-verb.js CHANGED
@@ -148,15 +148,6 @@ function planReport(name, verbs, exemptNames) {
148
148
  };
149
149
  }
150
150
 
151
- /**
152
- * Enforces the function-naming taxonomy on function declarations,
153
- * function-valued variables, and class methods. Object-literal properties are
154
- * exempt — they overwhelmingly implement externally-defined shapes (rule
155
- * visitors, route tables) whose names the author doesn't choose — as are
156
- * names not starting with a lowercase letter (components, classes).
157
- * Options: `verbs` appends repo-local verbs to the shipped taxonomy;
158
- * `exemptNames` skips exact names.
159
- */
160
151
  const functionVerb = {
161
152
  meta: {
162
153
  type: 'suggestion',
@@ -0,0 +1,76 @@
1
+ // tool directives are not prose: they neither count toward a run nor join
2
+ // the prose on either side of them into one
3
+ const directivePattern =
4
+ /^\s*(?:oxlint-|eslint-|@ts-|prettier-|oxfmt-|biome-ignore|#region|#endregion|\/\s*<)/u;
5
+
6
+ function isOwnLine(text, comment) {
7
+ const lineStart = text.lastIndexOf('\n', comment.range[0] - 1) + 1;
8
+
9
+ return /^[ \t]*$/u.test(text.slice(lineStart, comment.range[0]));
10
+ }
11
+
12
+ function isProseLine(text, comment) {
13
+ return (
14
+ comment.type === 'Line' && !directivePattern.test(comment.value) && isOwnLine(text, comment)
15
+ );
16
+ }
17
+
18
+ // a run is prose line comments on consecutive lines; whatever else occupies
19
+ // a line between two of them (code, a blank, a directive, a block comment)
20
+ // breaks adjacency and so ends the run
21
+ function collectRuns(text, comments) {
22
+ const runs = [];
23
+
24
+ for (const comment of comments.filter((candidate) => isProseLine(text, candidate))) {
25
+ const run = runs.at(-1);
26
+
27
+ if (run !== undefined && comment.loc.start.line === run.at(-1).loc.end.line + 1) {
28
+ run.push(comment);
29
+ } else {
30
+ runs.push([comment]);
31
+ }
32
+ }
33
+
34
+ return runs;
35
+ }
36
+
37
+ const maxConsecutiveLineComments = {
38
+ meta: {
39
+ type: 'suggestion',
40
+ messages: {
41
+ tooLong:
42
+ 'This comment run is {{count}} lines; the limit is {{max}}. Cut it to the fact the code cannot show, or move it into a test name or the subsystem doc.',
43
+ },
44
+ schema: [
45
+ {
46
+ type: 'object',
47
+ properties: {
48
+ max: { type: 'integer', minimum: 1 },
49
+ },
50
+ additionalProperties: false,
51
+ },
52
+ ],
53
+ },
54
+ create(context) {
55
+ const max = context.options[0]?.max ?? 3;
56
+
57
+ return {
58
+ Program() {
59
+ const text = context.sourceCode.text;
60
+ const runs = collectRuns(text, context.sourceCode.getAllComments());
61
+
62
+ for (const run of runs) {
63
+ if (run.length > max) {
64
+ context.report({
65
+ loc: { start: run[0].loc.start, end: run.at(-1).loc.end },
66
+ messageId: 'tooLong',
67
+ data: { count: String(run.length), max: String(max) },
68
+ });
69
+ }
70
+ }
71
+ },
72
+ };
73
+ },
74
+ };
75
+
76
+ export default maxConsecutiveLineComments;
package/no-jsdoc.js ADDED
@@ -0,0 +1,40 @@
1
+ // mirrors eslint-plugin-jsdoc's default singleLineTags: a block that exists to
2
+ // cast inline (`/** @type {Foo} */ (bar)`) is a type annotation, not prose
3
+ const inlineTagPattern = /^@(?:type|lends)\b/u;
4
+
5
+ function isJSDoc(comment) {
6
+ return comment.type === 'Block' && comment.value.startsWith('*');
7
+ }
8
+
9
+ function isInlineCast(comment) {
10
+ return (
11
+ comment.loc.start.line === comment.loc.end.line &&
12
+ inlineTagPattern.test(comment.value.slice(1).trim())
13
+ );
14
+ }
15
+
16
+ const noJSDoc = {
17
+ meta: {
18
+ type: 'suggestion',
19
+ messages: {
20
+ jsdoc:
21
+ 'Delete this JSDoc block. A rule a caller must respect is a test whose name states it or a sentence in the subsystem doc; the reason a line does the non-obvious thing is a `//` at that line.',
22
+ },
23
+ schema: [],
24
+ },
25
+ create(context) {
26
+ return {
27
+ Program() {
28
+ const offenders = context.sourceCode
29
+ .getAllComments()
30
+ .filter((comment) => isJSDoc(comment) && !isInlineCast(comment));
31
+
32
+ for (const comment of offenders) {
33
+ context.report({ loc: comment.loc, messageId: 'jsdoc' });
34
+ }
35
+ },
36
+ };
37
+ },
38
+ };
39
+
40
+ export default noJSDoc;
package/oxlintrc.json CHANGED
@@ -25,8 +25,10 @@
25
25
  "zgeoff/no-await-args": "error",
26
26
  "zgeoff/no-await-in-condition": "error",
27
27
  "zgeoff/no-await-in-logical": "error",
28
- "zgeoff/no-single-line-jsdoc": "error",
28
+ "zgeoff/no-jsdoc": "error",
29
+ "zgeoff/max-consecutive-line-comments": ["error", { "max": 3 }],
29
30
  "zgeoff/function-verb": "error",
31
+ "zgeoff/test-beside-module": "error",
30
32
 
31
33
  // enforced comment style — line comments unless JSDoc or exclamation
32
34
  "@stylistic/multiline-comment-style": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/oxlint-config",
3
- "version": "0.0.6",
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": {
@@ -12,6 +12,9 @@
12
12
  "oxlintrc.json",
13
13
  "plugin.js",
14
14
  "function-verb.js",
15
+ "max-consecutive-line-comments.js",
16
+ "no-jsdoc.js",
17
+ "test-beside-module.js",
15
18
  "index.js"
16
19
  ],
17
20
  "type": "module",
package/plugin.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import functionVerb from './function-verb.js';
2
+ import maxConsecutiveLineComments from './max-consecutive-line-comments.js';
3
+ import noJSDoc from './no-jsdoc.js';
4
+ import testBesideModule from './test-beside-module.js';
2
5
 
3
- // Custom lint rules, loaded through oxlint's jsPlugins (ESLint v9 rule API).
4
- // Each rule bans a shape no native oxlint rule can express — esquery selectors
5
- // for AST shapes, a source-comment scan where the target isn't
6
- // esquery-selectable.
6
+ // custom rules loaded through oxlint's jsPlugins (ESLint v9 rule API): each
7
+ // bans a shape no native oxlint rule can express, as an esquery selector or a
8
+ // source-comment scan
7
9
 
8
10
  function buildBanRule(type, message, selectors) {
9
11
  return {
@@ -19,82 +21,6 @@ function buildBanRule(type, message, selectors) {
19
21
  };
20
22
  }
21
23
 
22
- // mirrors eslint-plugin-jsdoc's default singleLineTags: blocks that exist to
23
- // cast inline (`/** @type {Foo} */ (bar)`) stay single-line by design
24
- const inlineTagPattern = /^@(?:type|lends)\b/u;
25
-
26
- function isSingleLineJSDoc(comment) {
27
- return (
28
- comment.type === 'Block' &&
29
- comment.value.startsWith('*') &&
30
- comment.loc.start.line === comment.loc.end.line
31
- );
32
- }
33
-
34
- /**
35
- * Builds the multi-line replacement for a single-line JSDoc comment, or null
36
- * when the comment shares its line with code — expanding those in place would
37
- * scramble the surrounding statement, so they are reported without a fix.
38
- */
39
- function planExpansion(text, comment) {
40
- if (!isOwnLine(text, comment)) {
41
- return null;
42
- }
43
-
44
- const lineStart = text.lastIndexOf('\n', comment.range[0] - 1) + 1;
45
- const indent = text.slice(lineStart, comment.range[0]);
46
- const body = comment.value.slice(1).trim();
47
- const bodyLine = body === '' ? `${indent} *` : `${indent} * ${body}`;
48
-
49
- return `/**\n${bodyLine}\n${indent} */`;
50
- }
51
-
52
- function isOwnLine(text, comment) {
53
- const [start, end] = comment.range;
54
- const lineStart = text.lastIndexOf('\n', start - 1) + 1;
55
- const lineEnd = text.indexOf('\n', end);
56
- const sliceEnd = lineEnd === -1 ? text.length : lineEnd;
57
-
58
- return /^[ \t]*$/u.test(text.slice(lineStart, start)) && text.slice(end, sliceEnd).trim() === '';
59
- }
60
-
61
- const noSingleLineJSDoc = {
62
- meta: {
63
- type: 'layout',
64
- fixable: 'whitespace',
65
- messages: {
66
- singleLine:
67
- 'Write JSDoc blocks multi-line: `/**` alone, one `*`-prefixed line per point, `*/` alone.',
68
- },
69
- schema: [],
70
- },
71
- create(context) {
72
- return {
73
- Program() {
74
- const offenders = context.sourceCode
75
- .getAllComments()
76
- .filter(
77
- (comment) =>
78
- isSingleLineJSDoc(comment) && !inlineTagPattern.test(comment.value.slice(1).trim()),
79
- );
80
-
81
- for (const comment of offenders) {
82
- const replacement = planExpansion(context.sourceCode.text, comment);
83
-
84
- context.report({
85
- loc: comment.loc,
86
- messageId: 'singleLine',
87
- fix:
88
- replacement === null
89
- ? undefined
90
- : (fixer) => fixer.replaceTextRange(comment.range, replacement),
91
- });
92
- }
93
- },
94
- };
95
- },
96
- };
97
-
98
24
  const plugin = {
99
25
  meta: { name: 'zgeoff' },
100
26
  rules: {
@@ -167,8 +93,10 @@ const plugin = {
167
93
  'Await into a named const instead of chaining it after a && / || / ?? operator.',
168
94
  ['LogicalExpression > AwaitExpression'],
169
95
  ),
170
- 'no-single-line-jsdoc': noSingleLineJSDoc,
96
+ 'no-jsdoc': noJSDoc,
97
+ 'max-consecutive-line-comments': maxConsecutiveLineComments,
171
98
  'function-verb': functionVerb,
99
+ 'test-beside-module': testBesideModule,
172
100
  },
173
101
  };
174
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;