@tenphi/eslint-plugin-tasty 1.18.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.
Files changed (54) hide show
  1. package/README.md +72 -45
  2. package/docs/rules/consistent-token-usage.md +74 -0
  3. package/docs/rules/known-property.md +55 -0
  4. package/docs/rules/no-classname-prop.md +62 -0
  5. package/docs/rules/no-ignored-pseudo-element-state.md +97 -0
  6. package/docs/rules/no-important.md +25 -0
  7. package/docs/rules/no-nested-selector.md +39 -0
  8. package/docs/rules/no-nested-state-map.md +33 -0
  9. package/docs/rules/no-own-at-root.md +57 -0
  10. package/docs/rules/no-protected-restyle.md +194 -0
  11. package/docs/rules/no-raw-color-values.md +51 -0
  12. package/docs/rules/no-raw-motion-duration.md +50 -0
  13. package/docs/rules/no-raw-transition-duration.md +56 -0
  14. package/docs/rules/no-runtime-styles-mutation.md +58 -0
  15. package/docs/rules/no-state-in-selector.md +142 -0
  16. package/docs/rules/no-style-prop.md +129 -0
  17. package/docs/rules/no-style-spread.md +55 -0
  18. package/docs/rules/no-styles-prop.md +59 -0
  19. package/docs/rules/no-unknown-state-alias.md +45 -0
  20. package/docs/rules/prefer-auto-calc.md +30 -0
  21. package/docs/rules/prefer-custom-property-syntax.md +56 -0
  22. package/docs/rules/prefer-directional-shorthand.md +46 -0
  23. package/docs/rules/prefer-element-selector.md +77 -0
  24. package/docs/rules/prefer-fill-mix.md +119 -0
  25. package/docs/rules/prefer-hide.md +31 -0
  26. package/docs/rules/prefer-longhand-property.md +25 -0
  27. package/docs/rules/prefer-shorthand-property.md +194 -0
  28. package/docs/rules/prefer-state-negation.md +44 -0
  29. package/docs/rules/require-default-state.md +50 -0
  30. package/docs/rules/static-no-dynamic-values.md +32 -0
  31. package/docs/rules/static-valid-selector.md +36 -0
  32. package/docs/rules/valid-boolean-property.md +39 -0
  33. package/docs/rules/valid-color-token.md +64 -0
  34. package/docs/rules/valid-custom-property.md +36 -0
  35. package/docs/rules/valid-custom-unit.md +34 -0
  36. package/docs/rules/valid-default-state-order.md +44 -0
  37. package/docs/rules/valid-directional-modifier.md +46 -0
  38. package/docs/rules/valid-preset.md +35 -0
  39. package/docs/rules/valid-radius-shape.md +31 -0
  40. package/docs/rules/valid-recipe.md +35 -0
  41. package/docs/rules/valid-state-definition.md +46 -0
  42. package/docs/rules/valid-state-key.md +62 -0
  43. package/docs/rules/valid-styles-structure.md +48 -0
  44. package/docs/rules/valid-sub-element.md +100 -0
  45. package/docs/rules/valid-transition.md +46 -0
  46. package/docs/rules/valid-value.md +65 -0
  47. package/harness/dev.harnesspm/hooks.json +12 -0
  48. package/harness/dev.harnesspm/rules/usage.md +11 -0
  49. package/harness/dev.harnesspm/scripts/check-edit.mjs +163 -0
  50. package/harness/dev.harnesspm/scripts/check.mjs +254 -0
  51. package/harness/dev.harnesspm/scripts/lint.mjs +318 -0
  52. package/harness/dev.harnesspm/scripts/project-config.mjs +316 -0
  53. package/harness/plugin.json +23 -0
  54. package/package.json +11 -7
@@ -0,0 +1,254 @@
1
+ /**
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.
5
+ */
6
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
7
+ import { join, relative, resolve } from 'node:path';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+ import {
10
+ NO_PARSER,
11
+ collectProblems,
12
+ createLinter,
13
+ formatProblems,
14
+ isLintable,
15
+ isTypeScript,
16
+ lintOptions,
17
+ loadESLint,
18
+ loadTypeScriptParser,
19
+ rulePage,
20
+ summarize,
21
+ } from './lint.mjs';
22
+ import { createConfigResolver, isOn } from './project-config.mjs';
23
+
24
+ const USAGE = `Usage: npx harnesspm run tasty-lint:check <files or folders>... [--strict] [--json]
25
+
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.
33
+
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.
37
+
38
+ A path is read from the current folder, else from the project root. Each
39
+ problem prints as file:line:col tasty/<rule> <severity> message, with the
40
+ file relative to the project root. The page of each rule reported follows. Exit code: 0
41
+ without errors (warnings allowed), 1 with errors, 2 when nothing could be
42
+ linted.
43
+
44
+ --strict also run the strict preset's rules that the config does not set
45
+ --json print the problems as JSON`;
46
+
47
+ const PACKAGE_ROOT = realpathSync(
48
+ fileURLToPath(new URL('../../../', import.meta.url)),
49
+ );
50
+ const BUILD = join(PACKAGE_ROOT, 'dist/index.js');
51
+ const cwd = process.cwd();
52
+ const projectDir = process.env.HARNESSPM_PROJECT_DIR || cwd;
53
+
54
+ function fail(message) {
55
+ console.error(`tasty-lint: ${message}`);
56
+ process.exit(2);
57
+ }
58
+
59
+ const errorText = (error) =>
60
+ error instanceof Error ? error.message : String(error);
61
+
62
+ const options = { strict: false, json: false, help: false };
63
+ const paths = [];
64
+ for (const arg of process.argv.slice(2)) {
65
+ if (!arg.startsWith('--')) paths.push(arg);
66
+ else if (arg.slice(2) in options) options[arg.slice(2)] = true;
67
+ else fail(`unknown option ${arg}\n\n${USAGE}`);
68
+ }
69
+ if (options.help || paths.length === 0) {
70
+ console.log(USAGE);
71
+ process.exit(options.help ? 0 : 2);
72
+ }
73
+
74
+ // A path is read from the current folder, else from the project root: an
75
+ // agent's shell can be elsewhere when it names a file of the project.
76
+ const targets = paths.map((path) =>
77
+ [resolve(cwd, path), resolve(projectDir, path)].find((p) => existsSync(p)),
78
+ );
79
+ const missing = paths.filter((_, i) => !targets[i]);
80
+ if (missing.length > 0) fail(`cannot find ${missing.join(', ')}`);
81
+ if (!existsSync(BUILD)) {
82
+ fail(
83
+ `cannot find the plugin build at ${BUILD}. In the plugin's repository, run \`pnpm build\` first.`,
84
+ );
85
+ }
86
+
87
+ const {
88
+ default: plugin,
89
+ recommended,
90
+ strict,
91
+ } = await import(pathToFileURL(BUILD).href);
92
+ const preset = options.strict ? 'strict' : 'recommended';
93
+
94
+ let ESLint;
95
+ let espree;
96
+ let parser;
97
+ try {
98
+ ({ ESLint, espree } = await loadESLint([cwd, projectDir], PACKAGE_ROOT));
99
+ parser = await loadTypeScriptParser([cwd, projectDir]);
100
+ } catch (error) {
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
+ }
121
+ }
122
+
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({
127
+ ESLint,
128
+ plugin,
129
+ packageRoot: PACKAGE_ROOT,
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
+ }
176
+
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
+ }
185
+ }
186
+ results.sort((a, b) => (a.filePath < b.filePath ? -1 : 1));
187
+
188
+ if (!parser && results.some((result) => isTypeScript(result.filePath))) {
189
+ fail(NO_PARSER);
190
+ }
191
+
192
+ // Paths print relative to the project root, as harnesspm runs from there.
193
+ const collected = collectProblems(results, projectDir);
194
+ const { problems } = collected;
195
+ skipped.push(...collected.skipped);
196
+ const pageOf = (name) =>
197
+ rulePage(name, { projectDir, packageRoot: PACKAGE_ROOT, cwd: projectDir });
198
+ const errors = problems.filter((p) => p.severity === 'error').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('; ');
211
+
212
+ if (options.json) {
213
+ console.log(
214
+ JSON.stringify(
215
+ {
216
+ preset,
217
+ configs: [...configs],
218
+ files: results.map((r) => r.filePath),
219
+ linted,
220
+ problems: problems.map((p) => ({
221
+ ...p,
222
+ page: p.rule ? pageOf(p.rule.slice('tasty/'.length)) : null,
223
+ })),
224
+ skipped,
225
+ },
226
+ null,
227
+ 2,
228
+ ),
229
+ );
230
+ } else {
231
+ const out = [];
232
+ const count = `${linted} file${linted === 1 ? '' : 's'}`;
233
+ if (linted === 0) {
234
+ out.push(`Nothing linted in ${paths.join(', ')}.`);
235
+ } else if (problems.length === 0) {
236
+ out.push(`No tasty/* problems in ${count} (${ruleset}).`);
237
+ } else {
238
+ out.push(
239
+ formatProblems(problems, pageOf),
240
+ '',
241
+ `${summarize(problems)} in ${count} (${ruleset}).`,
242
+ );
243
+ }
244
+ for (const s of skipped) out.push(`Skipped ${s.file}: ${s.reason}`);
245
+ if (linted === 0 && skipped.length === 0) {
246
+ out.push(
247
+ 'Pass .js, .jsx, .ts or .tsx files of the project, or folders with them.',
248
+ );
249
+ }
250
+ console.log(out.join('\n'));
251
+ }
252
+
253
+ // Nothing linted is not a pass.
254
+ process.exit(errors > 0 ? 1 : linted === 0 ? 2 : 0);
@@ -0,0 +1,318 @@
1
+ /**
2
+ * What `tasty-lint:check` and the `check-edit` hook compute. The functions
3
+ * that lint take ESLint's class, the plugin and the parser as arguments: the
4
+ * tools pass the project's ESLint and this package's `dist/` build, and
5
+ * `src/harness.test.ts` passes the source, so most tests need no build.
6
+ */
7
+ import { existsSync, realpathSync } from 'node:fs';
8
+ import { createRequire } from 'node:module';
9
+ import { dirname, join, relative } from 'node:path';
10
+ import { pathToFileURL } from 'node:url';
11
+ import { SOURCES } from './project-config.mjs';
12
+
13
+ /** Files the tools lint: JavaScript and TypeScript, with or without JSX. */
14
+ const LINTABLE = /\.(?:[cm]?[jt]s|[jt]sx)$/;
15
+ const TYPESCRIPT = /\.(?:[cm]?ts|tsx)$/;
16
+ const DECLARATION = /\.d\.[cm]?ts$/;
17
+
18
+ /** Tasty's own modules, which every import source list includes. */
19
+ const TASTY = '@tenphi/tasty';
20
+
21
+ export function isLintable(file) {
22
+ return LINTABLE.test(file) && !DECLARATION.test(file);
23
+ }
24
+
25
+ export function isTypeScript(file) {
26
+ return TYPESCRIPT.test(file);
27
+ }
28
+
29
+ /**
30
+ * Whether the text imports from Tasty or one of `sources`, subpaths included:
31
+ * a text test that runs before anything is parsed.
32
+ */
33
+ export function importsFrom(text, sources = []) {
34
+ for (const source of new Set([TASTY, ...sources])) {
35
+ const escaped = source.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
36
+ if (new RegExp(`(['"\`])${escaped}(?:/[^'"\`]*)?\\1`).test(text)) {
37
+ return true;
38
+ }
39
+ }
40
+ return false;
41
+ }
42
+
43
+ /**
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.
51
+ */
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);
96
+ const config = [
97
+ {
98
+ files: [SOURCES],
99
+ plugins: { tasty: plugin },
100
+ // Directives for other plugins' rules are not this tool's business.
101
+ linterOptions: { reportUnusedDisableDirectives: false },
102
+ languageOptions: {
103
+ ecmaVersion: 'latest',
104
+ sourceType: 'module',
105
+ ...(espree ? { parser: read(espree) } : {}),
106
+ parserOptions: { ecmaFeatures: { jsx: true } },
107
+ },
108
+ settings: { tasty: settings },
109
+ rules,
110
+ },
111
+ ];
112
+ if (parser) {
113
+ config.push({
114
+ files: ['**/*.{ts,tsx,mts,cts}'],
115
+ // The parser prints its TypeScript version warning on stdout, which
116
+ // would break --json. The project's own lint still shows it.
117
+ languageOptions: {
118
+ parser: read(parser),
119
+ parserOptions: { loggerFn: false },
120
+ },
121
+ });
122
+ }
123
+ return config;
124
+ }
125
+
126
+ export function createLinter({ ESLint, cwd, ...options }) {
127
+ return new ESLint({
128
+ cwd,
129
+ overrideConfigFile: true,
130
+ overrideConfig: lintConfig(options),
131
+ errorOnUnmatchedPattern: false,
132
+ });
133
+ }
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
+
157
+ /**
158
+ * The problems in ESLint's results: this plugin's rules and parse errors. A
159
+ * message with no rule that is not fatal says that ESLint skipped the file.
160
+ */
161
+ export function collectProblems(results, cwd) {
162
+ const problems = [];
163
+ const skipped = [];
164
+ for (const result of results) {
165
+ const file = relative(cwd, result.filePath) || result.filePath;
166
+ for (const message of result.messages) {
167
+ if (message.ruleId === null && !message.fatal) {
168
+ skipped.push({ file, reason: message.message });
169
+ continue;
170
+ }
171
+ if (!message.fatal && !message.ruleId?.startsWith('tasty/')) continue;
172
+ problems.push({
173
+ file,
174
+ line: message.line ?? 0,
175
+ column: message.column ?? 0,
176
+ rule: message.fatal ? null : message.ruleId,
177
+ severity: message.severity === 2 ? 'error' : 'warning',
178
+ message: message.message,
179
+ });
180
+ }
181
+ }
182
+ return { problems, skipped };
183
+ }
184
+
185
+ /**
186
+ * The problems in `after` that `before` does not have. Two problems match by
187
+ * rule, message and the text of their line, so lines that moved still match.
188
+ */
189
+ export function newProblems(after, afterText, before, beforeText) {
190
+ const afterLines = afterText.split('\n');
191
+ const beforeLines = beforeText.split('\n');
192
+ const key = (problem, lines) =>
193
+ [problem.rule, problem.message, lines[problem.line - 1]?.trim()].join('\0');
194
+ const left = new Map();
195
+ for (const problem of before) {
196
+ const k = key(problem, beforeLines);
197
+ left.set(k, (left.get(k) ?? 0) + 1);
198
+ }
199
+ return after.filter((problem) => {
200
+ const k = key(problem, afterLines);
201
+ const count = left.get(k) ?? 0;
202
+ if (count === 0) return true;
203
+ left.set(k, count - 1);
204
+ return false;
205
+ });
206
+ }
207
+
208
+ /** The rule name without the `tasty/` prefix. */
209
+ export function ruleName(rule) {
210
+ return rule.slice('tasty/'.length);
211
+ }
212
+
213
+ /**
214
+ * One line per problem, `file:line:col tasty/<rule> <severity> message`,
215
+ * then the page of each rule reported. Without the severity, an agent in the
216
+ * end-to-end run guessed it, and wrongly. `pageOf` turns a rule name into the
217
+ * path to show.
218
+ */
219
+ export function formatProblems(problems, pageOf) {
220
+ const lines = problems.map((p) =>
221
+ p.rule
222
+ ? `${p.file}:${p.line}:${p.column} ${p.rule} ${p.severity} ${p.message}`
223
+ : `${p.file}:${p.line}:${p.column} parse error ${p.message}`,
224
+ );
225
+ const rules = [...new Set(problems.map((p) => p.rule).filter(Boolean))];
226
+ if (rules.length > 0) {
227
+ lines.push('', 'Rule pages:');
228
+ for (const rule of rules)
229
+ lines.push(` ${rule} ${pageOf(ruleName(rule))}`);
230
+ }
231
+ return lines.join('\n');
232
+ }
233
+
234
+ export function summarize(problems) {
235
+ const errors = problems.filter((p) => p.severity === 'error').length;
236
+ const warnings = problems.length - errors;
237
+ const count = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
238
+ return `${count(problems.length, 'problem')} (${count(errors, 'error')}, ${count(warnings, 'warning')})`;
239
+ }
240
+
241
+ /** The path of a module as `dir` resolves it, or undefined. */
242
+ function resolveFrom(dir, specifier) {
243
+ try {
244
+ return createRequire(join(dir, 'noop.js')).resolve(specifier);
245
+ } catch {
246
+ return undefined;
247
+ }
248
+ }
249
+
250
+ async function importFile(file) {
251
+ const mod = await import(pathToFileURL(file).href);
252
+ return mod.default ?? mod;
253
+ }
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
+
258
+ /**
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`.
263
+ */
264
+ export async function loadESLint(dirs, packageRoot) {
265
+ for (const dir of [...dirs, packageRoot]) {
266
+ const file = resolveFrom(dir, 'eslint');
267
+ if (!file) continue;
268
+ const eslint = await importFile(file);
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
+ );
273
+ }
274
+ const espree = resolveFrom(dirname(file), 'espree');
275
+ return {
276
+ ESLint: await eslint.loadESLint({ useFlatConfig: true }),
277
+ espree: espree ? await importFile(espree) : undefined,
278
+ };
279
+ }
280
+ throw Object.assign(new Error(NO_ESLINT), { code: 'NO_ESLINT' });
281
+ }
282
+
283
+ /**
284
+ * The TypeScript parser the project installs: `@typescript-eslint/parser`, or
285
+ * the one `typescript-eslint` depends on. Undefined when there is none.
286
+ */
287
+ export async function loadTypeScriptParser(dirs) {
288
+ for (const dir of dirs) {
289
+ let file = resolveFrom(dir, '@typescript-eslint/parser');
290
+ if (!file) {
291
+ const manifest = resolveFrom(dir, 'typescript-eslint/package.json');
292
+ if (manifest) {
293
+ file = resolveFrom(
294
+ dirname(realpathSync(manifest)),
295
+ '@typescript-eslint/parser',
296
+ );
297
+ }
298
+ }
299
+ if (file) return importFile(file);
300
+ }
301
+ return undefined;
302
+ }
303
+
304
+ export const NO_PARSER =
305
+ 'Linting TypeScript needs a TypeScript parser, and this project has none. Install typescript-eslint (or @typescript-eslint/parser) as a dev dependency.';
306
+
307
+ /**
308
+ * The path to show for a rule's page: through the stable link harnesspm keeps
309
+ * in the project when it exists, else in this package.
310
+ */
311
+ export function rulePage(name, { projectDir, packageRoot, cwd }) {
312
+ const stable = join(
313
+ projectDir,
314
+ 'node_modules/.harnesspm/pkgs/@tenphi/eslint-plugin-tasty',
315
+ );
316
+ const root = existsSync(stable) ? stable : packageRoot;
317
+ return relative(cwd, join(root, 'docs/rules', `${name}.md`));
318
+ }