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

Issue:
Add a Sass-specific property-no-unknown
It would be useful to have a version of stylelint's [property-no-unknown](https://stylelint.io/user-guide/rules/property-no-unknown) rule that's aware of Sass's [nested properties](https://sass-lang.com/documentation/style-rules/declarations#nesting), and so can accurately recognize that

```scss
a {
  font: {weight: bold}
}
```

is valid but

```scss
a {
  font: {stuff: bold}
}
```

is invalid.

Pull request description:
Add property-no-unknown rule
Fix #490.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e5b55a..8d4f0e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 # 5.2.0
 
+- Added: `property-no-unknown` rule to disallow unknown properties. (#490).
 - Added: `at-use-no-redundant-alias` rule to disallow redundant namespace aliases (#445).
 - Added: `function-calculation-no-interpolation` rule to forbid interpolation in calc functions (#539).
 
diff --git a/README.md b/README.md
index 426e1ec..7de6906 100644
--- a/README.md
+++ b/README.md
@@ -134,6 +134,7 @@ Please also see the [example configs](./docs/examples/) for special cases.
 
 - [`declaration-nested-properties`](./src/rules/declaration-nested-properties/README.md): Require or disallow properties with `-` in their names to be in a form of a nested group.
 - [`declaration-nested-properties-no-divided-groups`](./src/rules/declaration-nested-properties-no-divided-groups/README.md): Disallow nested properties of the same "namespace" to be divided into multiple groups.
+- [`property-no-unknown`](./src/rules/property-no-unknown/README.md): Disallow unknown properties, including [nested properties](https://sass-lang.com/documentation/style-rules/declarations/#nesting).
 
 ### Dimension
 
diff --git a/src/rules/index.js b/src/rules/index.js
index 292672c..cdc1179 100644
--- a/src/rules/index.js
+++ b/src/rules/index.js
@@ -60,6 +60,7 @@ const rules = {
   "operator-no-unspaced": require("./operator-no-unspaced"),
   "partial-no-import": require("./partial-no-import"),
   "percent-placeholder-pattern": require("./percent-placeholder-pattern"),
+  "property-no-unknown": require("./property-no-unknown"),
   "selector-nest-combinators": require("./selector-nest-combinators"),
   "selector-no-redundant-nesting-selector": require("./selector-no-redundant-nesting-selector"),
   "selector-no-union-class-name": require("./selector-no-union-class-name")
diff --git a/src/rules/property-no-unknown/README.md b/src/rules/property-no-unknown/README.md
new file mode 100644
index 0000000..90fbdb3
--- /dev/null
+++ b/src/rules/property-no-unknown/README.md
@@ -0,0 +1,207 @@
+# property-no-unknown
+
+Disallow unknown properties. Should be used instead of Stylelint's [property-no-unknown](https://stylelint.io/user-guide/rules/property-no-unknown).
+
+<!-- prettier-ignore -->
+```scss
+a { height: 100%; }
+/** ↑
+ * This property */
+```
+
+This rule considers properties defined in the [CSS Specifications and browser specific properties](https://github.com/betit/known-css-properties#source) to be known.
+
+This rule ignores:
+
+- variables (`$sass`, `@less`, `--custom-property`)
+- vendor-prefixed properties (e.g., `-moz-align-self`, `-webkit-align-self`)
+
+Use option `checkPrefixed` described below to turn on checking of vendor-prefixed properties.
+
+The [`message` secondary option](../../../docs/user-guide/configure.md#message) can accept the arguments of this rule.
+
+## Options
+
+### `true`
+
+The following patterns are considered problems:
+
+<!-- prettier-ignore -->
+```scss
+a {
+  colr: blue;
+}
+```
+
+<!-- prettier-ignore -->
+```scss
+a {
+  my-property: 1;
+}
+```
+
+<!-- prettier-ignore -->
+```scss
+a {
+  font: {
+    stuff: bold;
+  }
+}
+```
+
+The following patterns are _not_ considered problems:
+
+<!-- prettier-ignore -->
+```scss
+a {
+  color: green;
+}
+```
+
+<!-- prettier-ignore -->
+```scss
+a {
+  fill: black;
+}
+```
+
+<!-- prettier-ignore -->
+```scss
+a {
+  -moz-align-self: center;
+}
+```
+
+<!-- prettier-ignore -->
+```scss
+a {
+  -webkit-align-self: center;
+}
+```
+
+<!-- prettier-ignore -->
+```scss
+a {
+  align-self: center;
+}
+```
+
+<!-- prettier-ignore -->
+```scss
+a {
+  font: {
+    weight: bold;
+  }
+}
+```
+
+## Optional secondary options
+
+### `ignoreProperties: ["/regex/", /regex/, "string"]`
+
+Given:
+
+```json
+["/^my-/", "custom"]
+```
+
+The following patterns are _not_ considered problems:
+
+<!-- prettier-ignore -->
+```scss
+a {
+  my-property: 10px;
+}
+```
+
+<!-- prettier-ignore -->
+```scss
+a {
+  my-other-property: 10px;
+}
+```
+
+<!-- prettier-ignore -->
+```scss
+a {
+  custom: 10px;
+}
+```
+
+### `ignoreSelectors: ["/regex/", /regex/, "string"]`
+
+Skips checking properties of the given selectors against this rule.
+
+Given:
+
+```json
+[":root"]
+```
+
+The following patterns are _not_ considered problems:
+
+<!-- prettier-ignore -->
+```scss
+:root {
+  my-property: blue;
+}
+```
+
+### `ignoreAtRules: ["/regex/", /regex/, "string"]`
+
+Ignores properties nested within specified at-rules.
+
+Given:
+
+```json
+["supports"]
+```
+
+The following patterns are _not_ considered problems:
+
+<!-- prettier-ignore -->
+```scss
+@supports (display: grid) {
+  a {
+    my-property: 1;
+  }
+}
+```
+
+### `checkPrefixed: true | false` (default: `false`)
+
+If `true`, this rule will check vendor-prefixed properties.
+
+For example with `true`:
+
+The following patterns are _not_ considered problems:
+
+<!-- prettier-ignore -->
+```scss
+a {
+  -webkit-overflow-scrolling: auto;
+}
+```
+
+<!-- prettier-ignore -->
+```scss
+a {
+  -moz-box-flex: 0;
+}
+```
+
+The following patterns are considered problems:
+
+<!-- prettier-ignore -->
+```scss
+a {
+  -moz-align-self: center;
+}
+```
+
+<!-- prettier-ignore -->
+```scss
+a {
+  -moz-overflow-scrolling: center;
+}
+```
diff --git a/src/rules/property-no-unknown/index.js b/src/rules/property-no-unknown/index.js
new file mode 100644
index 0000000..1230c90
--- /dev/null
+++ b/src/rules/property-no-unknown/index.js
@@ -0,0 +1,128 @@
+"use strict";
+
+const isCustomPropertySet = require("../../utils/isCustomPropertySet");
+const isStandardSyntaxProperty = require("../../utils/isStandardSyntaxProperty");
+const isStandardSyntaxDeclaration = require("../../utils/isStandardSyntaxDeclaration");
+const isType = require("../../utils/isType");
+const optionsMatches = require("../../utils/optionsMatches");
+const namespace = require("../../utils/namespace");
+const ruleUrl = require("../../utils/ruleUrl");
+const properties = require("known-css-properties").all;
+
+const { utils } = require("stylelint");
+const { isBoolean, isRegExp, isString } = require("../../utils/validateTypes");
+
+const ruleName = namespace("property-no-unknown");
+
+function vendorPrefix(node) {
+  const match = node.match(/^(-\w+-)/);
+  if (match) {
+    return match[0] || "";
+  }
+  return "";
+}
+
+const messages = utils.ruleMessages(ruleName, {
+  rejected: property => `Unexpected unknown property "${property}"`
+});
+
+const meta = {
+  url: ruleUrl(ruleName)
+};
+
+function rule(primary, secondaryOptions) {
+  const allValidProperties = new Set(properties);
+
+  return (root, result) => {
+    const validOptions = utils.validateOptions(
+      result,
+      ruleName,
+      { actual: primary },
+      {
+        actual: secondaryOptions,
+        possible: {
+          ignoreProperties: [isString, isRegExp],
+          checkPrefixed: [isBoolean],
+          ignoreSelectors: [isString, isRegExp],
+          ignoreAtRules: [isString, isRegExp]
+        },
+        optional: true
+      }
+    );
+
+    if (!validOptions) {
+      return;
+    }
+
+    const shouldCheckPrefixed =
+      secondaryOptions && secondaryOptions.checkPrefixed;
+
+    root.walkDecls(decl => {
+      const hasNamespace = decl.prop.indexOf(".");
+      let prop =
+        hasNamespace > -1 ? decl.prop.slice(hasNamespace + 1) : decl.prop;
+
+      if (
+        !isStandardSyntaxProperty(prop) ||
+        !isStandardSyntaxDeclaration(decl) ||
+        isCustomPropertySet(prop) ||
+        prop.startsWith("--") ||
+        (!shouldCheckPrefixed && vendorPrefix(prop)) ||
+        optionsMatches(secondaryOptions, "ignoreProperties", prop)
+      ) {
+        return;
+      }
+
+      const parent = decl.parent;
+
+      if (
+        parent &&
+        isType(parent, "rule") &&
+        optionsMatches(secondaryOptions, "ignoreSelectors", parent.selector)
+      ) {
+        return;
+      }
+
+      let node = parent;
+
+      while (node && node.type !== "root") {
+        if (
+          isType(node, "atrule") &&
+          optionsMatches(secondaryOptions, "ignoreAtRules", node.name)
+        ) {
+          return;
+        }
+
+        node = node.parent;
+      }
+
+      // Nested properties
+      if (
+        parent &&
+        isType(parent, "rule") &&
+        parent.selector &&
+        parent.selector[parent.selector.length - 1] === ":" &&
+        parent.selector.substring(0, 2) !== "--"
+      ) {
+        prop = parent.selector.replace(":", "") + "-" + prop;
+      }
+
+      if (allValidProperties.has(prop.toLowerCase())) {
+        return;
+      }
+
+      utils.report({
+        message: messages.rejected(prop),
+        node: decl,
+        word: prop,
+        result,
+        ruleName
+      });
+    });
+  };
+}
+
+rule.ruleName = ruleName;
+rule.messages = messages;
+rule.meta = meta;
+module.exports = rule;
diff --git a/src/utils/isStandardSyntaxDeclaration.js b/src/utils/isStandardSyntaxDeclaration.js
new file mode 100644
index 0000000..f46151b
--- /dev/null
+++ b/src/utils/isStandardSyntaxDeclaration.js
@@ -0,0 +1,50 @@
+"use strict";
+
+/**
+ * Check whether a declaration is standard
+ *
+ * @param {Object} decl
+ * @returns {boolean}
+ */
+module.exports = function (decl) {
+  const prop = decl.prop;
+  const parent = decl.parent;
+
+  // SCSS var (e.g. $var: x), list (e.g. $list: (x)) or map (e.g. $map: (key:value))
+  if (prop.startsWith("$")) {
+    return true;
+  }
+
+  // SCSS var within a namespace (e.g. namespace.$var: x)
+  if (prop.includes(".$")) {
+    return true;
+  }
+
+  // Less var (e.g. @var: x), but exclude variable interpolation (e.g. @{var})
+  if (prop[0] === "@" && prop[1] !== "{") {
+    return false;
+  }
+
+  // Less map declaration
+  if (parent && parent.type === "atrule" && parent.raws.afterName === ":") {
+    return false;
+  }
+
+  // Less map (e.g. #my-map() { myprop: red; })
+  if (
+    parent &&
+    parent.type === "rule" &&
+    parent.selector &&
+    parent.selector.startsWith("#") &&
+    parent.selector.endsWith("()")
+  ) {
+    return false;
+  }
+
+  // Less &:extend
+  if ("extend" in decl && decl.extend) {
+    return false;
+  }
+
+  return true;
+};


Test patch:
diff --git a/src/rules/property-no-unknown/__tests__/index.js b/src/rules/property-no-unknown/__tests__/index.js
new file mode 100644
index 0000000..e99c1fb
--- /dev/null
+++ b/src/rules/property-no-unknown/__tests__/index.js
@@ -0,0 +1,335 @@
+"use strict";
+
+const { messages, ruleName } = require("..");
+
+testRule({
+  ruleName,
+  config: [true],
+
+  accept: [
+    {
+      code: ".foo { color: green; }"
+    },
+    {
+      code: ".foo { COLoR: green; }"
+    },
+    {
+      code: ".foo { fill: black; }"
+    },
+    {
+      code: ".foo { -webkit-align-self: center; }"
+    },
+    {
+      code: ".foo { align-self: center; }"
+    },
+    {
+      code: ".foo { --bg-color: white; }",
+      description: "ignore standard CSS variables"
+    },
+    {
+      code: ".foo { -moz-align-self: center; }",
+      description: "ignore vendor prefixes"
+    },
+    {
+      code: ".foo { *width: 100px; }",
+      description: "ignore CSS hacks"
+    },
+    {
+      code: ".foo { --custom-property-set: { colr: blue; } }",
+      description: "ignore custom property sets"
+    }
+  ],
+
+  reject: [
+    {
+      code: ".foo { colr: blue; }",
+      message: messages.rejected("colr"),
+      line: 1,
+      column: 8,
+      endLine: 1,
+      endColumn: 12
+    },
+    {
+      code: ".foo { COLR: blue; }",
+      message: messages.rejected("COLR"),
+      line: 1,
+      column: 8,
+      endLine: 1,
+      endColumn: 12
+    },
+    {
+      code: ".foo {\n  colr: blue;\n}",
+      message: messages.rejected("colr"),
+      line: 2,
+      column: 3,
+      endLine: 2,
+      endColumn: 7
+    },
+    {
+      code: ".foo { *wdth: 100px; }",
+      message: messages.rejected("wdth"),
+      line: 1,
+      column: 8,
+      endLine: 1,
+      endColumn: 12
+    },
+    {
+      code: ":export { my-property: red; }",
+      message: messages.rejected("my-property"),
+      line: 1,
+      column: 11,
+      endLine: 1,
+      endColumn: 22
+    }
+  ]
+});
+
+testRule({
+  ruleName,
+  customSyntax: "postcss-scss",
+  config: [true],
+
+  accept: [
+    {
+      code: ".foo { $bgColor: white; }",
+      description: "ignore SCSS variables"
+    },
+    {
+      code: ".foo { namespace.$bgColor: white; }",
+      description: "ignore SCSS variables within namespace"
+    },
+    {
+      code: ".foo { #{$prop}: black; }",
+      description: "ignore property interpolation"
+    },
+    {
+      code: ".foo { border: { style: solid; } }",
+      description: "ignore nested properties"
+    }
+  ]
+});
+
+testRule({
+  ruleName,
+  customSyntax: "postcss-less",
+  config: [true],
+
+  accept: [
+    {
+      code: ".foo { @bgColor: white; }",
+      description: "ignore LESS variables"
+    },
+    {
+      code: ".foo { @{prop}: black; }",
+      description: "ignore property interpolation"
+    },
+    {
+      code: ".foo { transform+: rotate(15deg); }",
+      description: "Append property value with space using +"
+    },
+    {
+      code: ".foo { transform+_: rotate(15deg); }",
+      description: "Append property value with space using +_"
+    },
+    {
+      code: "@foo: { prop: red; }",
+      description: "ignore LESS map props"
+    }
+  ]
+});
+
+testRule({
+  ruleName,
+  config: [
+    true,
+    {
+      ignoreProperties: ["-moz-overflow-scrolling", "/^my-/"],
+      checkPrefixed: true
+    }
+  ],
+
+  accept: [
+    {
+      code: ".foo { -webkit-overflow-scrolling: auto; }"
+    },
+    {
+      code: ".foo { -moz-overflow-scrolling: auto; }"
+    },
+    {
+      code: ".foo { my-property: 1; }"
+    },
+    {
+      code: ".foo { my-other-property: 1; }"
+    }
+  ],
+
+  reject: [
+    {
+      code: ".foo { overflow-scrolling: auto; }",
+      message: messages.rejected("overflow-scrolling"),
+      line: 1,
+      column: 8
+    },
+    {
+      code: ".foo { not-my-property: 1; }",
+      message: messages.rejected("not-my-property"),
+      line: 1,
+      column: 8
+    }
+  ]
+});
+
+testRule({
+  ruleName,
+  config: [
+    true,
+    {
+      ignoreProperties: [/^my-/],
+      checkPrefixed: true
+    }
+  ],
+
+  accept: [
+    {
+      code: ".foo { my-property: 1; }"
+    }
+  ],
+
+  reject: [
+    {
+      code: ".foo { not-my-property: 1; }",
+      message: messages.rejected("not-my-property"),
+      line: 1,
+      column: 8
+    }
+  ]
+});
+
+testRule({
+  ruleName,
+  config: [true, { checkPrefixed: true }],
+
+  accept: [
+    {
+      code: ".foo { -webkit-overflow-scrolling: auto; }"
+    },
+    {
+      code: ".foo { -moz-box-flex: 0; }"
+    }
+  ],
+
+  reject: [
+    {
+      code: ".foo { -moz-overflow-scrolling: auto; }",
+      message: messages.rejected("-moz-overflow-scrolling"),
+      line: 1,
+      column: 8
+    },
+    {
+      code: ".foo { -moz-align-self: center; }",
+      message: messages.rejected("-moz-align-self"),
+      line: 1,
+      column: 8
+    }
+  ]
+});
+
+testRule({
+  ruleName,
+  config: [true, { ignoreSelectors: [":export", ":import"] }],
+
+  accept: [
+    {
+      code: ":export { my-property: 1; }"
+    }
+  ],
+
+  reject: [
+    {
+      code: ":not-export { my-property: 1; }",
+      message: messages.rejected("my-property"),
+      line: 1,
+      column: 15
+    },
+    {
+      // Non-regex strings must exactly match the parent selector.
+      code: ':import("path/to/file.css") { my-property: 1; }',
+      message: messages.rejected("my-property"),
+      line: 1,
+      column: 31
+    }
+  ]
+});
+
+testRule({
+  ruleName,
+  config: [true, { ignoreSelectors: ["/:export/", /^:import/] }],
+
+  accept: [
+    {
+      code: ":export { my-property: 1; }"
+    },
+    {
+      code: ':import("path/to/file.css") { my-property: 1; }'
+    }
+  ],
+
+  reject: [
+    {
+      code: ":exprat { my-property: 1; }",
+      message: messages.rejected("my-property"),
+      line: 1,
+      column: 11
+    }
+  ]
+});
+
+testRule({
+  ruleName,
+  config: [true, { ignoreAtRules: ["supports", /^my-/] }],
+
+  accept: [
+    {
+      code: "@supports (display: grid) { my-property: 1; }"
+    },
+    {
+      code: "@my-at-rule { foo: 1; }"
+    },
+    {
+      code: "@supports (display:grid) { @media (min-width: 10px) { foo: 1; } }"
+    },
+    {
+      code: "@supports (display:grid) { @media (min-width: 10px) { a { foo: 1; } } }"
+    },
+    {
+      code: "@my-other-at-rule { a { foo: 1; } }"
+    }
+  ],
+
+  reject: [
+    {
+      code: "@media screen { a { foo: 1; } }",
+      message: messages.rejected("foo"),
+      line: 1,
+      column: 21
+    },
+    {
+      code: "@not-my-at-rule { foo: 1; }",
+      message: messages.rejected("foo"),
+      line: 1,
+      column: 19
+    },
+    {
+      code: "a { foo: 1; }",
+      message: messages.rejected("foo"),
+      line: 1,
+      column: 5
+    },
+    {
+      code: "@not-my-at-rule foobar { foo: 1; }",
+      message: messages.rejected("foo"),
+      line: 1,
+      column: 26
+    }
+  ]
+});