@tenphi/eslint-plugin-tasty 1.19.0 → 1.19.1
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 +10 -2
- package/docs/rules/consistent-token-usage.md +2 -0
- package/docs/rules/no-classname-prop.md +2 -0
- package/docs/rules/no-protected-restyle.md +2 -0
- package/docs/rules/no-raw-color-values.md +2 -0
- package/docs/rules/no-style-prop.md +2 -0
- package/docs/rules/no-style-spread.md +2 -0
- package/docs/rules/no-styles-prop.md +2 -0
- package/harness/dev.harnesspm/rules/usage.md +1 -1
- package/harness/dev.harnesspm/scripts/check-edit.mjs +45 -9
- package/harness/dev.harnesspm/scripts/check.mjs +122 -27
- package/harness/dev.harnesspm/scripts/lint.mjs +100 -18
- package/harness/dev.harnesspm/scripts/project-config.mjs +316 -0
- package/harness/plugin.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -419,6 +419,7 @@ usages still report. In multiline JSX, target the line containing the reported
|
|
|
419
419
|
attribute or spread property. JSX child comments can use
|
|
420
420
|
`{/* eslint-disable-next-line tasty/no-classname-prop -- library requires this class */}`
|
|
421
421
|
immediately above the affected element when the attribute is on that next line.
|
|
422
|
+
Oxlint reads these comments too, and its own `oxlint-disable-next-line` form.
|
|
422
423
|
|
|
423
424
|
If `react/forbid-component-props` or `react/forbid-dom-props` also forbids
|
|
424
425
|
`style`, a `style` exception has to name both rules, and the React part hides
|
|
@@ -643,7 +644,7 @@ The package ships an agent harness for [harnesspm](https://www.npmjs.com/package
|
|
|
643
644
|
|
|
644
645
|
- a short always-on rule: lint the files you change and fix every `tasty/*` problem;
|
|
645
646
|
- the rule pages of the installed version, in the docs index;
|
|
646
|
-
- `npx harnesspm run tasty-lint:check <files> [--strict] [--json]`, which runs only the `tasty/*` rules
|
|
647
|
+
- `npx harnesspm run tasty-lint:check <files> [--strict] [--json]`, which runs only the `tasty/*` rules and prints each problem with its rule's page;
|
|
647
648
|
- a Claude Code hook that lints each file the agent edits, when it imports Tasty or a configured `importSources` module, and hands the new problems back to the agent.
|
|
648
649
|
|
|
649
650
|
The tool and the hook run only in projects that trust the package, in `harnesspm.json`:
|
|
@@ -652,7 +653,14 @@ The tool and the hook run only in projects that trust the package, in `harnesspm
|
|
|
652
653
|
{ "trust": { "@tenphi/eslint-plugin-tasty": true } }
|
|
653
654
|
```
|
|
654
655
|
|
|
655
|
-
|
|
656
|
+
Both run the `tasty/*` rules as the project's own lint does, so they report the same problems:
|
|
657
|
+
|
|
658
|
+
- **Config.** Each file gets the levels and options of the `tasty/*` rules, and `settings.tasty`, from the nearest config above it that loads the plugin: `.oxlintrc.json`, `.oxlintrc.jsonc`, `oxlint.config.ts`, `oxlint.config.mts`, or an ESLint flat config that imports `@tenphi/eslint-plugin-tasty`. Overrides and ignored files apply. Without such a config, they run the recommended preset (`--strict`: the strict one). With a config, `--strict` adds the strict preset's rules that the config does not set.
|
|
659
|
+
- **Comments.** `eslint-disable` comments apply, and Oxlint's `oxlint-disable` ones unless the config is ESLint's.
|
|
660
|
+
- **`typeAwareJSX`.** `tasty-lint:check` uses it. The hook skips it, since building the TypeScript program takes seconds on every edit, and reads JSX props by their names.
|
|
661
|
+
- **Not covered.** Other plugins' rules do not run. A config the project's lint script names with `--config` is not found: only the names above count.
|
|
662
|
+
|
|
663
|
+
They need `eslint`, even in a project that lints with Oxlint, and `typescript-eslint` or `@typescript-eslint/parser` for TypeScript files. Without `eslint`, the hook stays silent and `tasty-lint:check` says what to install.
|
|
656
664
|
|
|
657
665
|
## License
|
|
658
666
|
|
|
@@ -44,6 +44,8 @@ const libraryElement = (
|
|
|
44
44
|
);
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
With Oxlint, `// oxlint-disable-next-line` works the same way.
|
|
48
|
+
|
|
47
49
|
Place the directive immediately before the reported attribute line. It suppresses
|
|
48
50
|
only this rule for that line. The warning message includes this exception path;
|
|
49
51
|
the plugin does not silently exempt library-provided values or enforce reasons.
|
|
@@ -183,6 +183,8 @@ A justified override takes a reasoned disable on its line:
|
|
|
183
183
|
<Button fill="#brand" />;
|
|
184
184
|
```
|
|
185
185
|
|
|
186
|
+
With Oxlint, `// oxlint-disable-next-line` works the same way.
|
|
187
|
+
|
|
186
188
|
## Relation to `no-styles-prop`
|
|
187
189
|
|
|
188
190
|
`tasty/no-styles-prop` reports instance `styles` on every recognized Tasty
|
|
@@ -37,6 +37,8 @@ const floating = (
|
|
|
37
37
|
);
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
With Oxlint, `// oxlint-disable-next-line` works the same way.
|
|
41
|
+
|
|
40
42
|
Place the directive immediately before the reported attribute line. It suppresses
|
|
41
43
|
only this rule for that line. The warning message includes this exception path;
|
|
42
44
|
the plugin does not silently exempt library-provided values or enforce reasons.
|
|
@@ -40,6 +40,8 @@ const adapted = (
|
|
|
40
40
|
);
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
With Oxlint, `// oxlint-disable-next-line` works the same way.
|
|
44
|
+
|
|
43
45
|
Place the directive immediately before the reported attribute line. It suppresses
|
|
44
46
|
only this rule for that line. The warning message includes this exception path;
|
|
45
47
|
the plugin does not silently exempt edge cases or enforce comment reasons.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The check-edit hook: after an agent edits a file that imports Tasty, lints
|
|
3
|
-
* it with the
|
|
3
|
+
* it with the Tasty rules the project's lint config sets (or the recommended
|
|
4
|
+
* ones) and hands the new problems back.
|
|
4
5
|
*
|
|
5
6
|
* Claude Code runs it on PostToolUse for Edit, Write and MultiEdit, with the
|
|
6
7
|
* event as JSON on stdin. Exit 0 says nothing. Exit 2 puts stderr in front of
|
|
@@ -18,11 +19,13 @@ import {
|
|
|
18
19
|
importsFrom,
|
|
19
20
|
isLintable,
|
|
20
21
|
isTypeScript,
|
|
22
|
+
lintOptions,
|
|
21
23
|
loadESLint,
|
|
22
24
|
loadTypeScriptParser,
|
|
23
25
|
newProblems,
|
|
24
26
|
rulePage,
|
|
25
27
|
} from './lint.mjs';
|
|
28
|
+
import { createConfigResolver, isOn } from './project-config.mjs';
|
|
26
29
|
|
|
27
30
|
/** More problems than this are summed up, not listed. */
|
|
28
31
|
const LIMIT = 20;
|
|
@@ -74,31 +77,56 @@ if (!importsFrom(text)) {
|
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
let problems;
|
|
80
|
+
let linter;
|
|
81
|
+
let typeAware = false;
|
|
77
82
|
try {
|
|
78
|
-
const { default: plugin, recommended } = await import(build('index.js'));
|
|
79
83
|
const dirs = [dirname(file), projectDir];
|
|
80
|
-
|
|
84
|
+
let loaded;
|
|
85
|
+
try {
|
|
86
|
+
loaded = await loadESLint(dirs, PACKAGE_ROOT);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
// A project that lints with Oxlint alone may have no ESLint. Then the hook
|
|
89
|
+
// stays out of the way, and `tasty-lint:check` says what to install.
|
|
90
|
+
if (error?.code === 'NO_ESLINT') process.exit(0);
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
const { ESLint, espree } = loaded;
|
|
94
|
+
const { default: plugin, recommended } = await import(build('index.js'));
|
|
95
|
+
const project = await createConfigResolver({
|
|
96
|
+
ESLint,
|
|
97
|
+
plugin,
|
|
98
|
+
packageRoot: PACKAGE_ROOT,
|
|
99
|
+
}).forFile(file);
|
|
100
|
+
linter = project.source?.linter;
|
|
101
|
+
const lint = lintOptions(project, { recommended, projectDir });
|
|
102
|
+
if (project.ignored || !Object.values(lint.rules).some(isOn)) process.exit(0);
|
|
81
103
|
const parser = await loadTypeScriptParser(dirs);
|
|
82
104
|
if (!parser && isTypeScript(file)) setupError(NO_PARSER);
|
|
105
|
+
// typeAwareJSX builds a TypeScript program of the whole project: on UI Kit,
|
|
106
|
+
// 13 s instead of 1 s for one file, on every edit. Without it, JSX props are
|
|
107
|
+
// classified by name, so `tasty-lint:check` has the last word on them.
|
|
108
|
+
const { typeAwareJSX, ...settings } = lint.settings;
|
|
109
|
+
typeAware = Boolean(typeAwareJSX);
|
|
83
110
|
const eslint = createLinter({
|
|
84
111
|
ESLint,
|
|
85
112
|
plugin,
|
|
86
|
-
rules: recommended,
|
|
87
113
|
parser,
|
|
88
|
-
|
|
114
|
+
espree,
|
|
115
|
+
...lint,
|
|
116
|
+
settings,
|
|
89
117
|
});
|
|
90
|
-
const
|
|
118
|
+
const lintText = async (source) =>
|
|
91
119
|
collectProblems(
|
|
92
120
|
await eslint.lintText(source, { filePath: file }),
|
|
93
121
|
projectDir,
|
|
94
122
|
).problems;
|
|
95
123
|
|
|
96
|
-
problems = await
|
|
124
|
+
problems = await lintText(text);
|
|
97
125
|
// Report only what this edit added, so problems an older line already had
|
|
98
126
|
// do not come back after every edit of the file.
|
|
99
127
|
const original = event.tool_response?.originalFile;
|
|
100
128
|
if (problems.length > 0 && typeof original === 'string' && original) {
|
|
101
|
-
problems = newProblems(problems, text, await
|
|
129
|
+
problems = newProblems(problems, text, await lintText(original), original);
|
|
102
130
|
}
|
|
103
131
|
} catch (error) {
|
|
104
132
|
setupError(error instanceof Error ? error.message : String(error));
|
|
@@ -112,8 +140,10 @@ const relativeFile = shown[0].file;
|
|
|
112
140
|
// agent's shell went last, such as a folder in node_modules.
|
|
113
141
|
const pageOf = (name) =>
|
|
114
142
|
rulePage(name, { projectDir, packageRoot: PACKAGE_ROOT, cwd: projectDir });
|
|
143
|
+
// The disable comment the project's linter reads.
|
|
144
|
+
const directive = `${linter === 'oxlint' ? 'oxlint' : 'eslint'}-disable-next-line`;
|
|
115
145
|
const out = [
|
|
116
|
-
`tasty-lint: this edit left ${problems.length} Tasty lint problem${problems.length === 1 ? '' : 's'} in ${relativeFile}. Fix ${problems.length === 1 ? 'it' : 'them'}. For an intentional exception, put \`//
|
|
146
|
+
`tasty-lint: this edit left ${problems.length} Tasty lint problem${problems.length === 1 ? '' : 's'} in ${relativeFile}. Fix ${problems.length === 1 ? 'it' : 'them'}. For an intentional exception, put \`// ${directive} tasty/<rule> -- <reason>\` above the line.`,
|
|
117
147
|
'',
|
|
118
148
|
formatProblems(shown, pageOf),
|
|
119
149
|
];
|
|
@@ -123,5 +153,11 @@ if (problems.length > LIMIT) {
|
|
|
123
153
|
`${problems.length - LIMIT} more: run \`npx harnesspm run tasty-lint:check ${relativeFile}\`.`,
|
|
124
154
|
);
|
|
125
155
|
}
|
|
156
|
+
if (typeAware) {
|
|
157
|
+
out.push(
|
|
158
|
+
'',
|
|
159
|
+
`This hook skips typeAwareJSX to stay fast, so it reads JSX props by their names. If a reported JSX prop is not a style, check it with \`npx harnesspm run tasty-lint:check ${relativeFile}\`.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
126
162
|
console.error(out.join('\n'));
|
|
127
163
|
process.exit(2);
|
|
@@ -1,31 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tasty-lint:check — lints files with this plugin's Tasty rules only,
|
|
3
|
-
* project's tasty.config.* and
|
|
2
|
+
* tasty-lint:check — lints files with this plugin's Tasty rules only, as the
|
|
3
|
+
* project's own lint config sets them, with the project's tasty.config.* and
|
|
4
|
+
* ESLint.
|
|
4
5
|
*/
|
|
5
|
-
import { existsSync, realpathSync } from 'node:fs';
|
|
6
|
-
import { join, resolve } from 'node:path';
|
|
6
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
7
|
+
import { join, relative, resolve } from 'node:path';
|
|
7
8
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
9
|
import {
|
|
9
10
|
NO_PARSER,
|
|
10
11
|
collectProblems,
|
|
11
12
|
createLinter,
|
|
12
13
|
formatProblems,
|
|
14
|
+
isLintable,
|
|
13
15
|
isTypeScript,
|
|
16
|
+
lintOptions,
|
|
14
17
|
loadESLint,
|
|
15
18
|
loadTypeScriptParser,
|
|
16
19
|
rulePage,
|
|
17
20
|
summarize,
|
|
18
21
|
} from './lint.mjs';
|
|
22
|
+
import { createConfigResolver, isOn } from './project-config.mjs';
|
|
19
23
|
|
|
20
24
|
const USAGE = `Usage: npx harnesspm run tasty-lint:check <files or folders>... [--strict] [--json]
|
|
21
25
|
|
|
22
|
-
Lints the files with the Tasty rules of @tenphi/eslint-plugin-tasty only
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
Lints the files with the Tasty rules of @tenphi/eslint-plugin-tasty only, as
|
|
27
|
+
the project's own lint runs them: each file gets the tasty/* rules, their
|
|
28
|
+
options and settings.tasty (such as typeAwareJSX) from the nearest
|
|
29
|
+
.oxlintrc.json, .oxlintrc.jsonc, oxlint.config.ts, oxlint.config.mts or ESLint
|
|
30
|
+
flat config above it that loads the plugin, and the files that config ignores
|
|
31
|
+
are skipped. Without such a config, it runs the recommended preset. The rules
|
|
32
|
+
read the project's tasty.config.* too. Other plugins' rules do not run.
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
Disable comments for the Tasty rules apply: // eslint-disable-next-line
|
|
35
|
+
tasty/<rule> -- <reason>, and the oxlint-disable forms, unless the config is
|
|
36
|
+
ESLint's.
|
|
29
37
|
|
|
30
38
|
A path is read from the current folder, else from the project root. Each
|
|
31
39
|
problem prints as file:line:col tasty/<rule> <severity> message, with the
|
|
@@ -33,7 +41,7 @@ file relative to the project root. The page of each rule reported follows. Exit
|
|
|
33
41
|
without errors (warnings allowed), 1 with errors, 2 when nothing could be
|
|
34
42
|
linted.
|
|
35
43
|
|
|
36
|
-
--strict also run the rules
|
|
44
|
+
--strict also run the strict preset's rules that the config does not set
|
|
37
45
|
--json print the problems as JSON`;
|
|
38
46
|
|
|
39
47
|
const PACKAGE_ROOT = realpathSync(
|
|
@@ -48,6 +56,9 @@ function fail(message) {
|
|
|
48
56
|
process.exit(2);
|
|
49
57
|
}
|
|
50
58
|
|
|
59
|
+
const errorText = (error) =>
|
|
60
|
+
error instanceof Error ? error.message : String(error);
|
|
61
|
+
|
|
51
62
|
const options = { strict: false, json: false, help: false };
|
|
52
63
|
const paths = [];
|
|
53
64
|
for (const arg of process.argv.slice(2)) {
|
|
@@ -81,45 +92,129 @@ const {
|
|
|
81
92
|
const preset = options.strict ? 'strict' : 'recommended';
|
|
82
93
|
|
|
83
94
|
let ESLint;
|
|
95
|
+
let espree;
|
|
84
96
|
let parser;
|
|
85
97
|
try {
|
|
86
|
-
ESLint = await loadESLint([cwd, projectDir], PACKAGE_ROOT);
|
|
98
|
+
({ ESLint, espree } = await loadESLint([cwd, projectDir], PACKAGE_ROOT));
|
|
87
99
|
parser = await loadTypeScriptParser([cwd, projectDir]);
|
|
88
100
|
} catch (error) {
|
|
89
|
-
fail(
|
|
101
|
+
fail(errorText(error));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const show = (file) => relative(projectDir, file) || file;
|
|
105
|
+
|
|
106
|
+
/** The files in a folder that the tools lint, as ESLint skips node_modules. */
|
|
107
|
+
function* walk(dir) {
|
|
108
|
+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
|
|
109
|
+
a.name < b.name ? -1 : 1,
|
|
110
|
+
);
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
const path = join(dir, entry.name);
|
|
113
|
+
if (entry.isDirectory()) {
|
|
114
|
+
if (entry.name !== 'node_modules' && entry.name !== '.git') {
|
|
115
|
+
yield* walk(path);
|
|
116
|
+
}
|
|
117
|
+
} else if (isLintable(entry.name)) {
|
|
118
|
+
yield path;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
90
121
|
}
|
|
91
122
|
|
|
92
|
-
|
|
123
|
+
// Each file gets the rules its config sets, so files are linted in groups of
|
|
124
|
+
// the same rules and settings. A file named on the command line that is not
|
|
125
|
+
// linted is reported; one found in a folder is skipped quietly, as ESLint does.
|
|
126
|
+
const resolver = createConfigResolver({
|
|
93
127
|
ESLint,
|
|
94
128
|
plugin,
|
|
95
|
-
|
|
96
|
-
parser,
|
|
97
|
-
cwd: projectDir,
|
|
129
|
+
packageRoot: PACKAGE_ROOT,
|
|
98
130
|
});
|
|
131
|
+
const groups = new Map();
|
|
132
|
+
const skipped = [];
|
|
133
|
+
const configs = new Set();
|
|
134
|
+
for (const target of targets) {
|
|
135
|
+
const named = !statSync(target).isDirectory();
|
|
136
|
+
for (const file of named ? [target] : walk(target)) {
|
|
137
|
+
if (!isLintable(file)) {
|
|
138
|
+
skipped.push({
|
|
139
|
+
file: show(file),
|
|
140
|
+
reason: 'not a JavaScript or TypeScript file.',
|
|
141
|
+
});
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
let project;
|
|
145
|
+
try {
|
|
146
|
+
project = await resolver.forFile(file);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
fail(
|
|
149
|
+
`cannot read the lint config for ${show(file)}: ${errorText(error)}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const config = project.source && show(project.source.file);
|
|
153
|
+
const lint = lintOptions(project, {
|
|
154
|
+
recommended,
|
|
155
|
+
strict,
|
|
156
|
+
useStrict: options.strict,
|
|
157
|
+
projectDir,
|
|
158
|
+
});
|
|
159
|
+
if (project.ignored || !Object.values(lint.rules).some(isOn)) {
|
|
160
|
+
if (named) {
|
|
161
|
+
skipped.push({
|
|
162
|
+
file: show(file),
|
|
163
|
+
reason: project.ignored
|
|
164
|
+
? `${config} does not lint it.`
|
|
165
|
+
: `${config} runs no tasty/* rule on it.`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (config) configs.add(config);
|
|
171
|
+
const key = JSON.stringify(lint);
|
|
172
|
+
if (!groups.has(key)) groups.set(key, { lint, files: [], config });
|
|
173
|
+
groups.get(key).files.push(file);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
99
176
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
177
|
+
const results = [];
|
|
178
|
+
for (const { lint, files } of groups.values()) {
|
|
179
|
+
const eslint = createLinter({ ESLint, plugin, parser, espree, ...lint });
|
|
180
|
+
try {
|
|
181
|
+
results.push(...(await eslint.lintFiles(files)));
|
|
182
|
+
} catch (error) {
|
|
183
|
+
fail(errorText(error));
|
|
184
|
+
}
|
|
105
185
|
}
|
|
186
|
+
results.sort((a, b) => (a.filePath < b.filePath ? -1 : 1));
|
|
106
187
|
|
|
107
188
|
if (!parser && results.some((result) => isTypeScript(result.filePath))) {
|
|
108
189
|
fail(NO_PARSER);
|
|
109
190
|
}
|
|
110
191
|
|
|
111
192
|
// Paths print relative to the project root, as harnesspm runs from there.
|
|
112
|
-
const
|
|
193
|
+
const collected = collectProblems(results, projectDir);
|
|
194
|
+
const { problems } = collected;
|
|
195
|
+
skipped.push(...collected.skipped);
|
|
113
196
|
const pageOf = (name) =>
|
|
114
197
|
rulePage(name, { projectDir, packageRoot: PACKAGE_ROOT, cwd: projectDir });
|
|
115
198
|
const errors = problems.filter((p) => p.severity === 'error').length;
|
|
116
|
-
const linted = results.length - skipped.length;
|
|
199
|
+
const linted = results.length - collected.skipped.length;
|
|
200
|
+
|
|
201
|
+
// Which rules ran: a config's, with --strict also the strict preset's others,
|
|
202
|
+
// and the preset for files without a config.
|
|
203
|
+
const sources = [...new Set([...groups.values()].map((g) => g.config ?? null))];
|
|
204
|
+
const ruleset = sources
|
|
205
|
+
.map((config) =>
|
|
206
|
+
config
|
|
207
|
+
? `rules from ${config}${options.strict ? ' and the strict preset' : ''}`
|
|
208
|
+
: `${preset} rules`,
|
|
209
|
+
)
|
|
210
|
+
.join('; ');
|
|
117
211
|
|
|
118
212
|
if (options.json) {
|
|
119
213
|
console.log(
|
|
120
214
|
JSON.stringify(
|
|
121
215
|
{
|
|
122
216
|
preset,
|
|
217
|
+
configs: [...configs],
|
|
123
218
|
files: results.map((r) => r.filePath),
|
|
124
219
|
linted,
|
|
125
220
|
problems: problems.map((p) => ({
|
|
@@ -138,16 +233,16 @@ if (options.json) {
|
|
|
138
233
|
if (linted === 0) {
|
|
139
234
|
out.push(`Nothing linted in ${paths.join(', ')}.`);
|
|
140
235
|
} else if (problems.length === 0) {
|
|
141
|
-
out.push(`No tasty/* problems in ${count} (${
|
|
236
|
+
out.push(`No tasty/* problems in ${count} (${ruleset}).`);
|
|
142
237
|
} else {
|
|
143
238
|
out.push(
|
|
144
239
|
formatProblems(problems, pageOf),
|
|
145
240
|
'',
|
|
146
|
-
`${summarize(problems)} in ${count} (${
|
|
241
|
+
`${summarize(problems)} in ${count} (${ruleset}).`,
|
|
147
242
|
);
|
|
148
243
|
}
|
|
149
244
|
for (const s of skipped) out.push(`Skipped ${s.file}: ${s.reason}`);
|
|
150
|
-
if (
|
|
245
|
+
if (linted === 0 && skipped.length === 0) {
|
|
151
246
|
out.push(
|
|
152
247
|
'Pass .js, .jsx, .ts or .tsx files of the project, or folders with them.',
|
|
153
248
|
);
|
|
@@ -8,6 +8,7 @@ import { existsSync, realpathSync } from 'node:fs';
|
|
|
8
8
|
import { createRequire } from 'node:module';
|
|
9
9
|
import { dirname, join, relative } from 'node:path';
|
|
10
10
|
import { pathToFileURL } from 'node:url';
|
|
11
|
+
import { SOURCES } from './project-config.mjs';
|
|
11
12
|
|
|
12
13
|
/** Files the tools lint: JavaScript and TypeScript, with or without JSX. */
|
|
13
14
|
const LINTABLE = /\.(?:[cm]?[jt]s|[jt]sx)$/;
|
|
@@ -40,22 +41,71 @@ export function importsFrom(text, sources = []) {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* `
|
|
44
|
+
* Oxlint reads `oxlint-disable`, `oxlint-disable-next-line`,
|
|
45
|
+
* `oxlint-disable-line` and `oxlint-enable` comments as ESLint reads the
|
|
46
|
+
* `eslint-` ones, and also takes `eslint-disable` and `eslint-enable` in line
|
|
47
|
+
* comments, which ESLint skips. ESLint reads directives from each comment's
|
|
48
|
+
* `value`, so the parser below rewrites the value of those comments after
|
|
49
|
+
* parsing. The source text is not touched, so strings, code, lines and
|
|
50
|
+
* columns stay as they are.
|
|
46
51
|
*/
|
|
47
|
-
|
|
52
|
+
const OXLINT_DIRECTIVE =
|
|
53
|
+
/^(\s*)oxlint-(disable(?:-next-line|-line)?|enable)(?=\s|$)/;
|
|
54
|
+
const BLOCK_DIRECTIVE = /^\s*eslint-(?:disable|enable)(?=\s|$)/;
|
|
55
|
+
|
|
56
|
+
export function readOxlintComments(comment) {
|
|
57
|
+
comment.value = comment.value.replace(OXLINT_DIRECTIVE, '$1eslint-$2');
|
|
58
|
+
if (comment.type === 'Line' && BLOCK_DIRECTIVE.test(comment.value)) {
|
|
59
|
+
comment.type = 'Block';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The parser with Oxlint's disable comments read as ESLint's. */
|
|
64
|
+
export function withOxlintComments(parser) {
|
|
65
|
+
return {
|
|
66
|
+
meta: { name: 'tasty-lint/oxlint-comments' },
|
|
67
|
+
parseForESLint(text, options) {
|
|
68
|
+
const result =
|
|
69
|
+
typeof parser.parseForESLint === 'function'
|
|
70
|
+
? parser.parseForESLint(text, options)
|
|
71
|
+
: { ast: parser.parse(text, options) };
|
|
72
|
+
for (const comment of result.ast.comments ?? []) {
|
|
73
|
+
readOxlintComments(comment);
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The ESLint flat config that runs only this plugin's rules: `rules` and
|
|
82
|
+
* `settings` (`settings.tasty`) come from the project's lint config, or the
|
|
83
|
+
* rules from a preset. `espree` is ESLint's own parser, for JavaScript, and
|
|
84
|
+
* `parser` the TypeScript one. With `oxlintComments`, Oxlint's disable
|
|
85
|
+
* comments count too.
|
|
86
|
+
*/
|
|
87
|
+
export function lintConfig({
|
|
88
|
+
plugin,
|
|
89
|
+
rules,
|
|
90
|
+
settings = {},
|
|
91
|
+
parser,
|
|
92
|
+
espree,
|
|
93
|
+
oxlintComments = true,
|
|
94
|
+
}) {
|
|
95
|
+
const read = (p) => (oxlintComments ? withOxlintComments(p) : p);
|
|
48
96
|
const config = [
|
|
49
97
|
{
|
|
50
|
-
files: [
|
|
98
|
+
files: [SOURCES],
|
|
51
99
|
plugins: { tasty: plugin },
|
|
52
100
|
// Directives for other plugins' rules are not this tool's business.
|
|
53
101
|
linterOptions: { reportUnusedDisableDirectives: false },
|
|
54
102
|
languageOptions: {
|
|
55
103
|
ecmaVersion: 'latest',
|
|
56
104
|
sourceType: 'module',
|
|
105
|
+
...(espree ? { parser: read(espree) } : {}),
|
|
57
106
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
|
58
107
|
},
|
|
108
|
+
settings: { tasty: settings },
|
|
59
109
|
rules,
|
|
60
110
|
},
|
|
61
111
|
];
|
|
@@ -64,21 +114,46 @@ export function lintConfig({ plugin, rules, parser }) {
|
|
|
64
114
|
files: ['**/*.{ts,tsx,mts,cts}'],
|
|
65
115
|
// The parser prints its TypeScript version warning on stdout, which
|
|
66
116
|
// would break --json. The project's own lint still shows it.
|
|
67
|
-
languageOptions: {
|
|
117
|
+
languageOptions: {
|
|
118
|
+
parser: read(parser),
|
|
119
|
+
parserOptions: { loggerFn: false },
|
|
120
|
+
},
|
|
68
121
|
});
|
|
69
122
|
}
|
|
70
123
|
return config;
|
|
71
124
|
}
|
|
72
125
|
|
|
73
|
-
export function createLinter({ ESLint,
|
|
126
|
+
export function createLinter({ ESLint, cwd, ...options }) {
|
|
74
127
|
return new ESLint({
|
|
75
128
|
cwd,
|
|
76
129
|
overrideConfigFile: true,
|
|
77
|
-
overrideConfig: lintConfig(
|
|
130
|
+
overrideConfig: lintConfig(options),
|
|
78
131
|
errorOnUnmatchedPattern: false,
|
|
79
132
|
});
|
|
80
133
|
}
|
|
81
134
|
|
|
135
|
+
/**
|
|
136
|
+
* What to lint a file with, from what `createConfigResolver().forFile()` found:
|
|
137
|
+
* the project's rules and settings, or the recommended preset when no config
|
|
138
|
+
* loads the plugin. `useStrict` adds the strict preset's rules that the config
|
|
139
|
+
* does not set. Rules and patterns resolve from the config's folder.
|
|
140
|
+
*/
|
|
141
|
+
export function lintOptions(
|
|
142
|
+
project,
|
|
143
|
+
{ recommended, strict, useStrict = false, projectDir },
|
|
144
|
+
) {
|
|
145
|
+
let rules = useStrict ? strict : recommended;
|
|
146
|
+
if (project.rules) {
|
|
147
|
+
rules = useStrict ? { ...strict, ...project.rules } : project.rules;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
rules,
|
|
151
|
+
settings: project.settings,
|
|
152
|
+
oxlintComments: project.source?.linter !== 'eslint',
|
|
153
|
+
cwd: project.source?.dir ?? projectDir,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
82
157
|
/**
|
|
83
158
|
* The problems in ESLint's results: this plugin's rules and parse errors. A
|
|
84
159
|
* message with no rule that is not fatal says that ESLint skipped the file.
|
|
@@ -177,25 +252,32 @@ async function importFile(file) {
|
|
|
177
252
|
return mod.default ?? mod;
|
|
178
253
|
}
|
|
179
254
|
|
|
255
|
+
export const NO_ESLINT =
|
|
256
|
+
'cannot find eslint. Install it in the project as a dev dependency: it is a peer dependency of @tenphi/eslint-plugin-tasty, and the tool runs the Tasty rules through ESLint even in a project that lints with Oxlint.';
|
|
257
|
+
|
|
180
258
|
/**
|
|
181
|
-
* ESLint as the project installs it, else the copy this package resolves
|
|
182
|
-
* flat config class
|
|
259
|
+
* ESLint as the project installs it, else the copy this package resolves: the
|
|
260
|
+
* flat config class (ESLint 9 and later, or 8.57 through `loadESLint()`), and
|
|
261
|
+
* espree, its JavaScript parser. Without ESLint, the error's `code` is
|
|
262
|
+
* `NO_ESLINT`.
|
|
183
263
|
*/
|
|
184
264
|
export async function loadESLint(dirs, packageRoot) {
|
|
185
265
|
for (const dir of [...dirs, packageRoot]) {
|
|
186
266
|
const file = resolveFrom(dir, 'eslint');
|
|
187
267
|
if (!file) continue;
|
|
188
268
|
const eslint = await importFile(file);
|
|
189
|
-
if (typeof eslint.loadESLint
|
|
190
|
-
|
|
269
|
+
if (typeof eslint.loadESLint !== 'function') {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`ESLint ${eslint.ESLint?.version ?? ''} at ${dirname(file)} is too old: the tool needs ESLint 8.57 or later.`,
|
|
272
|
+
);
|
|
191
273
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
274
|
+
const espree = resolveFrom(dirname(file), 'espree');
|
|
275
|
+
return {
|
|
276
|
+
ESLint: await eslint.loadESLint({ useFlatConfig: true }),
|
|
277
|
+
espree: espree ? await importFile(espree) : undefined,
|
|
278
|
+
};
|
|
195
279
|
}
|
|
196
|
-
throw new Error(
|
|
197
|
-
'cannot find eslint. Install it in the project: it is a peer dependency of @tenphi/eslint-plugin-tasty.',
|
|
198
|
-
);
|
|
280
|
+
throw Object.assign(new Error(NO_ESLINT), { code: 'NO_ESLINT' });
|
|
199
281
|
}
|
|
200
282
|
|
|
201
283
|
/**
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project's own Tasty lint config, so the tools report what the project's
|
|
3
|
+
* lint reports: the level and options of each tasty/* rule, `settings.tasty`,
|
|
4
|
+
* and the files the config ignores. It comes from the nearest Oxlint or ESLint
|
|
5
|
+
* config above a file that loads this plugin.
|
|
6
|
+
*
|
|
7
|
+
* Both are read through ESLint's `calculateConfigForFile()`: the project's ESLint
|
|
8
|
+
* config as it is, and an Oxlint config once it is turned into a flat config
|
|
9
|
+
* that holds only its Tasty part.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
12
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const NAME = '@tenphi/eslint-plugin-tasty';
|
|
15
|
+
|
|
16
|
+
/** The names Oxlint gives the plugin when `jsPlugins` lists it without one. */
|
|
17
|
+
const OXLINT_NAMES = ['@tenphi/tasty', NAME];
|
|
18
|
+
|
|
19
|
+
/** The files Oxlint 1.x finds by itself, in each folder from the file up. */
|
|
20
|
+
export const OXLINT_CONFIGS = [
|
|
21
|
+
'.oxlintrc.json',
|
|
22
|
+
'.oxlintrc.jsonc',
|
|
23
|
+
'oxlint.config.ts',
|
|
24
|
+
'oxlint.config.mts',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export const ESLINT_CONFIGS = [
|
|
28
|
+
'eslint.config.js',
|
|
29
|
+
'eslint.config.mjs',
|
|
30
|
+
'eslint.config.cjs',
|
|
31
|
+
'eslint.config.ts',
|
|
32
|
+
'eslint.config.mts',
|
|
33
|
+
'eslint.config.cts',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** The files the tools lint, in an ESLint `files` pattern. */
|
|
37
|
+
export const SOURCES = '**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}';
|
|
38
|
+
|
|
39
|
+
const LEVELS = {
|
|
40
|
+
off: 'off',
|
|
41
|
+
allow: 'off',
|
|
42
|
+
0: 'off',
|
|
43
|
+
warn: 'warn',
|
|
44
|
+
1: 'warn',
|
|
45
|
+
error: 'error',
|
|
46
|
+
deny: 'error',
|
|
47
|
+
2: 'error',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Whether a rule entry turns the rule on. */
|
|
51
|
+
export function isOn(entry) {
|
|
52
|
+
return LEVELS[Array.isArray(entry) ? entry[0] : entry] !== 'off';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* JSON with comments and trailing commas, as Oxlint reads `.oxlintrc.json`.
|
|
57
|
+
* Strings are copied as they are.
|
|
58
|
+
*/
|
|
59
|
+
export function parseJsonc(text) {
|
|
60
|
+
const withoutComments = replaceOutsideStrings(
|
|
61
|
+
text,
|
|
62
|
+
/\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$)/y,
|
|
63
|
+
' ',
|
|
64
|
+
);
|
|
65
|
+
return JSON.parse(
|
|
66
|
+
replaceOutsideStrings(withoutComments, /,(?=\s*[}\]])/y, ''),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Replaces each match of the sticky `pattern` that starts outside a string. */
|
|
71
|
+
function replaceOutsideStrings(text, pattern, replacement) {
|
|
72
|
+
let out = '';
|
|
73
|
+
let i = 0;
|
|
74
|
+
while (i < text.length) {
|
|
75
|
+
if (text[i] === '"') {
|
|
76
|
+
let end = i + 1;
|
|
77
|
+
while (end < text.length && text[end] !== '"') {
|
|
78
|
+
end += text[end] === '\\' ? 2 : 1;
|
|
79
|
+
}
|
|
80
|
+
out += text.slice(i, end + 1);
|
|
81
|
+
i = end + 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
pattern.lastIndex = i;
|
|
85
|
+
const match = pattern.exec(text);
|
|
86
|
+
if (match) {
|
|
87
|
+
out += replacement;
|
|
88
|
+
i += match[0].length;
|
|
89
|
+
} else {
|
|
90
|
+
out += text[i++];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function importConfig(file) {
|
|
97
|
+
// Loaded only for a TypeScript config: the hook starts on every edit.
|
|
98
|
+
const { createJiti } = await import('jiti');
|
|
99
|
+
const jiti = createJiti(file, { moduleCache: false });
|
|
100
|
+
const mod = await jiti.import(file);
|
|
101
|
+
return mod?.default ?? mod;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function readOxlintConfig(file) {
|
|
105
|
+
return /\.jsonc?$/.test(file)
|
|
106
|
+
? parseJsonc(readFileSync(file, 'utf8'))
|
|
107
|
+
: importConfig(file);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A config and the configs it extends, in the order Oxlint merges them. */
|
|
111
|
+
async function oxlintLayers(config, dir, seen = new Set()) {
|
|
112
|
+
const layers = [];
|
|
113
|
+
for (const base of config?.extends ?? []) {
|
|
114
|
+
if (typeof base === 'string') {
|
|
115
|
+
const file = resolve(dir, base);
|
|
116
|
+
if (seen.has(file)) continue;
|
|
117
|
+
seen.add(file);
|
|
118
|
+
layers.push(
|
|
119
|
+
...(await oxlintLayers(
|
|
120
|
+
await readOxlintConfig(file),
|
|
121
|
+
dirname(file),
|
|
122
|
+
seen,
|
|
123
|
+
)),
|
|
124
|
+
);
|
|
125
|
+
} else if (base && typeof base === 'object') {
|
|
126
|
+
layers.push(...(await oxlintLayers(base, dir, seen)));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (config && typeof config === 'object') layers.push(config);
|
|
130
|
+
return layers;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Whether a `jsPlugins` specifier loads this package. */
|
|
134
|
+
function isThisPlugin(specifier, dir, packageRoot) {
|
|
135
|
+
if (specifier === NAME || specifier.startsWith(`${NAME}/`)) return true;
|
|
136
|
+
if (!specifier.startsWith('.') && !isAbsolute(specifier)) return false;
|
|
137
|
+
try {
|
|
138
|
+
return realpathSync(resolve(dir, specifier)).startsWith(packageRoot + sep);
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The names an Oxlint config's `jsPlugins` give this plugin. */
|
|
145
|
+
function oxlintNames(layers, dir, packageRoot) {
|
|
146
|
+
const names = new Set();
|
|
147
|
+
const entries = layers.flatMap((layer) => [
|
|
148
|
+
...(layer.jsPlugins ?? []),
|
|
149
|
+
...(layer.overrides ?? []).flatMap((o) => o?.jsPlugins ?? []),
|
|
150
|
+
]);
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
const { name, specifier } =
|
|
153
|
+
typeof entry === 'string' ? { specifier: entry } : (entry ?? {});
|
|
154
|
+
if (
|
|
155
|
+
typeof specifier !== 'string' ||
|
|
156
|
+
!isThisPlugin(specifier, dir, packageRoot)
|
|
157
|
+
) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
for (const n of typeof name === 'string' ? [name] : OXLINT_NAMES) {
|
|
161
|
+
names.add(n);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return names;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The rules of this plugin in a rule map, renamed to `tasty/<rule>`, with
|
|
169
|
+
* Oxlint's levels (`allow`, `deny`) written as ESLint's.
|
|
170
|
+
*/
|
|
171
|
+
export function tastyRules(rules, names, known) {
|
|
172
|
+
const out = {};
|
|
173
|
+
for (const [key, entry] of Object.entries(rules ?? {})) {
|
|
174
|
+
const slash = key.lastIndexOf('/');
|
|
175
|
+
const rule = key.slice(slash + 1);
|
|
176
|
+
if (slash < 0 || !names.has(key.slice(0, slash)) || !known.has(rule)) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const [level, ...options] = Array.isArray(entry) ? entry : [entry];
|
|
180
|
+
const severity = LEVELS[level];
|
|
181
|
+
if (severity) {
|
|
182
|
+
out[`tasty/${rule}`] = options.length ? [severity, ...options] : severity;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** An Oxlint `files` pattern for ESLint: one without a slash matches at any depth. */
|
|
189
|
+
export function fileGlob(pattern) {
|
|
190
|
+
const glob = pattern.replace(/^\.\//, '');
|
|
191
|
+
return glob.includes('/') ? glob : `**/${glob}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** An Oxlint `ignorePatterns` entry, which follows `.gitignore`, for ESLint. */
|
|
195
|
+
export function ignoreGlobs(pattern) {
|
|
196
|
+
const negated = pattern.startsWith('!');
|
|
197
|
+
let glob = negated ? pattern.slice(1) : pattern;
|
|
198
|
+
const folder = glob.endsWith('/');
|
|
199
|
+
if (folder) glob = glob.slice(0, -1);
|
|
200
|
+
if (glob.startsWith('/')) glob = glob.slice(1);
|
|
201
|
+
else if (!glob.includes('/')) glob = `**/${glob}`;
|
|
202
|
+
return (folder ? [`${glob}/**`] : [glob, `${glob}/**`]).map((g) =>
|
|
203
|
+
negated ? `!${g}` : g,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The flat config that holds the Tasty part of an Oxlint config: its rules,
|
|
209
|
+
* the overrides that change them, `settings.tasty` and `ignorePatterns`.
|
|
210
|
+
* Undefined when the config does not load the plugin.
|
|
211
|
+
*/
|
|
212
|
+
export async function oxlintFlatConfig(file, { plugin, packageRoot }) {
|
|
213
|
+
const dir = dirname(file);
|
|
214
|
+
const layers = await oxlintLayers(
|
|
215
|
+
await readOxlintConfig(file),
|
|
216
|
+
dir,
|
|
217
|
+
new Set([file]),
|
|
218
|
+
);
|
|
219
|
+
const names = oxlintNames(layers, dir, packageRoot);
|
|
220
|
+
if (names.size === 0) return undefined;
|
|
221
|
+
const known = new Set(Object.keys(plugin.rules));
|
|
222
|
+
const settings = Object.assign(
|
|
223
|
+
{},
|
|
224
|
+
...layers.map((layer) => layer.settings?.tasty),
|
|
225
|
+
);
|
|
226
|
+
const ignores = layers
|
|
227
|
+
.flatMap((layer) => layer.ignorePatterns ?? [])
|
|
228
|
+
.flatMap(ignoreGlobs);
|
|
229
|
+
const overrides = layers.flatMap((layer) => layer.overrides ?? []);
|
|
230
|
+
return [
|
|
231
|
+
{
|
|
232
|
+
files: [SOURCES],
|
|
233
|
+
plugins: { tasty: plugin },
|
|
234
|
+
settings: { tasty: settings },
|
|
235
|
+
},
|
|
236
|
+
...(ignores.length > 0 ? [{ ignores }] : []),
|
|
237
|
+
...layers.map((layer) => ({
|
|
238
|
+
rules: tastyRules(layer.rules, names, known),
|
|
239
|
+
})),
|
|
240
|
+
...overrides.map((override) => ({
|
|
241
|
+
files: (override.files ?? []).map(fileGlob),
|
|
242
|
+
...(override.excludeFiles
|
|
243
|
+
? { ignores: override.excludeFiles.map(fileGlob) }
|
|
244
|
+
: {}),
|
|
245
|
+
rules: tastyRules(override.rules, names, known),
|
|
246
|
+
})),
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Finds and reads the project's Tasty lint config for each file.
|
|
252
|
+
*
|
|
253
|
+
* `forFile(file)` gives `{ source, rules, settings }`: `source` is the config
|
|
254
|
+
* (`{ linter, file, dir }`), or null when no config above the file loads the
|
|
255
|
+
* plugin, and then `rules` is null too. `ignored` is true when the config does
|
|
256
|
+
* not lint the file.
|
|
257
|
+
*/
|
|
258
|
+
export function createConfigResolver({ ESLint, plugin, packageRoot }) {
|
|
259
|
+
const known = new Set(Object.keys(plugin.rules));
|
|
260
|
+
const byDir = new Map();
|
|
261
|
+
|
|
262
|
+
async function sourceIn(dir) {
|
|
263
|
+
const oxlint = OXLINT_CONFIGS.map((n) => join(dir, n)).find(existsSync);
|
|
264
|
+
if (oxlint) {
|
|
265
|
+
const config = await oxlintFlatConfig(oxlint, { plugin, packageRoot });
|
|
266
|
+
if (config) {
|
|
267
|
+
return {
|
|
268
|
+
linter: 'oxlint',
|
|
269
|
+
file: oxlint,
|
|
270
|
+
dir,
|
|
271
|
+
eslint: new ESLint({
|
|
272
|
+
cwd: dir,
|
|
273
|
+
overrideConfigFile: true,
|
|
274
|
+
overrideConfig: config,
|
|
275
|
+
}),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// A text test first: loading an ESLint config loads every plugin it uses.
|
|
280
|
+
const eslint = ESLINT_CONFIGS.map((n) => join(dir, n)).find(existsSync);
|
|
281
|
+
if (eslint && readFileSync(eslint, 'utf8').includes(NAME)) {
|
|
282
|
+
return {
|
|
283
|
+
linter: 'eslint',
|
|
284
|
+
file: eslint,
|
|
285
|
+
dir,
|
|
286
|
+
eslint: new ESLint({ cwd: dir, overrideConfigFile: eslint }),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const parent = dirname(dir);
|
|
290
|
+
return parent === dir ? null : sourceFor(parent);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function sourceFor(dir) {
|
|
294
|
+
if (!byDir.has(dir)) byDir.set(dir, sourceIn(dir));
|
|
295
|
+
return byDir.get(dir);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
async forFile(file) {
|
|
300
|
+
const source = await sourceFor(dirname(file));
|
|
301
|
+
if (!source) return { source: null, rules: null, settings: {} };
|
|
302
|
+
const config = await source.eslint.calculateConfigForFile(file);
|
|
303
|
+
if (!config) return { source, ignored: true, rules: {}, settings: {} };
|
|
304
|
+
const names = new Set(
|
|
305
|
+
Object.entries(config.plugins ?? {})
|
|
306
|
+
.filter(([, p]) => p === plugin || p?.meta?.name === NAME)
|
|
307
|
+
.map(([name]) => name),
|
|
308
|
+
);
|
|
309
|
+
return {
|
|
310
|
+
source,
|
|
311
|
+
rules: tastyRules(config.rules, names, known),
|
|
312
|
+
settings: { ...config.settings?.tasty },
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
package/harness/plugin.json
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"check": {
|
|
13
13
|
"file": "./dev.harnesspm/scripts/check.mjs",
|
|
14
|
-
"description": "Lint files or folders with only the tasty/* rules
|
|
14
|
+
"description": "Lint files or folders with only the tasty/* rules, as the project's Oxlint or ESLint config sets them (else the recommended preset, or --strict), and print each problem with its rule's page",
|
|
15
15
|
"allow": true
|
|
16
16
|
},
|
|
17
17
|
"check-edit": {
|