@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,31 @@
1
+ # tasty/valid-radius-shape
2
+
3
+ Reports a `radius` shape keyword Tasty does not know, such as `'circle'`.
4
+
5
+ **Presets:** error in recommended and strict. **Fix:** editor suggestions.
6
+
7
+ ## Why
8
+
9
+ `radius` takes four shape keywords besides lengths. Any other word renders no radius.
10
+
11
+ ## Examples
12
+
13
+ ```js
14
+ // ✅ Valid
15
+ radius: 'round'
16
+ radius: 'ellipse'
17
+
18
+ // ❌ Unknown radius shape 'circle'. Valid shapes: round, ellipse, leaf, backleaf.
19
+ radius: 'circle'
20
+
21
+ // ❌ Unknown radius shape 'roud'. Did you mean 'round'? Suggestion: 'round'
22
+ radius: 'roud'
23
+ ```
24
+
25
+ ## Checks
26
+
27
+ Valid shapes: `round`, `ellipse`, `leaf`, `backleaf`. A word close to one of them gets an editor suggestion to replace it.
28
+
29
+ ## Options
30
+
31
+ None.
@@ -0,0 +1,35 @@
1
+ # tasty/valid-recipe
2
+
3
+ Reports a recipe name in `recipe` that your config does not list.
4
+
5
+ **Presets:** error in recommended and strict. **Fix:** none.
6
+
7
+ ## Why
8
+
9
+ Tasty drops a recipe it does not know, so the styles it should bring never apply.
10
+
11
+ ## Examples
12
+
13
+ ```js
14
+ // ✅ Valid, when tasty.config lists 'card' and 'elevated' in recipes
15
+ recipe: 'card elevated'
16
+
17
+ // ❌ Unknown recipe 'cards'
18
+ recipe: 'cards'
19
+ ```
20
+
21
+ ## Checks
22
+
23
+ A recipe value is a space-separated list of names. A `/` separates the recipes applied after the component's own styles. `none` means no base recipes.
24
+
25
+ ```text
26
+ recipe: 'card elevated' → ['card', 'elevated']
27
+ recipe: 'reset input / autofill' → before: ['reset', 'input'], after: ['autofill']
28
+ recipe: 'none / autofill' → before: [], after: ['autofill']
29
+ ```
30
+
31
+ Each name must be in `recipes` in `tasty.config.*`. Without `recipes` in the config, the rule does nothing.
32
+
33
+ ## Options
34
+
35
+ None.
@@ -0,0 +1,46 @@
1
+ # tasty/valid-state-definition
2
+
3
+ Reports an invalid state alias definition in `configure({ states })`: a key without `@`, or a value that is not a valid state expression.
4
+
5
+ **Presets:** warning in strict. **Fix:** none.
6
+
7
+ ## Why
8
+
9
+ Every style that uses an alias gets its definition. A broken definition breaks each of those states at once.
10
+
11
+ ## Examples
12
+
13
+ ```js
14
+ // ✅ Valid
15
+ configure({
16
+ states: {
17
+ '@mobile': '@media(w < 768px)',
18
+ '@dark': '@root(theme=dark)',
19
+ },
20
+ });
21
+
22
+ // ❌ Empty @media() query.
23
+ configure({
24
+ states: {
25
+ '@mobile': '@media()',
26
+ },
27
+ });
28
+
29
+ // ❌ State alias 'mobile' must start with '@'.
30
+ configure({
31
+ states: {
32
+ mobile: '@media(w < 768px)',
33
+ },
34
+ });
35
+ ```
36
+
37
+ ## Checks
38
+
39
+ 1. An alias key starts with `@`.
40
+ 2. Its value parses as a state expression, with the same parser as [`tasty/valid-state-key`](valid-state-key.md).
41
+
42
+ The rule reads `configure()` calls imported from Tasty.
43
+
44
+ ## Options
45
+
46
+ None.
@@ -0,0 +1,62 @@
1
+ # tasty/valid-state-key
2
+
3
+ Reports a state key that does not parse, such as `'hovered &'` or `':hovr'`, a misused `_` fallback floor, and an `@alias` your config does not list.
4
+
5
+ **Presets:** error in recommended and strict. **Fix:** none.
6
+
7
+ ## Why
8
+
9
+ Tasty skips a state key it cannot parse, so the value under it never applies. Nothing reports it at runtime.
10
+
11
+ ## Examples
12
+
13
+ ```js
14
+ // ✅ Valid
15
+ fill: { '': '#white', hovered: '#gray.05' }
16
+ fill: { '': '#white', ':hover': '#gray.05' }
17
+ fill: { '': '#white', '@mobile': '#gray' }
18
+
19
+ // ✅ A standalone '_' fallback floor
20
+ inset: { _: 'auto', '@(scroll-state(stuck: top))': '0 auto auto auto' }
21
+
22
+ // ✅ The '_' floor and the '' default together
23
+ fill: { _: '#white', '': '#gray', hovered: '#blue' }
24
+
25
+ // ❌ Expected expression after '&' operator.
26
+ fill: { '': '#white', 'hovered &': '#gray' }
27
+
28
+ // ❌ Unknown pseudo-class ':hovr'.
29
+ fill: { '': '#white', ':hovr': '#gray' }
30
+
31
+ // ❌ Unknown state alias '@mobil'. Configured aliases: @mobile.
32
+ fill: { '': '#white', '@mobil': '#gray' }
33
+
34
+ // ❌ The fallback floor "_" cannot be combined with other state logic.
35
+ fill: { '': '#white', '_ & hovered': '#blue' }
36
+ ```
37
+
38
+ ## Valid state keys
39
+
40
+ - `''`: the default, the lowest priority among states.
41
+ - `_`: the fallback floor, standalone only.
42
+ - A modifier: `hovered`, `pressed`, `disabled`.
43
+ - A value modifier: `theme=danger`, `size=large`, `size^=sm`, with `=`, `^=`, `$=` or `*=`.
44
+ - A pseudo-class: any standard CSS pseudo-class, such as `:hover`, `:focus-visible`, `:popover-open`, `:nth-child(2n+1)` or `:dir(rtl)`.
45
+ - A class or an attribute: `.active`, `[aria-expanded="true"]`, `[data-mode]`.
46
+ - Combinations: `hovered & .active`, `!disabled`, `(loading | processing) & !readonly`, with `&`, `|`, `^` and `!`.
47
+ - Advanced states: `@media(...)`, `@root(...)`, `@parent(...)`, `@(...)` container queries, `@own(...)`, `@starting`, `@supports(...)`.
48
+ - Aliases: `@mobile`, `@dark` and others from `states` in `tasty.config.*` or local `@name` keys.
49
+
50
+ ## Checks
51
+
52
+ 1. **Syntax.** Operators, balanced parentheses, known pseudo-classes, and the dimension shorthands (`w`, `h`, `is`, `bs`) inside `@media(...)` and `@(...)`.
53
+ 2. **Aliases.** When `states` in `tasty.config.*` lists aliases, every `@name` must be one of them or a local alias. [`tasty/no-unknown-state-alias`](no-unknown-state-alias.md) does the same with more context, in the strict preset.
54
+ 3. **Fallback floor.** `_` stands alone. Combined with other state logic (`_ & hovered`, `_ | loading`), Tasty ignores the key.
55
+
56
+ ## The `_` fallback floor
57
+
58
+ A standalone `_` key is a fallback for the whole map. Its value always applies, and states with higher priority layer over it through the cascade. It covers the case where a negated `@supports(...)` or container-query default would otherwise never apply, which leaves no rule active. `_` can sit next to `''`: one is the always-on floor, the other the negated default.
59
+
60
+ ## Options
61
+
62
+ None.
@@ -0,0 +1,48 @@
1
+ # tasty/valid-styles-structure
2
+
3
+ Reports a styles object with the wrong shape: state keys at the top level, at-rule keys whose value is not the object they need, a `recipe` that is not a string, and the at-rule key spellings of Tasty v2.
4
+
5
+ **Presets:** error in recommended and strict. **Fix:** autofix (renamed at-rule keys).
6
+
7
+ ## Why
8
+
9
+ State maps go inside property values. A state key at the top of a styles object, such as `':hover': { fill: '#gray' }`, is read as a property name and renders nothing.
10
+
11
+ ## Examples
12
+
13
+ ```js
14
+ // ❌ State key '' at top level is not valid. State maps belong inside property values.
15
+ tastyStatic({
16
+ '': { fill: '#white' },
17
+ _: { fill: '#white' }, // the '_' floor goes inside a property too
18
+ ':hover': { fill: '#gray' },
19
+ });
20
+
21
+ // ✅ A state map inside the property
22
+ tastyStatic({
23
+ fill: {
24
+ '': '#white',
25
+ ':hover': '#gray',
26
+ },
27
+ });
28
+
29
+ // ❌ '@properties' was renamed to '@property' in Tasty v3. Autofix: '@property'
30
+ tasty({ styles: { '@properties': { angle: { syntax: '<angle>', inherits: false, initialValue: '0deg' } } } });
31
+ ```
32
+
33
+ ## Checks
34
+
35
+ 1. Top-level keys are properties, sub-elements or special keys, not state keys.
36
+ 2. `@keyframes` is an object of `{ name: { step: styles } }`.
37
+ 3. `@property` is an object of `{ name: { syntax, inherits, initialValue } }`.
38
+ 4. `@font-face` is an object of `{ familyName: descriptors | descriptors[] }`.
39
+ 5. `@counter-style` is an object of `{ name: descriptors }`.
40
+ 6. `@function` is an object of `{ '$$name': { args, result } }`.
41
+ 7. `recipe` is a string.
42
+ 8. The Tasty v2 spellings `@properties`, `@fontFace` and `@counterStyle` are renamed to `@property`, `@font-face` and `@counter-style`. This is the only autofix, so `eslint --fix` does most of a v2 upgrade.
43
+
44
+ An at-rule value is reported only when it is surely not an object: a literal, a template or an array. A variable such as `'@font-face': fontFaceMap` may hold the object and is left alone.
45
+
46
+ ## Options
47
+
48
+ None.
@@ -0,0 +1,100 @@
1
+ # tasty/valid-sub-element
2
+
3
+ Sub-element values must be style objects, or `false` or `null` to disable the
4
+ sub-element. In a later `mergeStyles` layer or a `styles` prop, either one
5
+ removes the sub-element styles below it.
6
+ Their `$` selectors must select inner elements or pseudo-elements, rather than
7
+ reuse the sub-element mechanism to style the component root.
8
+
9
+ **Presets:** error in recommended and strict. **Fix:** none.
10
+
11
+ For a nested sub-element, `&` attaches to the containing sub-element's selector
12
+ rather than the component root. A nested `Heading: { $: '&:is(h1)' }` inside
13
+ `Outer` therefore styles `Outer` itself. Put that condition in `Outer`'s property
14
+ state maps using `@own(:is(h1))`, or select a descendant heading instead.
15
+
16
+ ## Reported
17
+
18
+ ```js
19
+ const Heading = tasty({
20
+ styles: {
21
+ Level1: { $: '&:is(h1)', preset: 'h1' },
22
+ Level2: { $: '&:is(h2)', preset: 'h2' },
23
+ },
24
+ });
25
+ ```
26
+
27
+ `&:is(h1)` attaches directly to the root selector. It selects a root `h1`,
28
+ rather than an element named `Level1`. The same issue applies to root compounds
29
+ such as `&.active` or `&[data-level="1"]`. Each `$` value gets one error, even
30
+ when several branches select the root. A root-targeting branch is also reported
31
+ in a list that contains valid pseudo-element or descendant selectors.
32
+
33
+ ## Style the root with a property state map
34
+
35
+ If the component itself can be a heading, put the condition in the root's
36
+ property state map. Preset names below assume corresponding configured presets.
37
+
38
+ ```js
39
+ const Heading = tasty({
40
+ styles: {
41
+ preset: {
42
+ '': 'body',
43
+ ':is(h1)': 'h1',
44
+ ':is(h2)': 'h2',
45
+ ':is(h3)': 'h3',
46
+ ':is(h4)': 'h4',
47
+ ':is(h5)': 'h5',
48
+ ':is(h6)': 'h6',
49
+ },
50
+ },
51
+ });
52
+ ```
53
+
54
+ Choose the default or fallback preset according to the component's contract.
55
+
56
+ ## Select descendant headings with an affix
57
+
58
+ If the headings are inside the component, use a descendant selector:
59
+
60
+ ```js
61
+ const Content = tasty({
62
+ styles: {
63
+ Level1: { $: 'h1', preset: 'h1' },
64
+ Level2: { $: 'h2', preset: 'h2' },
65
+ },
66
+ });
67
+ ```
68
+
69
+ The `h1` affix selects descendant `h1` tags. Omitting `$` selects
70
+ `[data-element="Level1"]` instead. Combinators and `@` placeholders remain
71
+ supported, for example `$: '>h1'` and `$: '>@'`.
72
+
73
+ Root pseudo-elements remain valid:
74
+
75
+ ```js
76
+ Before: { $: '&::before', content: '""' }
77
+ ```
78
+
79
+ The legacy pseudo-element spellings `:before`, `:after`, `:first-line`, and
80
+ `:first-letter` are also supported. The separate `no-state-in-selector` rule
81
+ may warn about conditions such as `&:hover::before`; those conditions belong
82
+ in property state maps.
83
+
84
+ ## Scope and limits
85
+
86
+ The selector check applies to literal strings and templates without interpolation
87
+ inside owned sub-elements, including nested sub-elements, typed/shared styles,
88
+ JSX/Storybook styles, and configured style helpers. TypeScript wrappers and
89
+ statically computed sub-element and `$` keys are supported.
90
+
91
+ Root `$` values, selector arguments to `tastyStatic()` or `useGlobalStyles()`,
92
+ unrelated objects, and dynamic selectors are outside the selector check.
93
+ Malformed selectors that cannot be parsed are left to syntax validation.
94
+
95
+ There is no autofix or editor suggestion: choosing between a root state map and
96
+ a descendant selector changes which elements receive the styles.
97
+
98
+ ## Options
99
+
100
+ None.
@@ -0,0 +1,46 @@
1
+ # tasty/valid-transition
2
+
3
+ Reports a `transition` name that is neither a Tasty semantic transition nor a CSS property, a camelCase CSS name, and a CSS name that has a semantic equivalent.
4
+
5
+ **Presets:** warning in recommended and strict. **Fix:** editor suggestions.
6
+
7
+ ## Why
8
+
9
+ Tasty expands semantic names (`fill`, `theme`, `radius`, …) into the CSS properties they cover. Any other name is lowercased and emitted as is, so a typo or a camelCase name animates nothing.
10
+
11
+ ## Examples
12
+
13
+ ```js
14
+ // ✅ Valid
15
+ transition: 'fill 0.2s'
16
+ transition: 'theme 0.3s ease'
17
+ transition: '$$my-prop 0.2s'
18
+
19
+ // ❌ Unknown transition name 'colour'
20
+ transition: 'colour 0.2s'
21
+
22
+ // ❌ 'gridTemplateColumns' compiles to 'gridtemplatecolumns', which is not a CSS property.
23
+ // Suggestion: 'grid-template-columns'
24
+ transition: 'gridTemplateColumns 0.2s'
25
+
26
+ // ❌ 'background-color' has a Tasty semantic equivalent. Suggestion: 'fill'
27
+ transition: 'background-color 0.2s'
28
+ ```
29
+
30
+ ## Checks
31
+
32
+ A transition value is a comma-separated list. Each entry is `name [duration] [easing] [delay]`.
33
+
34
+ 1. The name must be a semantic transition, a CSS property, or a `$$name` custom property reference.
35
+ 2. A CSS property must be kebab-case. `zIndex` is the exception: it is a semantic name that compiles to `z-index`.
36
+ 3. A CSS property with a semantic equivalent (`background-color`, `box-shadow`) gets a suggestion to use the semantic name.
37
+
38
+ Semantic names: `fade`, `fill`, `color`, `theme`, `border`, `radius`, `shadow`, `outline`, `preset`, `text`, `gap`, `opacity`, `translate`, `rotate`, `scale`, `filter`, `image`, `background`, `width`, `height`, `inset` and `zIndex`.
39
+
40
+ `flow` is reported although the CSS property dataset lists it: no browser implements it, and Tasty has no `flow` transition.
41
+
42
+ Durations are checked by [`tasty/no-raw-transition-duration`](no-raw-transition-duration.md).
43
+
44
+ ## Options
45
+
46
+ None.
@@ -0,0 +1,65 @@
1
+ # tasty/valid-value
2
+
3
+ Parses every string style value and reports what Tasty cannot read: unknown words, modifiers a property does not take, colors where none belong, unbalanced parentheses and `!important`.
4
+
5
+ **Presets:** error in recommended and strict. **Fix:** none.
6
+
7
+ ## Why
8
+
9
+ Tasty's runtime parser files every word it does not recognize as a modifier and moves on, so a typo renders as nothing, with no error. This rule uses the plugin's own value parser, which reports every unrecognized token instead.
10
+
11
+ ## Examples
12
+
13
+ ```js
14
+ // ❌ Modifier 'topp' is not valid for 'padding'. Accepted: top, right, bottom, left, longhand.
15
+ padding: '2x topp'
16
+
17
+ // ❌ Property 'padding' does not accept color tokens, but found '#red'.
18
+ padding: '2x #red'
19
+
20
+ // ❌ Unbalanced parentheses in value.
21
+ fill: 'rgb(255, 0, 0'
22
+
23
+ // ❌ 'svgFill' takes a single paint: Tasty keeps one color and drops the rest.
24
+ svgFill: 'none #red'
25
+
26
+ // ❌ '#purple' is not expanded after url()
27
+ svgFill: 'url(#gradient) #purple'
28
+
29
+ // ✅ Valid
30
+ fill: '#purple.5'
31
+ padding: '2x top bottom'
32
+ border: '1bw solid #red top'
33
+ border: '1bw Highlight' // CSS named and system colors are colors
34
+ width: 'min 200px'
35
+ display: '-webkit-box' // with -webkit-line-clamp; also -webkit-inline-box
36
+ aspectRatio: '16/10' // <ratio> allows optional whitespace: same as '16 / 10'
37
+ svgFill: 'context-stroke'
38
+ svgFill: 'url(#gradient) currentColor' // a url() fallback is plain CSS
39
+ ```
40
+
41
+ ## How it works
42
+
43
+ 1. The value is split into groups (by commas) and parts (by spaced slashes), and each token is classified: color token, custom property, function, number with a unit, keyword, or unknown.
44
+ 2. The result is compared with what the property expects:
45
+ - A property without modifiers (`fill`, `color`, `opacity`, `width`, `height`, …) must have no modifier words.
46
+ - A property with modifiers (`border`, `padding`, `radius`, …) takes only modifiers from its own set.
47
+ - A property without colors (`padding`, `margin`, `gap`, `width`, …) must have no color tokens.
48
+ - `svgFill` is the native SVG `fill`, so it also takes `context-fill` and `context-stroke`. It takes one paint: Tasty keeps one color and drops the rest. A `url()` may have one fallback, but Tasty passes that value through as plain CSS, so a `#name` fallback is reported: it is never expanded.
49
+
50
+ `recipe` (checked by [`tasty/valid-recipe`](valid-recipe.md)) and the at-rule keys are not parsed.
51
+
52
+ ## Related rules
53
+
54
+ `valid-value` asks "does it parse?". Other rules check that the names exist in your config:
55
+
56
+ | Check | Rule |
57
+ | --- | --- |
58
+ | Does this value parse without unknown tokens? | `tasty/valid-value` |
59
+ | Is `#purple` a known color token? | [`tasty/valid-color-token`](valid-color-token.md) |
60
+ | Is `3cols` a known unit? | [`tasty/valid-custom-unit`](valid-custom-unit.md) |
61
+ | Is `$spacign` a known custom property? | [`tasty/valid-custom-property`](valid-custom-property.md) |
62
+
63
+ ## Options
64
+
65
+ None.
@@ -0,0 +1,12 @@
1
+ {
2
+ "hooks": {
3
+ "PostToolUse": [
4
+ {
5
+ "id": "check-edit",
6
+ "matcher": { "claude": "Edit|Write|MultiEdit" },
7
+ "run": "check-edit",
8
+ "timeout": 30
9
+ }
10
+ ]
11
+ }
12
+ }
@@ -0,0 +1,11 @@
1
+ # Linting Tasty styles
2
+
3
+ ## After you change styles
4
+
5
+ - Lint the changed files and fix every `tasty/*` problem.
6
+ - `tasty-lint:check <files>` runs only the Tasty rules.
7
+ - Each `tasty/<rule>` has a page in the `tasty-lint` docs index.
8
+
9
+ ## Exceptions
10
+
11
+ - Disable a rule for one line only: `// eslint-disable-next-line tasty/<rule> -- <reason>`. With Oxlint, `oxlint-disable-next-line` works too.
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The check-edit hook: after an agent edits a file that imports Tasty, lints
3
+ * it with the Tasty rules the project's lint config sets (or the recommended
4
+ * ones) and hands the new problems back.
5
+ *
6
+ * Claude Code runs it on PostToolUse for Edit, Write and MultiEdit, with the
7
+ * event as JSON on stdin. Exit 0 says nothing. Exit 2 puts stderr in front of
8
+ * the agent. Any other exit shows stderr to the person only, which is how a
9
+ * broken setup is reported: the agent cannot fix it.
10
+ */
11
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
12
+ import { dirname, join, resolve } from 'node:path';
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+ import {
15
+ NO_PARSER,
16
+ collectProblems,
17
+ createLinter,
18
+ formatProblems,
19
+ importsFrom,
20
+ isLintable,
21
+ isTypeScript,
22
+ lintOptions,
23
+ loadESLint,
24
+ loadTypeScriptParser,
25
+ newProblems,
26
+ rulePage,
27
+ } from './lint.mjs';
28
+ import { createConfigResolver, isOn } from './project-config.mjs';
29
+
30
+ /** More problems than this are summed up, not listed. */
31
+ const LIMIT = 20;
32
+
33
+ const PACKAGE_ROOT = realpathSync(
34
+ fileURLToPath(new URL('../../../', import.meta.url)),
35
+ );
36
+
37
+ function setupError(message) {
38
+ console.error(`tasty-lint (check-edit hook): ${message}`);
39
+ process.exit(1);
40
+ }
41
+
42
+ let event;
43
+ try {
44
+ event = JSON.parse(readFileSync(0, 'utf8') || '{}');
45
+ } catch {
46
+ process.exit(0);
47
+ }
48
+ const filePath = event?.tool_input?.file_path;
49
+ if (typeof filePath !== 'string' || !isLintable(filePath)) process.exit(0);
50
+
51
+ const cwd = typeof event.cwd === 'string' ? event.cwd : process.cwd();
52
+ const projectDir = process.env.HARNESSPM_PROJECT_DIR || cwd;
53
+ const file = resolve(cwd, filePath);
54
+ let text;
55
+ try {
56
+ text = readFileSync(file, 'utf8');
57
+ } catch {
58
+ process.exit(0);
59
+ }
60
+
61
+ const build = (name) => pathToFileURL(join(PACKAGE_ROOT, 'dist', name)).href;
62
+ if (!existsSync(join(PACKAGE_ROOT, 'dist/index.js'))) {
63
+ setupError(`cannot find the plugin build in ${PACKAGE_ROOT}/dist.`);
64
+ }
65
+
66
+ // The cheap test first. Only a file without a Tasty import pays for loading
67
+ // the config, to look for the design system modules it lists.
68
+ if (!importsFrom(text)) {
69
+ const { loadConfig } = await import(build('config.js'));
70
+ let sources = [];
71
+ try {
72
+ sources = loadConfig(file).importSources;
73
+ } catch {
74
+ // A broken config is reported when a file that imports Tasty is linted.
75
+ }
76
+ if (!importsFrom(text, sources)) process.exit(0);
77
+ }
78
+
79
+ let problems;
80
+ let linter;
81
+ let typeAware = false;
82
+ try {
83
+ const dirs = [dirname(file), projectDir];
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);
103
+ const parser = await loadTypeScriptParser(dirs);
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);
110
+ const eslint = createLinter({
111
+ ESLint,
112
+ plugin,
113
+ parser,
114
+ espree,
115
+ ...lint,
116
+ settings,
117
+ });
118
+ const lintText = async (source) =>
119
+ collectProblems(
120
+ await eslint.lintText(source, { filePath: file }),
121
+ projectDir,
122
+ ).problems;
123
+
124
+ problems = await lintText(text);
125
+ // Report only what this edit added, so problems an older line already had
126
+ // do not come back after every edit of the file.
127
+ const original = event.tool_response?.originalFile;
128
+ if (problems.length > 0 && typeof original === 'string' && original) {
129
+ problems = newProblems(problems, text, await lintText(original), original);
130
+ }
131
+ } catch (error) {
132
+ setupError(error instanceof Error ? error.message : String(error));
133
+ }
134
+
135
+ if (problems.length === 0) process.exit(0);
136
+
137
+ const shown = problems.slice(0, LIMIT);
138
+ const relativeFile = shown[0].file;
139
+ // Paths print relative to the project root: the event's cwd is wherever the
140
+ // agent's shell went last, such as a folder in node_modules.
141
+ const pageOf = (name) =>
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`;
145
+ const out = [
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.`,
147
+ '',
148
+ formatProblems(shown, pageOf),
149
+ ];
150
+ if (problems.length > LIMIT) {
151
+ out.push(
152
+ '',
153
+ `${problems.length - LIMIT} more: run \`npx harnesspm run tasty-lint:check ${relativeFile}\`.`,
154
+ );
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
+ }
162
+ console.error(out.join('\n'));
163
+ process.exit(2);