@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 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 with the project's `tasty.config.*` and prints each problem with its rule's page;
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
- They need `eslint`, and `typescript-eslint` or `@typescript-eslint/parser` for TypeScript files. They do not read the project's ESLint or Oxlint config, so other plugins, rule options and settings such as `typeAwareJSX` do not apply there.
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
 
@@ -55,6 +55,8 @@ const Rule = tasty({
55
55
  });
56
56
  ```
57
57
 
58
+ With Oxlint, `// oxlint-disable-next-line` works the same way.
59
+
58
60
  ## Options
59
61
 
60
62
  ```js
@@ -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
@@ -44,6 +44,8 @@ For an intentional one-off color, disable the rule for that line, with a reason:
44
44
  fill: '#1db954',
45
45
  ```
46
46
 
47
+ With Oxlint, `// oxlint-disable-next-line` works the same way.
48
+
47
49
  ## Options
48
50
 
49
51
  None.
@@ -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.
@@ -48,6 +48,8 @@ const Card = tasty({
48
48
  });
49
49
  ```
50
50
 
51
+ With Oxlint, `// oxlint-disable-next-line` works the same way.
52
+
51
53
  ## Options
52
54
 
53
55
  None.
@@ -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.
@@ -8,4 +8,4 @@
8
8
 
9
9
  ## Exceptions
10
10
 
11
- - Disable a rule for one line only: `// eslint-disable-next-line tasty/<rule> -- <reason>`.
11
+ - Disable a rule for one line only: `// eslint-disable-next-line tasty/<rule> -- <reason>`. With Oxlint, `oxlint-disable-next-line` works too.
@@ -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 recommended Tasty rules and hands the new problems back.
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
- const ESLint = await loadESLint(dirs, PACKAGE_ROOT);
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
- cwd: projectDir,
114
+ espree,
115
+ ...lint,
116
+ settings,
89
117
  });
90
- const lint = async (source) =>
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 lint(text);
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 lint(original), original);
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 \`// eslint-disable-next-line tasty/<rule> -- <reason>\` above the line.`,
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, with the
3
- * project's tasty.config.* and ESLint.
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: the
23
- recommended preset, or the strict one with --strict. The rules read the
24
- project's tasty.config.* as they do in the project's own lint.
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
- It does not read the project's ESLint or Oxlint config, so other plugins, rule
27
- options and settings such as typeAwareJSX do not apply. Disable comments such
28
- as // eslint-disable-next-line tasty/<rule> -- <reason> do.
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 only the strict preset enables
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(error instanceof Error ? error.message : String(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
+ }
90
121
  }
91
122
 
92
- const eslint = createLinter({
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
- rules: options.strict ? strict : recommended,
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
- let results;
101
- try {
102
- results = await eslint.lintFiles(targets);
103
- } catch (error) {
104
- fail(error instanceof Error ? error.message : String(error));
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 { problems, skipped } = collectProblems(results, projectDir);
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} (${preset} rules).`);
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} (${preset} rules).`,
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 (results.length === 0) {
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
- * The ESLint flat config that runs only this plugin's rules. It does not read
44
- * the project's linter config, so `settings.tasty` is empty: in particular
45
- * `typeAwareJSX`, which builds a TypeScript program, stays off.
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
- export function lintConfig({ plugin, rules, parser }) {
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: ['**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}'],
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: { parser, parserOptions: { loggerFn: false } },
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, plugin, rules, parser, cwd }) {
126
+ export function createLinter({ ESLint, cwd, ...options }) {
74
127
  return new ESLint({
75
128
  cwd,
76
129
  overrideConfigFile: true,
77
- overrideConfig: lintConfig({ plugin, rules, parser }),
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. The
182
- * flat config class: ESLint 9 and later, or 8.57 through `loadESLint()`.
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 === 'function') {
190
- return eslint.loadESLint({ useFlatConfig: true });
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
- throw new Error(
193
- `ESLint ${eslint.ESLint?.version ?? ''} at ${dirname(file)} is too old: the tool needs ESLint 8.57 or later.`,
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
+ }
@@ -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 (recommended, or --strict) and the project's tasty.config, and print each problem with its rule's page",
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": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenphi/eslint-plugin-tasty",
3
- "version": "1.19.0",
3
+ "version": "1.19.1",
4
4
  "description": "ESLint plugin for validating tasty() and tastyStatic() style objects",
5
5
  "type": "module",
6
6
  "exports": {