Repository: stylelint-scss/stylelint-scss (js)

Issue:
Add a lint for dangerous parent selector use in mixins
It's relatively common for authors to write selectors like `.context &` as a way of logically scoping the current style rule to a given context. However, this isn't very reliable in mixins, because the mixin may be included within another nested style rule, at which point the `.context` may be misplaced.

It would be useful to have a lint for this case to help authors ensure they're not accidentally making assumptions in their mixins. It should specifically warn for `&` in style rules when:

* They're within a `@mixin`.
* They're nested within another style rule.
* They _aren't_ at the beginning of a given complex selector. (That is, `& .foo` is fine, as is `.foo, & .bar`.)
* ~~They _aren't_ within `@at-root`, since that can't be affected by parent style rules anyway.~~ (This isn't accurate, `&` is still affected by outer style rules.)

It would even be possible to suggest a fix for this by pulling the parent selector into the child and making the child style rule a sibling. For example:

```scss
@mixin foo {
  .parent {
    color: blue;

    .context & {
      color: red;
    }
  }
}
```

could be adjusted to:

```scss
@mixin foo {
  .parent {
    color: blue;
  }

  .context .parent {
    color: red;
  }
}
```

Pull request:
Add at-mixin-no-risky-nesting-selector rule.
Fix #892.

Patch:
diff --git a/src/rules/at-mixin-no-risky-nesting-selector/README.md b/src/rules/at-mixin-no-risky-nesting-selector/README.md
new file mode 100644
index 0000000..e07813e
--- /dev/null
+++ b/src/rules/at-mixin-no-risky-nesting-selector/README.md
@@ -0,0 +1,144 @@
+# at-mixin-no-risky-nesting-selector
+
+Disallow risky nesting selectors within a mixin.
+
+If a mixin contains a parent selector within another style rule, and is used in a nested context, 
+the output selector may include the outermost parent selector in an unexpected way.
+
+In this example:
+```scss
+@mixin foo {
+  .a {
+    color: blue;
+    .b & {
+      color: red;
+    }
+  }
+}
+
+.c {
+  @include foo;
+}
+```
+
+The user may expect `.c` to go outside all selectors in `foo`:
+`.c .b .a {...}`
+
+But this outputs:
+```scss
+.c .a {
+  color: blue;
+}
+.b .c .a {
+  color: red;
+}
+```
+
+However, if we pull the parent selector into the child and make the child style rule a sibling:
+```scss
+@mixin foo {
+  .a {
+    color: blue;
+  }
+  .b .a {
+    color: red;
+  }
+}
+
+.c {
+  @include foo;
+}
+```
+
+It outputs:
+```scss
+.c .a {
+  color: blue;
+}
+.c .b .a {
+  color: red;
+}
+```
+
+This only occurs when a parent selector meets all of the following conditions:
+- Is within a `@mixin` rule.
+- Is nested within another style rule.
+- Is not positioned at the beginning of a complex selector.
+
+## Options
+
+### `true`
+
+The following patterns are considered warnings:
+
+```scss
+@mixin foo {
+  .bar {
+    color: blue;
+    .baz & {
+      color: red;
+    }
+  }
+}
+```
+
+```scss
+@mixin foo {
+  .bar {
+    color: blue;
+    .qux, .baz & .quux{
+      color: red;
+    }
+  }
+}
+```
+
+The following patterns are _not_ considered warnings:
+
+```scss
+.foo {
+  .bar {
+    color: blue;
+    .baz & {
+      color: red;
+    }
+  }
+}
+```
+
+```scss
+@mixin foo {
+  .bar {
+    color: blue;
+    & .baz {
+      color: red;
+    }
+  }
+}
+```
+
+```scss
+.bar {
+  color: blue;
+  .baz & {
+    color: red;
+  }
+}
+```
+
+```scss
+.foo {
+  color: blue;
+  & .bar, .baz & .qux {
+    color: red;
+  }
+}
+```
+
+```scss
+@mixin foo {
+  .baz & {
+    color: red;
+  }
+}
+```
\ No newline at end of file
diff --git a/src/rules/at-mixin-no-risky-nesting-selector/index.js b/src/rules/at-mixin-no-risky-nesting-selector/index.js
new file mode 100644
index 0000000..19dc777
--- /dev/null
+++ b/src/rules/at-mixin-no-risky-nesting-selector/index.js
@@ -0,0 +1,67 @@
+"use strict";
+
+const { utils } = require("stylelint");
+const namespace = require("../../utils/namespace");
+const ruleUrl = require("../../utils/ruleUrl");
+
+const ruleName = namespace("at-mixin-no-risky-nesting-selector");
+
+const messages = utils.ruleMessages(ruleName, {
+  rejected: `Unexpected nested parent selector in @mixin rule.`
+});
+
+const meta = {
+  url: ruleUrl(ruleName)
+};
+
+function isWithinMixin(node) {
+  let parent = node.parent;
+  while (parent) {
+    if (parent.type === "atrule" && parent.name === "mixin") {
+      return true;
+    }
+    parent = parent.parent;
+  }
+  return false;
+}
+
+function hasNestedParentSelector(selectors) {
+  return selectors
+    .split(",")
+    .some(
+      selector =>
+        selector.includes("&") && /.+&/.test(selector.replace(" ", ""))
+    );
+}
+
+function rule(actual) {
+  return (root, result) => {
+    const validOptions = utils.validateOptions(result, ruleName, { actual });
+
+    if (!validOptions) {
+      return;
+    }
+
+    root.walkRules(node => {
+      if (
+        node.selector?.includes("&") &&
+        isWithinMixin(node) &&
+        hasNestedParentSelector(node.selector) &&
+        node.parent.selector
+      ) {
+        utils.report({
+          message: messages.rejected,
+          node,
+          result,
+          ruleName
+        });
+      }
+    });
+  };
+}
+
+rule.ruleName = ruleName;
+rule.messages = messages;
+rule.meta = meta;
+
+module.exports = rule;
diff --git a/src/rules/index.js b/src/rules/index.js
index 5e853d4..e9a2b56 100644
--- a/src/rules/index.js
+++ b/src/rules/index.js
@@ -24,6 +24,7 @@ const rules = {
   "at-mixin-named-arguments": require("./at-mixin-named-arguments"),
   "at-mixin-parentheses-space-before": require("./at-mixin-parentheses-space-before"),
   "at-mixin-pattern": require("./at-mixin-pattern"),
+  "at-mixin-no-risky-nesting-selector": require("./at-mixin-no-risky-nesting-selector"),
   "at-rule-conditional-no-parentheses": require("./at-rule-conditional-no-parentheses"),
   "at-root-no-redundant": require("./at-root-no-redundant"),
   "at-rule-no-unknown": require("./at-rule-no-unknown"),


Test patch:
diff --git a/src/rules/at-mixin-no-risky-nesting-selector/__tests__/index.js b/src/rules/at-mixin-no-risky-nesting-selector/__tests__/index.js
new file mode 100644
index 0000000..7e79c8c
--- /dev/null
+++ b/src/rules/at-mixin-no-risky-nesting-selector/__tests__/index.js
@@ -0,0 +1,206 @@
+"use strict";
+
+const { ruleName } = require("..");
+
+testRule({
+  ruleName,
+  config: [true],
+  customSyntax: "postcss-scss",
+
+  accept: [
+    {
+      code: `
+      .parent {
+        color: blue;
+    
+        & .b {
+          color: red;
+        }
+      }
+    `,
+      description: "Nested parent selector"
+    },
+    {
+      code: `
+      .parent {
+        color: blue;
+    
+        & .b, &.context {
+          color: red;
+        }
+      }
+    `,
+      description: "Nested parent selector in complex selector"
+    },
+    {
+      code: `
+      .bar {
+        & .parent {
+          color: blue;
+      
+          .context {
+            color: red;
+          }
+        }
+      }
+    `,
+      description: "Parent selector nested in another style rule"
+    },
+    {
+      code: `
+      @mixin foo {
+        &.context {
+          color: red;
+        }
+      }
+    `,
+      description: "Parent selector in mixin"
+    },
+    {
+      code: `
+      .bar {
+        .parent {
+          color: blue;
+      
+          & .context {
+            color: red;
+          }
+        }
+      }
+    `,
+      description: "Parent selector nested in more than one style rule"
+    },
+    {
+      code: `
+      .parent {
+        color: blue;
+    
+        .context & {
+          color: red;
+        }
+      }
+    `,
+      description: "Selector ending in parent selector"
+    },
+    {
+      code: `
+      .parent {
+        color: blue;
+    
+        .context & .b {
+          color: red;
+        }
+      }
+    `,
+      description: "Parent selector in the middle of complex selector"
+    },
+    {
+      code: `
+      .parent {
+        color: blue;
+    
+        & .b, .context & {
+          color: red;
+        }
+      }
+    `,
+      description: "Complex selector, one ending in parent selector"
+    },
+    {
+      code: `
+      @mixin foo {
+        .parent {
+          color: blue;
+      
+          & .context {
+            color: red;
+          }
+        }
+      }
+    `,
+      description: "Parent selector in mixin, nested, at the beginning"
+    },
+    {
+      code: `
+      @mixin foo {
+        .bar & {
+          color: red;
+        }
+      }
+    `,
+      description: "Parent selector in mixin, at the end"
+    },
+    {
+      code: `
+      @mixin foo {
+        .parent {
+          .bar, & {
+            color: red;
+          }
+        }
+      }
+    `,
+      description: "Complex selector in mixin, nested, no following class."
+    }
+  ],
+
+  reject: [
+    {
+      code: `
+      @mixin foo {
+        .bar {
+          color: blue;
+          .baz & {
+            color: red;
+          }
+        }
+      }
+    `,
+      description: "Parent selector nested in selector within a mixin",
+      message:
+        "Unexpected nested parent selector in @mixin rule. (scss/at-mixin-no-risky-nesting-selector)",
+      column: 11,
+      endColumn: 12,
+      endLine: 7,
+      line: 5
+    },
+    {
+      code: `
+      @mixin foo {
+        .bar {
+          color: blue;
+          & .qux, .baz & .quux{
+            color: red;
+          }
+        }
+      }
+    `,
+      description: "Parent selector nested in complex selector within mixin",
+      message:
+        "Unexpected nested parent selector in @mixin rule. (scss/at-mixin-no-risky-nesting-selector)",
+      column: 11,
+      endColumn: 12,
+      endLine: 7,
+      line: 5
+    },
+    {
+      code: `
+      @mixin foo {
+        .bar {
+          color: blue;
+          & .qux & {
+            color: red;
+          }
+        }
+      }
+    `,
+      description: "Parent selector nested in complex selector within mixin",
+      message:
+        "Unexpected nested parent selector in @mixin rule. (scss/at-mixin-no-risky-nesting-selector)",
+      column: 11,
+      endColumn: 12,
+      endLine: 7,
+      line: 5
+    }
+  ]
+});