@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 +37 -3
- package/function-verb.js +0 -9
- package/max-consecutive-line-comments.js +76 -0
- package/no-jsdoc.js +40 -0
- package/oxlintrc.json +3 -1
- package/package.json +4 -1
- package/plugin.js +9 -81
- package/test-beside-module.js +93 -0
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,
|
|
8
|
-
|
|
9
|
-
|
|
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-
|
|
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
|
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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-
|
|
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;
|