Repository: eslint/css (js)

Issue:
Bug: `:has()` not flagged with Baseline Widely available config
### Environment

ESLint version: 9.20.1
@eslint/css version: 0.4.0
Node version: 20.0.0
npm version: 9.6.4
Operating System: MacOS 14.7.3


### Which language are you using?

stylesheet

### What did you do?

<details>
<summary>Configuration</summary>

```
import globals from "globals";
import css from "@eslint/css";


/** @type {import('eslint').Linter.Config[]} */
export default [
  {
    languageOptions: {
      globals: globals.browser
    }
  },
  {
    files: ["**/*.css"],
    plugins: {
      css,
    },
    language: "css/css",
    rules: {
      "css/require-baseline": ["warn", {
        "available": "widely"
      }],
    },
  },
];
```
</details>

```css
/* baseline/has (low) */
p:has(i) {
  color: sandybrown;
}
```


### What did you expect to happen?

The `:has()` pseudo-class is not yet Baseline Widely available, according to [MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/:has):

<img width="713" alt="Image" src="https://github.com/user-attachments/assets/1b4835a4-c4c1-43a4-a619-a98784e47121" />

I would have expected `npx eslint src/has.css` to emit a warning that it "is not a widely available baseline feature"

### What actually happened?

No warnings were emitted

### Link to Minimal Reproducible Example

https://github.com/rviscomi/baseline-demos/tree/main/eslint

### Participation

- [x] I am willing to submit a pull request for this issue.

### Additional comments

_No response_

Pull request:
feat: add selector support to require-baseline
<!--
    Thank you for contributing!

    ESLint adheres to the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct).
-->

#### Prerequisites checklist

- [x] I have read the [contributing guidelines](https://github.com/eslint/eslint/blob/HEAD/CONTRIBUTING.md).

<!--
    Please ensure your pull request is ready:

    - Read the pull request guide (https://eslint.org/docs/latest/contribute/pull-requests)
    - Update or create tests
    - If performance-related, include a benchmark
    - Update documentation for this change (if appropriate)
-->

<!--
    The following is required for all pull requests:
-->

#### What is the purpose of this pull request?

Add support for validating the Baseline status of selectors like `:has()`

#### What changes did you make? (Give an overview)

- added a new `Selector` handler that checks the validity of all child nodes
- added tests for pseudo-class and pseudo-element selectors
- updated docs

#### Related Issues

<!-- include tags like "fixes #123" or "refs #123" -->

Fixes #60

#### Is there anything you'd like reviewers to focus on?

<!-- markdownlint-disable-file MD004 -->


Patch:
diff --git a/docs/rules/require-baseline.md b/docs/rules/require-baseline.md
index 1f5adcb..fdc53d6 100644
--- a/docs/rules/require-baseline.md
+++ b/docs/rules/require-baseline.md
@@ -23,6 +23,7 @@ This rule warns when it finds any of the following:
 - A media condition inside `@media` that isn't widely available.
 - A CSS property value that isn't widely available or otherwise isn't enclosed in a `@supports` block (currently limited to identifiers only).
 - A CSS property function that isn't widely available.
+- A CSS pseudo-element or pseudo-class selector that isn't widely available.
 
 The data is provided via the [web-features](https://npmjs.com/package/web-features) package.
 
@@ -39,6 +40,18 @@ a {
 	width: abs(20% - 100px);
 }
 
+/* invalid - :has() is not widely available */
+h1:has(+ h2) {
+	margin: 0 0 0.25rem 0;
+}
+
+/* valid - @supports indicates you're choosing a limited availability selector */
+@supports selector(:has()) {
+	h1:has(+ h2) {
+		margin: 0 0 0.25rem 0;
+	}
+}
+
 /* invalid - device-posture is not widely available */
 @media (device-posture: folded) {
 	a {
diff --git a/src/rules/require-baseline.js b/src/rules/require-baseline.js
index 6b9d90b..8b66e27 100644
--- a/src/rules/require-baseline.js
+++ b/src/rules/require-baseline.js
@@ -15,6 +15,7 @@ import {
 	atRules,
 	mediaConditions,
 	types,
+	selectors,
 } from "../data/baseline-data.js";
 import { namedColors } from "../data/colors.js";
 
@@ -123,6 +124,12 @@ class SupportsRule {
 	 */
 	#properties = new Map();
 
+	/**
+	 * The selectors supported by this rule.
+	 * @type {Set<string>}
+	 */
+	#selectors = new Set();
+
 	/**
 	 * Adds a property to the rule.
 	 * @param {string} property The name of the property.
@@ -218,6 +225,24 @@ class SupportsRule {
 
 		return supportedProperty.hasFunctions();
 	}
+
+	/**
+	 * Adds a selector to the rule.
+	 * @param {string} selector The name of the selector.
+	 * @returns {void}
+	 */
+	addSelector(selector) {
+		this.#selectors.add(selector);
+	}
+
+	/**
+	 * Determines if the rule supports a selector.
+	 * @param {string} selector The name of the selector.
+	 * @returns {boolean} `true` if the selector is supported, `false` if not.
+	 */
+	hasSelector(selector) {
+		return this.#selectors.has(selector);
+	}
 }
 
 /**
@@ -303,6 +328,15 @@ class SupportsRules {
 	hasPropertyFunctions(property) {
 		return this.#rules.some(rule => rule.hasFunctions(property));
 	}
+
+	/**
+	 * Determines if any rule supports a selector.
+	 * @param {string} selector The name of the selector.
+	 * @returns {boolean} `true` if any rule supports the selector, `false` if not.
+	 */
+	hasSelector(selector) {
+		return this.#rules.some(rule => rule.hasSelector(selector));
+	}
 }
 
 //-----------------------------------------------------------------------------
@@ -347,6 +381,8 @@ export default {
 				"Type '{{type}}' is not a {{availability}} available baseline feature.",
 			notBaselineMediaCondition:
 				"Media condition '{{condition}}' is not a {{availability}} available baseline feature.",
+			notBaselineSelector:
+				"Selector '{{selector}}' is not a {{availability}} available baseline feature.",
 		},
 	},
 
@@ -459,6 +495,16 @@ export default {
 
 						continue;
 					}
+
+					if (
+						conditionChild.type === "FeatureFunction" &&
+						conditionChild.feature === "selector"
+					) {
+						for (const selectorChild of conditionChild.value
+							.children) {
+							supportsRule.addSelector(selectorChild.name);
+						}
+					}
 				}
 			},
 
@@ -625,6 +671,53 @@ export default {
 					});
 				}
 			},
+
+			Selector(node) {
+				for (const child of node.children) {
+					const selector = child.name;
+
+					if (!selectors.has(selector)) {
+						continue;
+					}
+
+					// if the selector has been tested in a @supports rule, don't check it
+					if (supportsRules.hasSelector(selector)) {
+						continue;
+					}
+
+					const selectorLevel = selectors.get(selector);
+
+					if (selectorLevel < baselineLevel) {
+						const loc = child.loc;
+
+						// some selectors are prefixed with the : or :: symbols
+						let prefixSymbolLength = 0;
+						if (child.type === "PseudoClassSelector") {
+							prefixSymbolLength = 1;
+						} else if (child.type === "PseudoElementSelector") {
+							prefixSymbolLength = 2;
+						}
+
+						context.report({
+							loc: {
+								start: loc.start,
+								end: {
+									line: loc.start.line,
+									column:
+										loc.start.column +
+										selector.length +
+										prefixSymbolLength,
+								},
+							},
+							messageId: "notBaselineSelector",
+							data: {
+								selector,
+								availability,
+							},
+						});
+					}
+				}
+			},
 		};
 	},
 };


Test patch:
diff --git a/tests/rules/require-baseline.test.js b/tests/rules/require-baseline.test.js
index 2147367..b08358f 100644
--- a/tests/rules/require-baseline.test.js
+++ b/tests/rules/require-baseline.test.js
@@ -57,6 +57,9 @@ ruleTester.run("require-baseline", rule, {
 		`@supports (width: abs(20% - 100px)) {
 			a { width: abs(20% - 100px); }
 		}`,
+		`@supports selector(:has()) {
+				h1:has(+ h2) { color: red; }
+		}`,
 		"div { cursor: pointer; }",
 		{
 			code: `@property --foo {
@@ -340,5 +343,59 @@ ruleTester.run("require-baseline", rule, {
 				},
 			],
 		},
+		{
+			code: "h1:has(+ h2) { margin: 0 0 0.25rem 0; }",
+			errors: [
+				{
+					messageId: "notBaselineSelector",
+					data: {
+						selector: "has",
+						availability: "widely",
+					},
+					line: 1,
+					column: 3,
+					endLine: 1,
+					endColumn: 7,
+				},
+			],
+		},
+		{
+			code: `@supports selector(:has()) {}
+
+			@supports (color: red) {
+				h1:has(+ h2) {
+					color: red;
+				}
+			}`,
+			errors: [
+				{
+					messageId: "notBaselineSelector",
+					data: {
+						selector: "has",
+						availability: "widely",
+					},
+					line: 4,
+					column: 7,
+					endLine: 4,
+					endColumn: 11,
+				},
+			],
+		},
+		{
+			code: "details::details-content { background-color: #a29bfe; }",
+			errors: [
+				{
+					messageId: "notBaselineSelector",
+					data: {
+						selector: "details-content",
+						availability: "widely",
+					},
+					line: 1,
+					column: 8,
+					endLine: 1,
+					endColumn: 25,
+				},
+			],
+		},
 	],
 });