@tenphi/eslint-plugin-tasty 1.17.1 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +64 -45
  2. package/dist/rules/no-protected-restyle.js +24 -14
  3. package/dist/rules/no-protected-restyle.js.map +1 -1
  4. package/docs/rules/consistent-token-usage.md +72 -0
  5. package/docs/rules/known-property.md +55 -0
  6. package/docs/rules/no-classname-prop.md +60 -0
  7. package/docs/rules/no-ignored-pseudo-element-state.md +97 -0
  8. package/docs/rules/no-important.md +25 -0
  9. package/docs/rules/no-nested-selector.md +39 -0
  10. package/docs/rules/no-nested-state-map.md +33 -0
  11. package/docs/rules/no-own-at-root.md +57 -0
  12. package/docs/rules/no-protected-restyle.md +192 -0
  13. package/docs/rules/no-raw-color-values.md +49 -0
  14. package/docs/rules/no-raw-motion-duration.md +50 -0
  15. package/docs/rules/no-raw-transition-duration.md +56 -0
  16. package/docs/rules/no-runtime-styles-mutation.md +58 -0
  17. package/docs/rules/no-state-in-selector.md +142 -0
  18. package/docs/rules/no-style-prop.md +127 -0
  19. package/docs/rules/no-style-spread.md +53 -0
  20. package/docs/rules/no-styles-prop.md +57 -0
  21. package/docs/rules/no-unknown-state-alias.md +45 -0
  22. package/docs/rules/prefer-auto-calc.md +30 -0
  23. package/docs/rules/prefer-custom-property-syntax.md +56 -0
  24. package/docs/rules/prefer-directional-shorthand.md +46 -0
  25. package/docs/rules/prefer-element-selector.md +77 -0
  26. package/docs/rules/prefer-fill-mix.md +119 -0
  27. package/docs/rules/prefer-hide.md +31 -0
  28. package/docs/rules/prefer-longhand-property.md +25 -0
  29. package/docs/rules/prefer-shorthand-property.md +194 -0
  30. package/docs/rules/prefer-state-negation.md +44 -0
  31. package/docs/rules/require-default-state.md +50 -0
  32. package/docs/rules/static-no-dynamic-values.md +32 -0
  33. package/docs/rules/static-valid-selector.md +36 -0
  34. package/docs/rules/valid-boolean-property.md +39 -0
  35. package/docs/rules/valid-color-token.md +64 -0
  36. package/docs/rules/valid-custom-property.md +36 -0
  37. package/docs/rules/valid-custom-unit.md +34 -0
  38. package/docs/rules/valid-default-state-order.md +44 -0
  39. package/docs/rules/valid-directional-modifier.md +46 -0
  40. package/docs/rules/valid-preset.md +35 -0
  41. package/docs/rules/valid-radius-shape.md +31 -0
  42. package/docs/rules/valid-recipe.md +35 -0
  43. package/docs/rules/valid-state-definition.md +46 -0
  44. package/docs/rules/valid-state-key.md +62 -0
  45. package/docs/rules/valid-styles-structure.md +48 -0
  46. package/docs/rules/valid-sub-element.md +100 -0
  47. package/docs/rules/valid-transition.md +46 -0
  48. package/docs/rules/valid-value.md +65 -0
  49. package/harness/dev.harnesspm/hooks.json +12 -0
  50. package/harness/dev.harnesspm/rules/usage.md +11 -0
  51. package/harness/dev.harnesspm/scripts/check-edit.mjs +127 -0
  52. package/harness/dev.harnesspm/scripts/check.mjs +159 -0
  53. package/harness/dev.harnesspm/scripts/lint.mjs +236 -0
  54. package/harness/plugin.json +23 -0
  55. package/package.json +11 -7
@@ -0,0 +1,159 @@
1
+ /**
2
+ * tasty-lint:check — lints files with this plugin's Tasty rules only, with the
3
+ * project's tasty.config.* and ESLint.
4
+ */
5
+ import { existsSync, realpathSync } from 'node:fs';
6
+ import { join, resolve } from 'node:path';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+ import {
9
+ NO_PARSER,
10
+ collectProblems,
11
+ createLinter,
12
+ formatProblems,
13
+ isTypeScript,
14
+ loadESLint,
15
+ loadTypeScriptParser,
16
+ rulePage,
17
+ summarize,
18
+ } from './lint.mjs';
19
+
20
+ const USAGE = `Usage: npx harnesspm run tasty-lint:check <files or folders>... [--strict] [--json]
21
+
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.
25
+
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.
29
+
30
+ A path is read from the current folder, else from the project root. Each
31
+ problem prints as file:line:col tasty/<rule> <severity> message, with the
32
+ file relative to the project root. The page of each rule reported follows. Exit code: 0
33
+ without errors (warnings allowed), 1 with errors, 2 when nothing could be
34
+ linted.
35
+
36
+ --strict also run the rules only the strict preset enables
37
+ --json print the problems as JSON`;
38
+
39
+ const PACKAGE_ROOT = realpathSync(
40
+ fileURLToPath(new URL('../../../', import.meta.url)),
41
+ );
42
+ const BUILD = join(PACKAGE_ROOT, 'dist/index.js');
43
+ const cwd = process.cwd();
44
+ const projectDir = process.env.HARNESSPM_PROJECT_DIR || cwd;
45
+
46
+ function fail(message) {
47
+ console.error(`tasty-lint: ${message}`);
48
+ process.exit(2);
49
+ }
50
+
51
+ const options = { strict: false, json: false, help: false };
52
+ const paths = [];
53
+ for (const arg of process.argv.slice(2)) {
54
+ if (!arg.startsWith('--')) paths.push(arg);
55
+ else if (arg.slice(2) in options) options[arg.slice(2)] = true;
56
+ else fail(`unknown option ${arg}\n\n${USAGE}`);
57
+ }
58
+ if (options.help || paths.length === 0) {
59
+ console.log(USAGE);
60
+ process.exit(options.help ? 0 : 2);
61
+ }
62
+
63
+ // A path is read from the current folder, else from the project root: an
64
+ // agent's shell can be elsewhere when it names a file of the project.
65
+ const targets = paths.map((path) =>
66
+ [resolve(cwd, path), resolve(projectDir, path)].find((p) => existsSync(p)),
67
+ );
68
+ const missing = paths.filter((_, i) => !targets[i]);
69
+ if (missing.length > 0) fail(`cannot find ${missing.join(', ')}`);
70
+ if (!existsSync(BUILD)) {
71
+ fail(
72
+ `cannot find the plugin build at ${BUILD}. In the plugin's repository, run \`pnpm build\` first.`,
73
+ );
74
+ }
75
+
76
+ const {
77
+ default: plugin,
78
+ recommended,
79
+ strict,
80
+ } = await import(pathToFileURL(BUILD).href);
81
+ const preset = options.strict ? 'strict' : 'recommended';
82
+
83
+ let ESLint;
84
+ let parser;
85
+ try {
86
+ ESLint = await loadESLint([cwd, projectDir], PACKAGE_ROOT);
87
+ parser = await loadTypeScriptParser([cwd, projectDir]);
88
+ } catch (error) {
89
+ fail(error instanceof Error ? error.message : String(error));
90
+ }
91
+
92
+ const eslint = createLinter({
93
+ ESLint,
94
+ plugin,
95
+ rules: options.strict ? strict : recommended,
96
+ parser,
97
+ cwd: projectDir,
98
+ });
99
+
100
+ let results;
101
+ try {
102
+ results = await eslint.lintFiles(targets);
103
+ } catch (error) {
104
+ fail(error instanceof Error ? error.message : String(error));
105
+ }
106
+
107
+ if (!parser && results.some((result) => isTypeScript(result.filePath))) {
108
+ fail(NO_PARSER);
109
+ }
110
+
111
+ // Paths print relative to the project root, as harnesspm runs from there.
112
+ const { problems, skipped } = collectProblems(results, projectDir);
113
+ const pageOf = (name) =>
114
+ rulePage(name, { projectDir, packageRoot: PACKAGE_ROOT, cwd: projectDir });
115
+ const errors = problems.filter((p) => p.severity === 'error').length;
116
+ const linted = results.length - skipped.length;
117
+
118
+ if (options.json) {
119
+ console.log(
120
+ JSON.stringify(
121
+ {
122
+ preset,
123
+ files: results.map((r) => r.filePath),
124
+ linted,
125
+ problems: problems.map((p) => ({
126
+ ...p,
127
+ page: p.rule ? pageOf(p.rule.slice('tasty/'.length)) : null,
128
+ })),
129
+ skipped,
130
+ },
131
+ null,
132
+ 2,
133
+ ),
134
+ );
135
+ } else {
136
+ const out = [];
137
+ const count = `${linted} file${linted === 1 ? '' : 's'}`;
138
+ if (linted === 0) {
139
+ out.push(`Nothing linted in ${paths.join(', ')}.`);
140
+ } else if (problems.length === 0) {
141
+ out.push(`No tasty/* problems in ${count} (${preset} rules).`);
142
+ } else {
143
+ out.push(
144
+ formatProblems(problems, pageOf),
145
+ '',
146
+ `${summarize(problems)} in ${count} (${preset} rules).`,
147
+ );
148
+ }
149
+ for (const s of skipped) out.push(`Skipped ${s.file}: ${s.reason}`);
150
+ if (results.length === 0) {
151
+ out.push(
152
+ 'Pass .js, .jsx, .ts or .tsx files of the project, or folders with them.',
153
+ );
154
+ }
155
+ console.log(out.join('\n'));
156
+ }
157
+
158
+ // Nothing linted is not a pass.
159
+ process.exit(errors > 0 ? 1 : linted === 0 ? 2 : 0);
@@ -0,0 +1,236 @@
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
+
12
+ /** Files the tools lint: JavaScript and TypeScript, with or without JSX. */
13
+ const LINTABLE = /\.(?:[cm]?[jt]s|[jt]sx)$/;
14
+ const TYPESCRIPT = /\.(?:[cm]?ts|tsx)$/;
15
+ const DECLARATION = /\.d\.[cm]?ts$/;
16
+
17
+ /** Tasty's own modules, which every import source list includes. */
18
+ const TASTY = '@tenphi/tasty';
19
+
20
+ export function isLintable(file) {
21
+ return LINTABLE.test(file) && !DECLARATION.test(file);
22
+ }
23
+
24
+ export function isTypeScript(file) {
25
+ return TYPESCRIPT.test(file);
26
+ }
27
+
28
+ /**
29
+ * Whether the text imports from Tasty or one of `sources`, subpaths included:
30
+ * a text test that runs before anything is parsed.
31
+ */
32
+ export function importsFrom(text, sources = []) {
33
+ for (const source of new Set([TASTY, ...sources])) {
34
+ const escaped = source.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
35
+ if (new RegExp(`(['"\`])${escaped}(?:/[^'"\`]*)?\\1`).test(text)) {
36
+ return true;
37
+ }
38
+ }
39
+ return false;
40
+ }
41
+
42
+ /**
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.
46
+ */
47
+ export function lintConfig({ plugin, rules, parser }) {
48
+ const config = [
49
+ {
50
+ files: ['**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}'],
51
+ plugins: { tasty: plugin },
52
+ // Directives for other plugins' rules are not this tool's business.
53
+ linterOptions: { reportUnusedDisableDirectives: false },
54
+ languageOptions: {
55
+ ecmaVersion: 'latest',
56
+ sourceType: 'module',
57
+ parserOptions: { ecmaFeatures: { jsx: true } },
58
+ },
59
+ rules,
60
+ },
61
+ ];
62
+ if (parser) {
63
+ config.push({
64
+ files: ['**/*.{ts,tsx,mts,cts}'],
65
+ // The parser prints its TypeScript version warning on stdout, which
66
+ // would break --json. The project's own lint still shows it.
67
+ languageOptions: { parser, parserOptions: { loggerFn: false } },
68
+ });
69
+ }
70
+ return config;
71
+ }
72
+
73
+ export function createLinter({ ESLint, plugin, rules, parser, cwd }) {
74
+ return new ESLint({
75
+ cwd,
76
+ overrideConfigFile: true,
77
+ overrideConfig: lintConfig({ plugin, rules, parser }),
78
+ errorOnUnmatchedPattern: false,
79
+ });
80
+ }
81
+
82
+ /**
83
+ * The problems in ESLint's results: this plugin's rules and parse errors. A
84
+ * message with no rule that is not fatal says that ESLint skipped the file.
85
+ */
86
+ export function collectProblems(results, cwd) {
87
+ const problems = [];
88
+ const skipped = [];
89
+ for (const result of results) {
90
+ const file = relative(cwd, result.filePath) || result.filePath;
91
+ for (const message of result.messages) {
92
+ if (message.ruleId === null && !message.fatal) {
93
+ skipped.push({ file, reason: message.message });
94
+ continue;
95
+ }
96
+ if (!message.fatal && !message.ruleId?.startsWith('tasty/')) continue;
97
+ problems.push({
98
+ file,
99
+ line: message.line ?? 0,
100
+ column: message.column ?? 0,
101
+ rule: message.fatal ? null : message.ruleId,
102
+ severity: message.severity === 2 ? 'error' : 'warning',
103
+ message: message.message,
104
+ });
105
+ }
106
+ }
107
+ return { problems, skipped };
108
+ }
109
+
110
+ /**
111
+ * The problems in `after` that `before` does not have. Two problems match by
112
+ * rule, message and the text of their line, so lines that moved still match.
113
+ */
114
+ export function newProblems(after, afterText, before, beforeText) {
115
+ const afterLines = afterText.split('\n');
116
+ const beforeLines = beforeText.split('\n');
117
+ const key = (problem, lines) =>
118
+ [problem.rule, problem.message, lines[problem.line - 1]?.trim()].join('\0');
119
+ const left = new Map();
120
+ for (const problem of before) {
121
+ const k = key(problem, beforeLines);
122
+ left.set(k, (left.get(k) ?? 0) + 1);
123
+ }
124
+ return after.filter((problem) => {
125
+ const k = key(problem, afterLines);
126
+ const count = left.get(k) ?? 0;
127
+ if (count === 0) return true;
128
+ left.set(k, count - 1);
129
+ return false;
130
+ });
131
+ }
132
+
133
+ /** The rule name without the `tasty/` prefix. */
134
+ export function ruleName(rule) {
135
+ return rule.slice('tasty/'.length);
136
+ }
137
+
138
+ /**
139
+ * One line per problem, `file:line:col tasty/<rule> <severity> message`,
140
+ * then the page of each rule reported. Without the severity, an agent in the
141
+ * end-to-end run guessed it, and wrongly. `pageOf` turns a rule name into the
142
+ * path to show.
143
+ */
144
+ export function formatProblems(problems, pageOf) {
145
+ const lines = problems.map((p) =>
146
+ p.rule
147
+ ? `${p.file}:${p.line}:${p.column} ${p.rule} ${p.severity} ${p.message}`
148
+ : `${p.file}:${p.line}:${p.column} parse error ${p.message}`,
149
+ );
150
+ const rules = [...new Set(problems.map((p) => p.rule).filter(Boolean))];
151
+ if (rules.length > 0) {
152
+ lines.push('', 'Rule pages:');
153
+ for (const rule of rules)
154
+ lines.push(` ${rule} ${pageOf(ruleName(rule))}`);
155
+ }
156
+ return lines.join('\n');
157
+ }
158
+
159
+ export function summarize(problems) {
160
+ const errors = problems.filter((p) => p.severity === 'error').length;
161
+ const warnings = problems.length - errors;
162
+ const count = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
163
+ return `${count(problems.length, 'problem')} (${count(errors, 'error')}, ${count(warnings, 'warning')})`;
164
+ }
165
+
166
+ /** The path of a module as `dir` resolves it, or undefined. */
167
+ function resolveFrom(dir, specifier) {
168
+ try {
169
+ return createRequire(join(dir, 'noop.js')).resolve(specifier);
170
+ } catch {
171
+ return undefined;
172
+ }
173
+ }
174
+
175
+ async function importFile(file) {
176
+ const mod = await import(pathToFileURL(file).href);
177
+ return mod.default ?? mod;
178
+ }
179
+
180
+ /**
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()`.
183
+ */
184
+ export async function loadESLint(dirs, packageRoot) {
185
+ for (const dir of [...dirs, packageRoot]) {
186
+ const file = resolveFrom(dir, 'eslint');
187
+ if (!file) continue;
188
+ const eslint = await importFile(file);
189
+ if (typeof eslint.loadESLint === 'function') {
190
+ return eslint.loadESLint({ useFlatConfig: true });
191
+ }
192
+ throw new Error(
193
+ `ESLint ${eslint.ESLint?.version ?? ''} at ${dirname(file)} is too old: the tool needs ESLint 8.57 or later.`,
194
+ );
195
+ }
196
+ throw new Error(
197
+ 'cannot find eslint. Install it in the project: it is a peer dependency of @tenphi/eslint-plugin-tasty.',
198
+ );
199
+ }
200
+
201
+ /**
202
+ * The TypeScript parser the project installs: `@typescript-eslint/parser`, or
203
+ * the one `typescript-eslint` depends on. Undefined when there is none.
204
+ */
205
+ export async function loadTypeScriptParser(dirs) {
206
+ for (const dir of dirs) {
207
+ let file = resolveFrom(dir, '@typescript-eslint/parser');
208
+ if (!file) {
209
+ const manifest = resolveFrom(dir, 'typescript-eslint/package.json');
210
+ if (manifest) {
211
+ file = resolveFrom(
212
+ dirname(realpathSync(manifest)),
213
+ '@typescript-eslint/parser',
214
+ );
215
+ }
216
+ }
217
+ if (file) return importFile(file);
218
+ }
219
+ return undefined;
220
+ }
221
+
222
+ export const NO_PARSER =
223
+ 'Linting TypeScript needs a TypeScript parser, and this project has none. Install typescript-eslint (or @typescript-eslint/parser) as a dev dependency.';
224
+
225
+ /**
226
+ * The path to show for a rule's page: through the stable link harnesspm keeps
227
+ * in the project when it exists, else in this package.
228
+ */
229
+ export function rulePage(name, { projectDir, packageRoot, cwd }) {
230
+ const stable = join(
231
+ projectDir,
232
+ 'node_modules/.harnesspm/pkgs/@tenphi/eslint-plugin-tasty',
233
+ );
234
+ const root = existsSync(stable) ? stable : packageRoot;
235
+ return relative(cwd, join(root, 'docs/rules', `${name}.md`));
236
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
+ "name": "tasty-lint",
4
+ "description": "Lint Tasty styles: a rule to lint what you change, a page for every tasty/* rule, a tool that runs only the Tasty rules, and a hook that lints each edited file",
5
+ "homepage": "https://github.com/tenphi/eslint-plugin-tasty#readme",
6
+ "repository": "https://github.com/tenphi/eslint-plugin-tasty",
7
+ "keywords": ["tasty", "eslint", "oxlint", "lint", "styles"],
8
+ "extensions": {
9
+ "dev.harnesspm": {
10
+ "docs": "../docs",
11
+ "scripts": {
12
+ "check": {
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",
15
+ "allow": true
16
+ },
17
+ "check-edit": {
18
+ "file": "./dev.harnesspm/scripts/check-edit.mjs"
19
+ }
20
+ }
21
+ }
22
+ }
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenphi/eslint-plugin-tasty",
3
- "version": "1.17.1",
3
+ "version": "1.19.0",
4
4
  "description": "ESLint plugin for validating tasty() and tastyStatic() style objects",
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,7 +12,9 @@
12
12
  }
13
13
  },
14
14
  "files": [
15
- "dist"
15
+ "dist",
16
+ "docs",
17
+ "harness"
16
18
  ],
17
19
  "sideEffects": false,
18
20
  "engines": {
@@ -53,7 +55,8 @@
53
55
  "@changesets/changelog-github": "^0.5.2",
54
56
  "@changesets/cli": "^2.31.1",
55
57
  "@eslint/js": "^10.0.1",
56
- "@tenphi/tasty": "^3.17.1",
58
+ "@tenphi/harnesspm": "^0.2.0",
59
+ "@tenphi/tasty": "^3.19.0",
57
60
  "@types/node": "^22.0.0",
58
61
  "@types/semver": "^7.8.0",
59
62
  "@typescript-eslint/parser": "^8.56.1",
@@ -85,10 +88,11 @@
85
88
  "test:watch": "vitest",
86
89
  "test:coverage": "vitest run --coverage",
87
90
  "typecheck": "tsc --noEmit",
88
- "lint": "eslint src",
89
- "lint:fix": "eslint src --fix",
90
- "format": "prettier --write \"src/**/*.ts\"",
91
- "format:check": "prettier --check \"src/**/*.ts\"",
91
+ "lint": "eslint src harness",
92
+ "lint:fix": "eslint src harness --fix",
93
+ "format": "prettier --write \"src/**/*.ts\" \"harness/**/*.mjs\"",
94
+ "format:check": "prettier --check \"src/**/*.ts\" \"harness/**/*.mjs\"",
95
+ "check:harness": "harnesspm check",
92
96
  "generate:css-properties": "node scripts/generate-css-properties.mjs",
93
97
  "changeset": "changeset",
94
98
  "version": "changeset version",