From 2c6427f417d46dbc10c99bc9d0648fef11bf7baa Mon Sep 17 00:00:00 2001
From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com>
Date: Wed, 21 Oct 2026 00:08:30 +0200
Subject: [PATCH 1/6] Remove vNext
---
CHANGELOG.md | 21 +
docs/reference/cli/index.md | 8 +
docs/reference/cli/lint/index.md | 386 +++++++++
docs/reference/cli/lint/rules.md | 347 ++++++++
docs/reference/schemas/outputs/lint/result.md | 95 ++
docs/reference/schemas/outputs/lint/rules.md | 109 +++
dsc/dsc.settings.json | 3 +
dsc/dsc_default.settings.json | 3 +
dsc/locales/en-us.toml | 14 +
dsc/src/args.rs | 26 +
dsc/src/main.rs | 83 +-
dsc/src/subcommand.rs | 214 ++++-
dsc/src/util.rs | 61 +-
dsc/tests/dsc_lint.tests.ps1 | 301 +++++++
lib/dsc-lib/locales/en-us.toml | 131 +++
lib/dsc-lib/locales/schemas.outputs.yaml | 27 +
lib/dsc-lib/src/configure/mod.rs | 11 +-
lib/dsc-lib/src/dscerror.rs | 3 +
lib/dsc-lib/src/dscresources/dscresource.rs | 47 +
lib/dsc-lib/src/functions/mod.rs | 109 ++-
lib/dsc-lib/src/lib.rs | 1 +
lib/dsc-lib/src/lint/context.rs | 202 +++++
lib/dsc-lib/src/lint/diagnostic.rs | 234 +++++
lib/dsc-lib/src/lint/expression_index.rs | 366 ++++++++
lib/dsc-lib/src/lint/mod.rs | 818 ++++++++++++++++++
lib/dsc-lib/src/lint/options.rs | 220 +++++
lib/dsc-lib/src/lint/pointer.rs | 165 ++++
lib/dsc-lib/src/lint/rule.rs | 158 ++++
lib/dsc-lib/src/lint/rules/dependency.rs | 438 ++++++++++
lib/dsc-lib/src/lint/rules/deprecation.rs | 206 +++++
lib/dsc-lib/src/lint/rules/document.rs | 364 ++++++++
lib/dsc-lib/src/lint/rules/environment.rs | 124 +++
lib/dsc-lib/src/lint/rules/expression.rs | 331 +++++++
lib/dsc-lib/src/lint/rules/mod.rs | 49 ++
lib/dsc-lib/src/lint/rules/parameter.rs | 648 ++++++++++++++
lib/dsc-lib/src/lint/rules/resource.rs | 427 +++++++++
lib/dsc-lib/src/parser/expressions.rs | 12 +
lib/dsc-lib/src/parser/functions.rs | 6 +
lib/dsc-lib/src/parser/mod.rs | 88 +-
lib/dsc-lib/src/util.rs | 28 +
.../tests/integration/schemas/schema_for.rs | 11 +
xtask/src/schemas/export.rs | 2 +
42 files changed, 6818 insertions(+), 79 deletions(-)
create mode 100644 docs/reference/cli/lint/index.md
create mode 100644 docs/reference/cli/lint/rules.md
create mode 100644 docs/reference/schemas/outputs/lint/result.md
create mode 100644 docs/reference/schemas/outputs/lint/rules.md
create mode 100644 dsc/tests/dsc_lint.tests.ps1
create mode 100644 lib/dsc-lib/locales/schemas.outputs.yaml
create mode 100644 lib/dsc-lib/src/lint/context.rs
create mode 100644 lib/dsc-lib/src/lint/diagnostic.rs
create mode 100644 lib/dsc-lib/src/lint/expression_index.rs
create mode 100644 lib/dsc-lib/src/lint/mod.rs
create mode 100644 lib/dsc-lib/src/lint/options.rs
create mode 100644 lib/dsc-lib/src/lint/pointer.rs
create mode 100644 lib/dsc-lib/src/lint/rule.rs
create mode 100644 lib/dsc-lib/src/lint/rules/dependency.rs
create mode 100644 lib/dsc-lib/src/lint/rules/deprecation.rs
create mode 100644 lib/dsc-lib/src/lint/rules/document.rs
create mode 100644 lib/dsc-lib/src/lint/rules/environment.rs
create mode 100644 lib/dsc-lib/src/lint/rules/expression.rs
create mode 100644 lib/dsc-lib/src/lint/rules/mod.rs
create mode 100644 lib/dsc-lib/src/lint/rules/parameter.rs
create mode 100644 lib/dsc-lib/src/lint/rules/resource.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc5d85e86..cab2b748d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -77,6 +77,25 @@ changes since the last release, see the [diff on GitHub][unreleased].
### Added
+- Added the `dsc lint` command, which checks a configuration document for problems without
+ applying it. The command validates the document against the configuration document schema and
+ runs a set of rules that report duplicate resource names, unresolved and circular dependencies,
+ expressions that don't parse or call unknown functions, undeclared or unused parameters and
+ variables, invalid parameter constraints, deprecated features, and, with resource discovery,
+ resource types that aren't installed or literal properties that don't match a resource's schema.
+ You can change the level of a rule for a run with the `--rule` option or for every run in the
+ new `lint` section of the settings file, and control which level makes the command fail with
+ `--fail-on`. The `dsc schema` command exposes the new `lint-result` and `lint-rules` schemas.
+
+ Related work items
+
+ - Issues:
+ - [#591][#591]
+ - [#842][#842]
+ - PRs: _None_
+
+
+
- Added what-if support to the MCP server tools. The `invoke_dsc_config` tool now accepts a
`what_if` option for the `set` operation, and the `invoke_dsc_resource` tool accepts `what_if`
for the `set` and `delete` operations. This mirrors the `--what-if` flag on the `dsc config set`,
@@ -1764,6 +1783,7 @@ Version `3.0.0` is the first generally available release of DSC.
[#515]: https://github.com/PowerShell/DSC/issues/515
[#543]: https://github.com/PowerShell/DSC/issues/543
[#566]: https://github.com/PowerShell/DSC/issues/566
+[#591]: https://github.com/PowerShell/DSC/issues/591
[#57]: https://github.com/PowerShell/DSC/issues/57
[#598]: https://github.com/PowerShell/DSC/issues/598
[#628]: https://github.com/PowerShell/DSC/issues/628
@@ -1828,6 +1848,7 @@ Version `3.0.0` is the first generally available release of DSC.
[#832]: https://github.com/PowerShell/DSC/issues/832
[#834]: https://github.com/PowerShell/DSC/issues/834
[#841]: https://github.com/PowerShell/DSC/issues/841
+[#842]: https://github.com/PowerShell/DSC/issues/842
[#843]: https://github.com/PowerShell/DSC/issues/843
[#844]: https://github.com/PowerShell/DSC/issues/844
[#845]: https://github.com/PowerShell/DSC/issues/845
diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md
index f0823aa37..ca243e89c 100644
--- a/docs/reference/cli/index.md
+++ b/docs/reference/cli/index.md
@@ -42,6 +42,13 @@ The `function` command manages DSC functions. You can use it to:
For more information, see [dsc function][03]
+### lint
+
+The `lint` command checks a configuration document for problems without applying it. It reports
+duplicate resources, invalid expressions, unresolved dependencies, invalid parameters, deprecated
+features, and, with resource discovery, resource types that aren't installed. For more
+information, see [dsc lint][07].
+
### mcp
The `mcp` command starts DSC as a Model Context Protocol (MCP) server. For more information, see
@@ -252,3 +259,4 @@ file, ensure that the input is encoded as UTF-8.
[04]: server/index.md
[05]: resource/index.md
[06]: schema/index.md
+[07]: lint/index.md
diff --git a/docs/reference/cli/lint/index.md b/docs/reference/cli/lint/index.md
new file mode 100644
index 000000000..487cc412c
--- /dev/null
+++ b/docs/reference/cli/lint/index.md
@@ -0,0 +1,386 @@
+---
+description: Command line reference for the 'dsc lint' command
+ms.date: 10/20/2026
+ms.topic: reference
+title: dsc lint
+---
+
+# dsc lint
+
+## Synopsis
+
+Checks a configuration document for problems without applying it.
+
+## Syntax
+
+### Configuration document from file
+
+```sh
+dsc lint [Options] --file
+```
+
+### Configuration document from option string
+
+```sh
+dsc lint [Options] --input
+```
+
+### Configuration document from stdin
+
+```sh
+cat | dsc lint [Options] --file -
+```
+
+### Several configuration documents
+
+```sh
+dsc lint [Options] --file --file
+```
+
+### List the rules
+
+```sh
+dsc lint --list-rules [--output-format ]
+```
+
+## Description
+
+The `lint` command analyzes a configuration document and reports problems as diagnostics. It never
+invokes a resource operation, so it's safe to run on any machine, including CI agents.
+
+The command works in stages:
+
+1. It parses the document as JSON or YAML. A document that doesn't parse is reported with the
+ `parse-error` rule.
+1. It validates the document against the configuration document JSON schema. Every violation is
+ reported with the `schema-invalid` rule and the JSON pointer to the problem.
+1. It runs the static rules, which only need the document. These rules find duplicate resources,
+ expressions that don't parse or call unknown functions, undeclared parameters and variables,
+ unresolved and circular dependencies, invalid parameter constraints, deprecated features, and
+ more.
+1. Unless you specify [--no-discovery](#--no-discovery), it discovers the resource types the
+ document uses and runs the resource rules, which report types that aren't installed, deprecated
+ types, and literal properties that don't match the resource's schema.
+
+Each diagnostic has a level: `error`, `warning`, or `info`. By default the command exits with code
+`5` when it reports at least one error. Use [--fail-on](#--fail-on) to fail on warnings or
+informational diagnostics instead. Use [--rule](#--rule) or the `lint` section of the DSC settings
+file to change the level of a rule or turn it off. A few rules have a fixed level that can't be
+changed: `parse-error`, `schema-invalid`, `unsupported-document-type`, and `discovery-failed`.
+
+For the list of rules and what each one checks, see [dsc lint rules][01].
+
+## Examples
+
+### Example 1 - Lint a configuration document
+
+
+
+The command reports a duplicate resource name and exits with code `5`.
+
+```yaml
+# example.dsc.config.yaml
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: EnsureTest1Value
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: one
+- name: EnsureTest1Value
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: two
+```
+
+```sh
+dsc lint --file ./example.dsc.config.yaml
+```
+
+```yaml
+diagnostics:
+- level: error
+ rule: duplicate-resource
+ category: document
+ message: Resource 'EnsureTest1Value' of type 'Microsoft.DSC.Debug/Echo' is declared more than once
+ path: /resources/1
+ file: ./example.dsc.config.yaml
+summary:
+ errors: 1
+ warnings: 0
+ info: 0
+valid: false
+discovery: true
+```
+
+### Example 2 - Fail on warnings in a pipeline
+
+
+
+The command exits with code `5` when any warning or error is reported, so a pipeline step fails on
+an unused parameter as well as on an error. Discovery is skipped because the build agent doesn't
+have the resources installed.
+
+```sh
+dsc lint --file ./example.dsc.config.yaml --no-discovery --fail-on warning
+```
+
+### Example 3 - Change the level of a rule
+
+
+
+The command turns off the `no-unused-parameters` rule and raises `missing-require-version`, which
+is off by default, to a warning.
+
+```sh
+dsc lint --file ./example.dsc.config.yaml --rule no-unused-parameters=off --rule missing-require-version=warning
+```
+
+### Example 4 - Check a document that targets another system root
+
+
+
+When you pass `--system-root`, the `system-root-unused` rule warns if no expression in the document
+calls the `systemRoot()` function, because the document wouldn't target the specified root.
+
+```sh
+dsc lint --file ./example.dsc.config.yaml --system-root D:\
+```
+
+### Example 5 - List the rules
+
+
+
+The command lists every rule with its category, the level it reports at, whether it needs resource
+discovery, and a description.
+
+```sh
+dsc lint --list-rules
+```
+
+## Options
+
+### -i, --input
+
+
+
+
+Specifies the configuration document to lint.
+
+The document must be a string containing a JSON or YAML object.
+
+This option is mutually exclusive with the `--file` option.
+
+```yaml
+Type : string
+Mandatory : false
+LongSyntax : --input
+ShortSyntax : -i
+```
+
+### -f, --file
+
+
+
+
+Defines the path to a configuration document to lint. Specify the option more than once to lint
+several documents in one run; the diagnostics then include the `file` property.
+
+You can also use this option to pass a configuration document from stdin with `-` as the path.
+
+This option is mutually exclusive with the `--input` option.
+
+```yaml
+Type : string
+Mandatory : false
+LongSyntax : --file
+ShortSyntax : -f
+```
+
+### -p, --parameters
+
+
+
+
+Specifies the parameter values that would be passed to the configuration, as a JSON or YAML
+object. When you specify parameters, the command checks that every supplied value matches a
+declared parameter, that every parameter without a default value has a value, and that each value
+satisfies the parameter's type and constraints.
+
+```yaml
+Type : string
+Mandatory : false
+LongSyntax : --parameters
+ShortSyntax : -p
+```
+
+### --parameters-file
+
+
+
+Defines the path to a JSON or YAML file with the parameter values. Use `-` to read the parameters
+from stdin. Values from `--parameters` take precedence over values from the file.
+
+```yaml
+Type : string
+Mandatory : false
+LongSyntax : --parameters-file
+```
+
+### -r, --system-root
+
+
+
+
+Specifies the operating system root path the configuration would target. The command doesn't
+check that the path exists; it only enables the `system-root-unused` rule.
+
+```yaml
+Type : string
+Mandatory : false
+LongSyntax : --system-root
+ShortSyntax : -r
+```
+
+### --no-discovery
+
+
+
+Skips resource discovery. The rules that inspect installed resources don't run, and the output
+reports `discovery: false`. Use this option on machines that don't have the resources installed,
+or to lint faster.
+
+```yaml
+Type : boolean
+Mandatory : false
+LongSyntax : --no-discovery
+```
+
+### --fail-on
+
+
+
+Defines the lowest diagnostic level that makes the command exit with code `5`. The default is
+`error`.
+
+```yaml
+Type : string
+Mandatory : false
+DefaultValue : error
+ValidValues : [error, warning, info]
+LongSyntax : --fail-on
+```
+
+### --rule
+
+
+
+Overrides the level of a rule for this run, as `=`. The level is one of `off`,
+`info`, `warning`, or `error`. Specify the option more than once to override several rules. The
+command exits with code `1` for an unknown rule.
+
+Overrides from this option take precedence over the `lint` section of the DSC settings file. The
+policy settings file takes precedence over this option.
+
+```yaml
+Type : string
+Mandatory : false
+LongSyntax : --rule
+```
+
+### --list-rules
+
+
+
+Lists the rules with the level each reports at in this run instead of linting a document. When
+the output isn't redirected, the command shows a table; otherwise it emits the list in the format
+specified by `--output-format`. For the schema of the list, see [dsc lint rules schema][03].
+
+This option is mutually exclusive with the `--input` and `--file` options.
+
+```yaml
+Type : boolean
+Mandatory : false
+LongSyntax : --list-rules
+```
+
+### -o, --output-format
+
+
+
+
+The `--output-format` option controls which format DSC uses for the data the command returns. The
+available formats are:
+
+- `json` to emit the data as a [JSON Line][02].
+- `pretty-json` to emit the data as JSON with newlines, indentation, and spaces for readability.
+- `yaml` to emit the data as YAML.
+
+The default output format depends on whether DSC detects that the output is being redirected or
+captured as a variable:
+
+- If the command isn't being redirected or captured, DSC displays the output as the `yaml` format
+ in the console.
+- If the command output is redirected or captured, DSC emits the data as the `json` format to
+ stdout.
+
+```yaml
+Type : string
+Mandatory : false
+ValidValues : [json, pretty-json, yaml]
+LongSyntax : --output-format
+ShortSyntax : -o
+```
+
+### -h, --help
+
+
+
+
+Displays the help for the current command or subcommand. When you specify this option, the
+application ignores all other options and arguments.
+
+```yaml
+Type : boolean
+Mandatory : false
+LongSyntax : --help
+ShortSyntax : -h
+```
+
+## Output
+
+This command returns the diagnostics for the document, counts of the diagnostics by level, whether
+the document has no errors, and whether resource discovery ran. The command always writes the
+result to stdout, including when the document doesn't parse. For more information, see
+[dsc lint result schema][04].
+
+## Exit codes
+
+| Exit Code | Meaning |
+| :-------: | :------------------------------------------------------------------------------- |
+| `0` | No diagnostic at or above the `--fail-on` level was reported. |
+| `1` | The command received invalid arguments, such as an unknown rule. |
+| `2` | The command failed for another reason, such as an unreadable settings file. |
+| `4` | A document or the parameters couldn't be read. |
+| `5` | At least one diagnostic at or above the `--fail-on` level was reported. |
+
+## Settings
+
+The `lint` section of the DSC settings file sets the level of rules for every run. The same
+section in the policy settings file takes precedence over the `--rule` option.
+
+```json
+{
+ "lint": {
+ "rules": {
+ "no-unused-parameters": { "level": "off" },
+ "missing-require-version": { "level": "warning" }
+ }
+ }
+}
+```
+
+
+[01]: rules.md
+[02]: https://jsonlines.org/
+[03]: ../../schemas/outputs/lint/rules.md
+[04]: ../../schemas/outputs/lint/result.md
diff --git a/docs/reference/cli/lint/rules.md b/docs/reference/cli/lint/rules.md
new file mode 100644
index 000000000..79b0cf21a
--- /dev/null
+++ b/docs/reference/cli/lint/rules.md
@@ -0,0 +1,347 @@
+---
+description: Reference for the rules the 'dsc lint' command checks configuration documents against
+ms.date: 10/20/2026
+ms.topic: reference
+title: dsc lint rules
+---
+
+# dsc lint rules
+
+The `dsc lint` command checks a configuration document against the rules on this page. Each rule
+has an identifier you can use with the `--rule` option or in the `lint` section of the settings
+file, a category, a default level, and a tier:
+
+- `static` rules only need the document.
+- `resource` rules need the installed resources and only run when discovery is enabled.
+
+Rules with a fixed level can't be changed or turned off.
+
+## Rules at a glance
+
+| Rule | Category | Default level | Tier |
+| :------------------------------- | :---------- | :------------ | :------- |
+| `parse-error` | document | error (fixed) | static |
+| `schema-invalid` | document | error (fixed) | static |
+| `unsupported-document-type` | document | error (fixed) | static |
+| `duplicate-resource` | document | error | static |
+| `duplicate-resource-name` | document | warning | static |
+| `empty-resources` | document | warning | static |
+| `unknown-microsoft-dsc-metadata` | document | warning | static |
+| `api-version-alias` | document | info | static |
+| `date-version` | document | warning | static |
+| `missing-require-version` | document | off | static |
+| `invalid-expression` | expression | error | static |
+| `unknown-function` | expression | error | static |
+| `invalid-argument-count` | expression | error | static |
+| `invalid-argument-type` | expression | error | static |
+| `undefined-parameter` | expression | error | static |
+| `undefined-variable` | expression | error | static |
+| `condition-not-expression` | expression | warning | static |
+| `no-unused-parameters` | parameter | warning | static |
+| `no-unused-variables` | parameter | warning | static |
+| `parameter-default-type` | parameter | error | static |
+| `invalid-parameter-constraints` | parameter | error | static |
+| `secure-parameter-default` | parameter | warning | static |
+| `prefer-secure-type` | parameter | warning | static |
+| `secure-value-in-output` | parameter | warning | static |
+| `use-parameter-description` | parameter | off | static |
+| `undeclared-parameter-value` | parameter | error | static |
+| `missing-parameter-value` | parameter | error | static |
+| `parameter-value-type` | parameter | error | static |
+| `unresolved-dependency` | dependency | error | static |
+| `circular-dependency` | dependency | error | static |
+| `reference-without-depends-on` | dependency | warning | static |
+| `duplicate-depends-on` | dependency | info | static |
+| `copy-loop-deprecated` | deprecation | warning | static |
+| `copy-options-unsupported` | deprecation | error | static |
+| `security-context-in-metadata` | deprecation | warning | static |
+| `security-context-conflict` | deprecation | error | static |
+| `dsc-version-unsatisfied` | deprecation | warning | static |
+| `system-root-unused` | environment | warning | static |
+| `hardcoded-system-path` | environment | off | static |
+| `discovery-failed` | resource | warning (fixed) | resource |
+| `unknown-resource-type` | resource | warning | resource |
+| `unsatisfied-require-version` | resource | warning | resource |
+| `deprecated-resource` | resource | warning | resource |
+| `property-schema-mismatch` | resource | error | resource |
+| `nested-resources-unsupported` | resource | warning | resource |
+| `exist-false-unsupported` | resource | error | resource |
+| `canonical-type-name` | resource | info | resource |
+
+## Document rules
+
+### parse-error
+
+The document must be valid JSON or YAML. The diagnostic includes the parser's message and, when
+known, the line and column of the problem. This rule has a fixed level.
+
+### schema-invalid
+
+The document must validate against the configuration document JSON schema. The command reports
+every violation with the JSON pointer to the problem, such as an unknown property, a missing
+`$schema`, or a `dependsOn` entry that isn't a `resourceId()` expression. This rule has a fixed
+level.
+
+### unsupported-document-type
+
+Only configuration documents can be linted. A document whose `$schema` identifies a resource or
+extension manifest is rejected. This rule has a fixed level.
+
+### duplicate-resource
+
+Resource names must be unique among resources of the same type at the same level. DSC refuses to
+run a document that declares the same resource twice. Types are compared without regard to case.
+
+```yaml
+resources:
+- name: Same
+ type: Microsoft.DSC.Debug/Echo
+- name: Same # duplicate-resource
+ type: microsoft.dsc.debug/echo
+```
+
+### duplicate-resource-name
+
+Resources at the same level should have distinct names even when their types differ, because DSC
+resolves dependencies by name first and then checks the type.
+
+### empty-resources
+
+The configuration should declare at least one resource.
+
+### unknown-microsoft-dsc-metadata
+
+Keys under `Microsoft.DSC` metadata must be ones DSC recognizes. DSC silently ignores unknown
+keys, so a typo such as `securityContxt` has no effect.
+
+### api-version-alias
+
+Resources should use `requireVersion` rather than its alias `apiVersion`.
+
+### date-version
+
+Version requirements should be semantic. Date-based versions such as `2025-01-01` are deprecated
+and only supported for compatibility.
+
+### missing-require-version
+
+Resources should pin the version they require with `requireVersion`. This rule is off by default;
+enable it to require reproducible documents.
+
+## Expression rules
+
+### invalid-expression
+
+Every string that starts with `[` is evaluated as an expression and must parse. To use a literal
+string that starts with a bracket, escape it with `[[`.
+
+```yaml
+properties:
+ output: "[concat('unterminated)]" # invalid-expression
+ literal: "[[not an expression]" # fine
+```
+
+### unknown-function
+
+Expressions may only call built-in functions or user-defined functions declared in `functions`.
+Function names are case-sensitive; the diagnostic suggests the correct casing when the name
+matches a built-in function except for case.
+
+### invalid-argument-count
+
+Function calls must pass the number of arguments the function accepts. The message is the same one
+DSC raises at runtime.
+
+### invalid-argument-type
+
+Literal arguments must be of a kind the function accepts at that position. Arguments that are
+nested expressions aren't checked, because their value is only known at runtime.
+
+### undefined-parameter
+
+`parameters()` must reference a parameter declared in `parameters`. Calls inside user-defined
+function bodies refer to the function's own parameters and aren't checked.
+
+### undefined-variable
+
+`variables()` must reference a variable declared in `variables`.
+
+### condition-not-expression
+
+A `condition` on a resource or output must be an expression that evaluates to `true`. A plain
+string such as `"true"` never does, so the item is always skipped.
+
+## Parameter rules
+
+### no-unused-parameters
+
+Declared parameters should be referenced by at least one expression.
+
+### no-unused-variables
+
+Declared variables should be referenced by at least one expression.
+
+### parameter-default-type
+
+A literal default value must satisfy the parameter's type, `allowedValues`, value limits, and
+length limits. Default values that are expressions aren't checked.
+
+### invalid-parameter-constraints
+
+Parameter constraints must be consistent with each other and with the parameter type: `minValue`
+can't exceed `maxValue`, `minLength` can't exceed `maxLength`, value limits only apply to `int`
+parameters, length limits only apply to `string`, `secureString`, and `array` parameters,
+`allowedValues` can't be empty, and every allowed value must be of the parameter's type.
+
+### secure-parameter-default
+
+A `secureString` or `secureObject` parameter shouldn't have a hard-coded default value, because
+anyone with the document can read it. An empty string or an expression is allowed.
+
+### prefer-secure-type
+
+A `string` or `object` parameter whose name suggests a secret, such as `password`, `token`, or
+`apiKey`, should use the `secureString` or `secureObject` type.
+
+### secure-value-in-output
+
+Outputs shouldn't expose a secure parameter or a `secret()` value. DSC skips such outputs at
+runtime.
+
+### use-parameter-description
+
+Parameters should have a description. This rule is off by default.
+
+### undeclared-parameter-value
+
+When you pass parameters with `--parameters` or `--parameters-file`, every supplied value must
+match a declared parameter.
+
+### missing-parameter-value
+
+When you pass parameters, every declared parameter without a default value must be supplied a
+value. Without parameters this rule doesn't run, because a document can declare required
+parameters on purpose.
+
+### parameter-value-type
+
+When you pass parameters, every supplied value must satisfy the parameter's type and constraints.
+
+## Dependency rules
+
+### unresolved-dependency
+
+Every `dependsOn` entry and every `reference(resourceId(...))` call must point at a resource
+declared at the same level. The diagnostic distinguishes a missing resource from a resource whose
+type doesn't match. Resources in copy loops aren't checked, because their names are only known at
+runtime.
+
+```yaml
+resources:
+- name: first
+ type: Microsoft.DSC.Debug/Echo
+- name: second
+ type: Microsoft.DSC.Debug/Echo
+ dependsOn:
+ - "[resourceId('Microsoft.DSC.Debug/Echo', 'missing')]" # unresolved-dependency
+```
+
+### circular-dependency
+
+Resource dependencies must not form a cycle, including a resource that depends on itself. The
+message shows the cycle, like `a -> b -> a`.
+
+### reference-without-depends-on
+
+A resource that references another resource with `reference()` must list it in `dependsOn`. DSC
+only makes the output of resources that have already run available to `reference()`, so the
+reference fails when the referenced resource is declared later.
+
+### duplicate-depends-on
+
+A dependency should be listed once.
+
+## Deprecation rules
+
+### copy-loop-deprecated
+
+Copy loops are deprecated and will be removed in a future release. See
+[issue #1429][01] for details.
+
+### copy-options-unsupported
+
+Copy loops don't support the `mode` and `batchSize` options; DSC rejects a document that sets
+them.
+
+### security-context-in-metadata
+
+The security context should be set with `directives.securityContext` rather than the deprecated
+`Microsoft.DSC` metadata.
+
+### security-context-conflict
+
+When both the metadata and the directive set a security context, they must agree; DSC refuses to
+run the document otherwise.
+
+### dsc-version-unsatisfied
+
+The `directives.version` requirement should be satisfied by the version of DSC in use. The rule
+checks the version of the `dsc` command that runs the linter.
+
+## Environment rules
+
+### system-root-unused
+
+When you pass `--system-root`, at least one expression in the document should call `systemRoot()`.
+Otherwise the document doesn't target the specified root.
+
+### hardcoded-system-path
+
+Absolute operating system paths such as `C:\Windows` or `/etc` should be built with `systemRoot()`
+so the document can target another root. This rule is off by default because many documents
+intentionally target the running system.
+
+## Resource rules
+
+These rules run after resource discovery and are skipped with `--no-discovery`.
+
+### discovery-failed
+
+Resource discovery must succeed for the resource rules to run. When it fails, the command reports
+this diagnostic and continues with the static results only. This rule has a fixed level.
+
+### unknown-resource-type
+
+Each resource type must be installed. This rule reports a warning rather than an error, because a
+document can be valid on a machine that has the resource.
+
+### unsatisfied-require-version
+
+An installed version of the resource type must satisfy `requireVersion`.
+
+### deprecated-resource
+
+Resources shouldn't use resource types whose manifest marks them as deprecated. The diagnostic
+includes the deprecation message from the manifest.
+
+### property-schema-mismatch
+
+Literal property values must validate against the resource's embedded schema. Properties that hold
+expressions aren't checked, because their value is only known at runtime. The rule never invokes
+the resource to retrieve its schema.
+
+### nested-resources-unsupported
+
+Only group and adapter resources can declare nested `resources`.
+
+### exist-false-unsupported
+
+A resource type must have the `delete` or `setHandlesExist` capability for `_exist: false` to
+work. Without either, `dsc config set` fails for the resource.
+
+### canonical-type-name
+
+Resource types should be written with the casing their manifest uses.
+
+
+[01]: https://github.com/PowerShell/DSC/issues/1429
diff --git a/docs/reference/schemas/outputs/lint/result.md b/docs/reference/schemas/outputs/lint/result.md
new file mode 100644
index 000000000..736dae6ea
--- /dev/null
+++ b/docs/reference/schemas/outputs/lint/result.md
@@ -0,0 +1,95 @@
+---
+description: JSON schema reference for the data returned by the 'dsc lint' command.
+ms.date: 10/20/2026
+ms.topic: reference
+title: dsc lint result schema reference
+---
+
+# dsc lint result schema reference
+
+## Synopsis
+
+The result output from the `dsc lint` command.
+
+## Metadata
+
+```yaml
+SchemaDialect: https://json-schema.org/draft/2020-12/schema
+SchemaID: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.0/outputs/lint/result.json
+Type: object
+```
+
+## Description
+
+The output from the `dsc lint` command includes every diagnostic the rules reported, counts of the
+diagnostics by level, whether the document is free of error-level diagnostics, and whether
+resource discovery ran. The command always emits this object, including when the document doesn't
+parse.
+
+## Required properties
+
+The output always includes these properties:
+
+- [diagnostics](#diagnostics)
+- [summary](#summary)
+- [valid](#valid)
+- [discovery](#discovery)
+
+## Properties
+
+### diagnostics
+
+The problems the rules reported, ordered by file, path, and rule. Each entry is an object with the
+following properties:
+
+- `level` is the severity of the problem: `error`, `warning`, or `info`.
+- `rule` is the identifier of the rule that reported the problem, such as `duplicate-resource`.
+- `category` is the area of the document the rule inspects: `document`, `expression`, `parameter`,
+ `dependency`, `deprecation`, `environment`, or `resource`.
+- `message` describes the problem.
+- `path` is the JSON pointer ([RFC 6901][01]) to the location of the problem in the document. An
+ empty string identifies the document root.
+- `file` is the file the document was read from. The property is omitted for documents passed
+ with `--input` or from stdin.
+- `location` is the position of the problem in the source text as an object with `line` and
+ `column` properties, both starting at `1`. The property is only present for problems that
+ prevented the document from parsing.
+
+```yaml
+Type: array
+Required: true
+```
+
+### summary
+
+Counts of the diagnostics by level, as an object with the `errors`, `warnings`, and `info`
+properties.
+
+```yaml
+Type: object
+Required: true
+```
+
+### valid
+
+Whether no error-level diagnostics were reported. This property doesn't depend on the `--fail-on`
+option.
+
+```yaml
+Type: boolean
+Required: true
+```
+
+### discovery
+
+Whether resource discovery ran. When `false`, the rules that inspect installed resources were
+skipped, either because of the `--no-discovery` option, because the document sets
+`directives.resourceDiscovery` to `duringDeployment`, or because discovery failed.
+
+```yaml
+Type: boolean
+Required: true
+```
+
+
+[01]: https://www.rfc-editor.org/rfc/rfc6901
diff --git a/docs/reference/schemas/outputs/lint/rules.md b/docs/reference/schemas/outputs/lint/rules.md
new file mode 100644
index 000000000..649e1c819
--- /dev/null
+++ b/docs/reference/schemas/outputs/lint/rules.md
@@ -0,0 +1,109 @@
+---
+description: JSON schema reference for the data returned by the 'dsc lint --list-rules' command.
+ms.date: 10/20/2026
+ms.topic: reference
+title: dsc lint rules schema reference
+---
+
+# dsc lint rules schema reference
+
+## Synopsis
+
+The output from the `dsc lint --list-rules` command.
+
+## Metadata
+
+```yaml
+SchemaDialect: https://json-schema.org/draft/2020-12/schema
+SchemaID: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.1.0/outputs/lint/rules.json
+Type: object
+```
+
+## Description
+
+The `dsc lint --list-rules` command returns an array of objects with this schema, one per rule,
+sorted by rule identifier. Each object describes the rule and the level it reports at after the
+settings file and any `--rule` overrides are applied.
+
+## Required properties
+
+Each entry always includes these properties:
+
+- [id](#id)
+- [category](#category)
+- [tier](#tier)
+- [defaultLevel](#defaultlevel)
+- [level](#level)
+- [fixed](#fixed)
+- [description](#description)
+
+## Properties
+
+### id
+
+The identifier of the rule, such as `duplicate-resource`.
+
+```yaml
+Type: string
+Required: true
+```
+
+### category
+
+The area of the document the rule inspects: `document`, `expression`, `parameter`, `dependency`,
+`deprecation`, `environment`, or `resource`.
+
+```yaml
+Type: string
+Required: true
+```
+
+### tier
+
+When the rule can run: `static` rules only need the document, `resource` rules need the installed
+resources and only run when discovery is enabled.
+
+```yaml
+Type: string
+Required: true
+ValidValues: [static, resource]
+```
+
+### defaultLevel
+
+The level the rule reports at unless it is overridden: `off`, `info`, `warning`, or `error`.
+
+```yaml
+Type: string
+Required: true
+ValidValues: [off, info, warning, error]
+```
+
+### level
+
+The level the rule reports at in this run, after the settings file and any `--rule` overrides are
+applied.
+
+```yaml
+Type: string
+Required: true
+ValidValues: [off, info, warning, error]
+```
+
+### fixed
+
+Whether the level of the rule can't be changed.
+
+```yaml
+Type: boolean
+Required: true
+```
+
+### description
+
+What the rule checks.
+
+```yaml
+Type: string
+Required: true
+```
diff --git a/dsc/dsc.settings.json b/dsc/dsc.settings.json
index 19acd63ea..0ca7abad1 100644
--- a/dsc/dsc.settings.json
+++ b/dsc/dsc.settings.json
@@ -8,5 +8,8 @@
"level": "WARN",
"format": "Default",
"allowOverride": true
+ },
+ "lint": {
+ "rules": {}
}
}
diff --git a/dsc/dsc_default.settings.json b/dsc/dsc_default.settings.json
index fe37d458f..aa66a7830 100644
--- a/dsc/dsc_default.settings.json
+++ b/dsc/dsc_default.settings.json
@@ -9,6 +9,9 @@
"level": "WARN",
"format": "Default",
"allowOverride": true
+ },
+ "lint": {
+ "rules": {}
}
}
}
diff --git a/dsc/locales/en-us.toml b/dsc/locales/en-us.toml
index c9b761889..0a0c9640f 100644
--- a/dsc/locales/en-us.toml
+++ b/dsc/locales/en-us.toml
@@ -40,6 +40,13 @@ version = "The version requirement for the resource, like '^1.2' or '>1.2.3, <1.
serverAbout = "Use DSC as a server over JSON-RPC (useful as MCP server)"
bicepAbout = "Use DSC as a Bicep server over gRPC"
ignoreSettingsFile = "Ignore the settings file when running the command"
+lintAbout = "Check a configuration document for problems without applying it"
+lintInput = "The configuration document as JSON or YAML to lint"
+lintFile = "The path to a configuration document to lint; specify multiple times to lint several documents. Use '-' to read from STDIN."
+noDiscovery = "Skip resource discovery and the rules that inspect installed resources"
+failOn = "The lowest diagnostic level that makes the command fail (default: error)"
+rule = "Override the level of a rule, like 'no-unused-parameters=off'; specify multiple times for several rules"
+listRules = "List the lint rules and the level each reports at instead of linting"
[main]
failedToSpawnMain = "Failed to spawn dsc main thread: %{error}"
@@ -162,6 +169,13 @@ invalidFunctionDescriptionFilter = "Invalid function description filter"
maxInt = "maxInt"
invalidManifest = "Error in manifest for"
jsonArrayNotSupported = "JSON array output format is only supported for `--all'"
+lintNoInput = "No configuration document specified; use '--file' or '--input'"
+lintFailed = "Linting failed"
+invalidLintRule = "Invalid lint rule option"
+tableHeader_lintRule = "Rule"
+tableHeader_lintCategory = "Category"
+tableHeader_lintLevel = "Level"
+tableHeader_lintTier = "Tier"
[util]
failedToConvertJsonToString = "Failed to convert JSON to string"
diff --git a/dsc/src/args.rs b/dsc/src/args.rs
index 69e6dba72..096d72c7e 100644
--- a/dsc/src/args.rs
+++ b/dsc/src/args.rs
@@ -5,6 +5,7 @@ use clap::{Parser, Subcommand, ValueEnum};
use clap_complete::Shell;
use dsc_lib::dscresources::command_resource::TraceLevel;
use dsc_lib::functions::FunctionCategory;
+use dsc_lib::lint::{LintLevel, RuleLevelOverride};
use dsc_lib::progress::ProgressFormat;
use dsc_lib::types::{FullyQualifiedTypeName, ResourceVersionReq, TypeNameFilter};
use rust_i18n::t;
@@ -96,6 +97,29 @@ pub enum SubCommand {
#[clap(subcommand)]
subcommand: FunctionSubCommand,
},
+ #[clap(name = "lint", about = t!("args.lintAbout").to_string())]
+ Lint {
+ #[clap(short = 'i', long, help = t!("args.lintInput").to_string(), conflicts_with = "file")]
+ input: Option,
+ #[clap(short = 'f', long, help = t!("args.lintFile").to_string(), conflicts_with = "input")]
+ file: Vec,
+ #[clap(short = 'p', long, help = t!("args.parameters").to_string())]
+ parameters: Option,
+ #[clap(long, help = t!("args.parametersFile").to_string())]
+ parameters_file: Option,
+ #[clap(short = 'r', long, help = t!("args.systemRoot").to_string())]
+ system_root: Option,
+ #[clap(long, help = t!("args.noDiscovery").to_string())]
+ no_discovery: bool,
+ #[clap(long, help = t!("args.failOn").to_string(), value_enum)]
+ fail_on: Option,
+ #[clap(long = "rule", value_name = "RULE=LEVEL", help = t!("args.rule").to_string())]
+ rules: Vec,
+ #[clap(long, help = t!("args.listRules").to_string(), conflicts_with_all = ["input", "file"])]
+ list_rules: bool,
+ #[clap(short = 'o', long, help = t!("args.outputFormat").to_string())]
+ output_format: Option,
+ },
#[clap(name = "server", alias = "mcp", about = t!("args.serverAbout").to_string())]
Server,
#[clap(name = "resource", about = t!("args.resourceAbout").to_string())]
@@ -318,6 +342,8 @@ pub enum SchemaType {
FunctionDefinition,
GetResult,
Include,
+ LintResult,
+ LintRules,
ManifestList,
ResolveResult,
Resource,
diff --git a/dsc/src/main.rs b/dsc/src/main.rs
index b2c83c020..ef65bef25 100644
--- a/dsc/src/main.rs
+++ b/dsc/src/main.rs
@@ -98,33 +98,24 @@ fn dsc_main() -> Result<(), ExitCode> {
generate(shell, &mut cmd, "dsc", &mut io::stdout());
},
SubCommand::Config { subcommand, parameters, parameters_file, system_root, as_group, as_assert, as_include } => {
- let Ok(params) = get_input(None, parameters_file.as_ref()) else {
- return Err(ExitCode::from(util::EXIT_INVALID_INPUT));
- };
- let file_params = if params.is_empty() {
- None
- } else {
- Some(params)
- };
-
- let merged_parameters = match (file_params, parameters) {
- (Some(file_content), Some(inline_content)) => {
- info!("{}", t!("main.mergingParameters"));
- match util::merge_parameters(&file_content, &inline_content) {
- Ok(merged) => Some(merged),
- Err(err) => {
- error!("{}: {err}", t!("main.failedMergingParameters"));
- return Err(ExitCode::from(util::EXIT_INVALID_INPUT)) ;
- }
- }
- },
- (Some(file_content), None) => Some(file_content),
- (None, Some(inline_content)) => Some(inline_content),
- (None, None) => None,
- };
-
+ let merged_parameters = merge_parameter_inputs(parameters, parameters_file.as_ref())?;
subcommand::config(&subcommand, &merged_parameters, system_root.as_ref(), &as_group, &as_assert, &as_include, progress_format)?;
},
+ SubCommand::Lint { input, file, parameters, parameters_file, system_root, no_discovery, fail_on, rules, list_rules, output_format } => {
+ let merged_parameters = merge_parameter_inputs(parameters, parameters_file.as_ref())?;
+ let args = subcommand::LintArgs {
+ input: input.as_ref(),
+ files: &file,
+ parameters: merged_parameters.as_ref(),
+ system_root: system_root.as_ref(),
+ no_discovery,
+ fail_on,
+ rules: &rules,
+ list_rules,
+ output_format: output_format.as_ref(),
+ };
+ subcommand::lint(&args, progress_format)?;
+ },
SubCommand::Extension { subcommand } => {
subcommand::extension(&subcommand, progress_format)?;
},
@@ -157,6 +148,48 @@ fn dsc_main() -> Result<(), ExitCode> {
Ok(())
}
+/// Combine the inline parameters with the parameters file, if either was given.
+///
+/// # Arguments
+///
+/// * `parameters` - The inline parameters, as JSON or YAML.
+/// * `parameters_file` - The path to a parameters file, or `-` to read it from STDIN.
+///
+/// # Returns
+///
+/// The merged parameters as JSON, or `None` when neither was given. Inline values take precedence
+/// over values from the file.
+///
+/// # Errors
+///
+/// Returns `EXIT_INVALID_INPUT` when the file can't be read or the two inputs can't be merged.
+fn merge_parameter_inputs(parameters: Option, parameters_file: Option<&String>) -> Result, ExitCode> {
+ let Ok(file_params) = get_input(None, parameters_file) else {
+ return Err(ExitCode::from(util::EXIT_INVALID_INPUT));
+ };
+ let file_params = if file_params.is_empty() {
+ None
+ } else {
+ Some(file_params)
+ };
+
+ match (file_params, parameters) {
+ (Some(file_content), Some(inline_content)) => {
+ info!("{}", t!("main.mergingParameters"));
+ match util::merge_parameters(&file_content, &inline_content) {
+ Ok(merged) => Ok(Some(merged)),
+ Err(err) => {
+ error!("{}: {err}", t!("main.failedMergingParameters"));
+ Err(ExitCode::from(util::EXIT_INVALID_INPUT))
+ }
+ }
+ },
+ (Some(file_content), None) => Ok(Some(file_content)),
+ (None, Some(inline_content)) => Ok(Some(inline_content)),
+ (None, None) => Ok(None),
+ }
+}
+
fn ctrlc_handler() {
use std::process::exit;
warn!("{}", t!("main.ctrlCReceived"));
diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs
index e776e5c2f..92cb1bd7b 100644
--- a/dsc/src/subcommand.rs
+++ b/dsc/src/subcommand.rs
@@ -5,7 +5,8 @@ use crate::args::{ConfigSubCommand, SchemaType, ExtensionSubCommand, FunctionSub
use crate::resolve::{get_contents, Include};
use crate::resource_command::{get_resource, self};
use crate::tablewriter::Table;
-use crate::util::{current_dsc_version, get_input, get_schema, in_desired_state, set_dscconfigroot, write_object, DSC_CONFIG_ROOT, EXIT_DSC_ASSERTION_FAILED, EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_INVALID_INPUT, EXIT_JSON_ERROR};
+use crate::util::{current_dsc_version, get_input, get_schema, in_desired_state, read_input, set_dscconfigroot, write_object, DSC_CONFIG_ROOT, EXIT_DSC_ASSERTION_FAILED, EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_INVALID_INPUT, EXIT_JSON_ERROR, EXIT_VALIDATION_FAILED};
+use dsc_lib::lint::{LintError, LintLevel, LintOptions, LintResult, Linter, RuleLevelOverride};
use dsc_lib::types::{FullyQualifiedTypeName, ResourceVersionReq, TypeNameFilter};
use dsc_lib::{
configure::{
@@ -38,7 +39,7 @@ use std::process::ExitCode;
use std::{
collections::HashMap,
io::{self, IsTerminal},
- path::Path
+ path::{Path, PathBuf}
};
use tracing::{debug, error, trace};
@@ -333,26 +334,7 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, mounte
},
Some(parameters) => {
debug!("{}", t!("subcommand.parameters"));
- match serde_json::from_str(parameters) {
- Ok(json) => Some(json),
- Err(_) => {
- match serde_yaml::from_str::(parameters) {
- Ok(yaml) => {
- match serde_json::to_value(yaml) {
- Ok(json) => Some(json),
- Err(err) => {
- error!("{}: {err}", t!("subcommand.failedConvertJson"));
- return Err(ExitCode::from(EXIT_DSC_ERROR));
- }
- }
- },
- Err(err) => {
- error!("{}: {err}", t!("subcommand.invalidParameters"));
- return Err(ExitCode::from(EXIT_INVALID_INPUT));
- }
- }
- }
- }
+ Some(parse_parameters(parameters)?)
}
};
@@ -459,6 +441,194 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, mounte
Ok(())
}
+/// Parse parameter input as JSON, falling back to YAML.
+///
+/// # Arguments
+///
+/// * `parameters` - The parameters as JSON or YAML text.
+///
+/// # Errors
+///
+/// Returns `EXIT_INVALID_INPUT` when the text is neither JSON nor YAML, or `EXIT_DSC_ERROR` when
+/// the YAML can't be converted to JSON.
+fn parse_parameters(parameters: &str) -> Result {
+ match serde_json::from_str(parameters) {
+ Ok(json) => Ok(json),
+ Err(_) => {
+ match serde_yaml::from_str::(parameters) {
+ Ok(yaml) => {
+ match serde_json::to_value(yaml) {
+ Ok(json) => Ok(json),
+ Err(err) => {
+ error!("{}: {err}", t!("subcommand.failedConvertJson"));
+ Err(ExitCode::from(EXIT_DSC_ERROR))
+ }
+ }
+ },
+ Err(err) => {
+ error!("{}: {err}", t!("subcommand.invalidParameters"));
+ Err(ExitCode::from(EXIT_INVALID_INPUT))
+ }
+ }
+ }
+ }
+}
+
+/// The arguments of the `dsc lint` command.
+pub struct LintArgs<'a> {
+ /// The inline document, if given.
+ pub input: Option<&'a String>,
+ /// The documents to read from files; `-` reads STDIN.
+ pub files: &'a [String],
+ /// The merged parameters, if any were given.
+ pub parameters: Option<&'a String>,
+ /// The system root the configuration targets, if not the running OS.
+ pub system_root: Option<&'a String>,
+ /// Whether to skip resource discovery and the rules that need it.
+ pub no_discovery: bool,
+ /// The lowest diagnostic level that makes the command fail; `error` when not given.
+ pub fail_on: Option,
+ /// Rule level overrides for this run.
+ pub rules: &'a [RuleLevelOverride],
+ /// Whether to list the rules instead of linting.
+ pub list_rules: bool,
+ /// The output format.
+ pub output_format: Option<&'a OutputFormat>,
+}
+
+/// Lint one or more configuration documents.
+///
+/// # Arguments
+///
+/// * `args` - The command arguments.
+/// * `progress_format` - How to report progress during resource discovery.
+///
+/// # Errors
+///
+/// Returns `EXIT_INVALID_ARGS` for an unknown rule or missing input, `EXIT_INVALID_INPUT` when a
+/// document or the parameters can't be read, `EXIT_VALIDATION_FAILED` when a diagnostic at or
+/// above the `--fail-on` level was reported, `EXIT_JSON_ERROR` when the result can't be
+/// serialized, and `EXIT_DSC_ERROR` for other failures.
+pub fn lint(args: &LintArgs, progress_format: ProgressFormat) -> Result<(), ExitCode> {
+ let parameters = match args.parameters {
+ Some(parameters) => Some(parse_parameters(parameters)?),
+ None => None,
+ };
+ let options = LintOptions {
+ discovery: !args.no_discovery,
+ rule_levels: args.rules.to_vec(),
+ parameters,
+ system_root: args.system_root.map(PathBuf::from),
+ dsc_version: Some(current_dsc_version()),
+ progress_format,
+ ..LintOptions::default()
+ };
+ let mut linter = match Linter::new(options) {
+ Ok(linter) => linter,
+ Err(DscError::Lint(err @ (LintError::UnknownRule(_) | LintError::InvalidRuleOverride(_) | LintError::InvalidRuleLevel(_)))) => {
+ error!("{}: {err}", t!("subcommand.invalidLintRule"));
+ return Err(ExitCode::from(EXIT_INVALID_ARGS));
+ },
+ Err(err) => {
+ error!("{}: {err}", t!("subcommand.lintFailed"));
+ return Err(ExitCode::from(EXIT_DSC_ERROR));
+ },
+ };
+
+ if args.list_rules {
+ return list_lint_rules(&linter, args.output_format);
+ }
+
+ let mut documents: Vec<(Option, String)> = Vec::new();
+ if let Some(input) = args.input {
+ documents.push((None, read_input(Some(input), None)?));
+ }
+ for file in args.files {
+ let document = read_input(None, Some(file))?;
+ let name = if file == "-" { None } else { Some(file.clone()) };
+ documents.push((name, document));
+ }
+ if documents.is_empty() {
+ error!("{}", t!("subcommand.lintNoInput"));
+ return Err(ExitCode::from(EXIT_INVALID_ARGS));
+ }
+
+ let mut result = LintResult::from_diagnostics(Vec::new(), true);
+ for (file, document) in documents {
+ linter.set_file(file);
+ let document_result = match linter.lint(&document) {
+ Ok(document_result) => document_result,
+ Err(DscError::Lint(err @ LintError::InvalidParameters(_))) => {
+ error!("{}: {err}", t!("subcommand.lintFailed"));
+ return Err(ExitCode::from(EXIT_INVALID_INPUT));
+ },
+ Err(err) => {
+ error!("{}: {err}", t!("subcommand.lintFailed"));
+ return Err(ExitCode::from(EXIT_DSC_ERROR));
+ },
+ };
+ result.merge(document_result);
+ }
+
+ let json = match serde_json::to_string(&result) {
+ Ok(json) => json,
+ Err(err) => {
+ error!("JSON: {err}");
+ return Err(ExitCode::from(EXIT_JSON_ERROR));
+ }
+ };
+ write_object(&json, args.output_format, false)?;
+
+ let threshold = args.fail_on.unwrap_or(LintLevel::Error);
+ if result.has_diagnostic_at_least(threshold) {
+ return Err(ExitCode::from(EXIT_VALIDATION_FAILED));
+ }
+ Ok(())
+}
+
+/// Print the lint rules with the level each reports at in this run.
+///
+/// # Arguments
+///
+/// * `linter` - The linter whose rule levels to show.
+/// * `format` - The output format; a table when not given and stdout is a terminal.
+///
+/// # Errors
+///
+/// Returns `EXIT_JSON_ERROR` when the rules can't be serialized.
+fn list_lint_rules(linter: &Linter, format: Option<&OutputFormat>) -> Result<(), ExitCode> {
+ let rules = linter.rules();
+ if format.is_none() && io::stdout().is_terminal() {
+ let mut table = Table::new(&[
+ t!("subcommand.tableHeader_lintRule").to_string().as_ref(),
+ t!("subcommand.tableHeader_lintCategory").to_string().as_ref(),
+ t!("subcommand.tableHeader_lintLevel").to_string().as_ref(),
+ t!("subcommand.tableHeader_lintTier").to_string().as_ref(),
+ t!("subcommand.tableHeader_description").to_string().as_ref(),
+ ]);
+ for rule in rules {
+ table.add_row(vec![
+ rule.id,
+ rule.category.to_string(),
+ rule.level.to_string(),
+ rule.tier.to_string(),
+ rule.description,
+ ]);
+ }
+ table.print(true);
+ return Ok(());
+ }
+
+ let json = match serde_json::to_string(&rules) {
+ Ok(json) => json,
+ Err(err) => {
+ error!("JSON: {err}");
+ return Err(ExitCode::from(EXIT_JSON_ERROR));
+ }
+ };
+ write_object(&json, format, false)
+}
+
/// Validate configuration.
///
/// # Arguments
diff --git a/dsc/src/util.rs b/dsc/src/util.rs
index 7323cce51..c10b101a3 100644
--- a/dsc/src/util.rs
+++ b/dsc/src/util.rs
@@ -41,6 +41,7 @@ use dsc_lib::{
extension_manifest::ExtensionManifest,
},
functions::FunctionDefinition,
+ lint::{LintResult, LintRuleInfo},
schemas::dsc_repo::{DscRepoSchema, RecognizedSchemaVersion, SchemaForm, SchemaUriPrefix},
types::SemanticVersion,
util::{
@@ -227,6 +228,12 @@ pub fn get_schema(schema: SchemaType) -> Schema {
SchemaType::Include => {
schema_for!(Include)
},
+ SchemaType::LintResult => {
+ repo_schema::()
+ },
+ SchemaType::LintRules => {
+ repo_schema::()
+ },
SchemaType::ManifestList => {
schema_for!(ManifestList)
},
@@ -455,7 +462,53 @@ pub fn enable_tracing(trace_level_arg: Option<&TraceLevel>, trace_format_arg: Op
info!("Trace-level is {:?}", tracing_setting.level);
}
+/// Read an input document and convert it to a JSON string.
+///
+/// # Arguments
+///
+/// * `input` - The inline document, if provided.
+/// * `file` - The path to the document, or `-` to read it from STDIN.
+///
+/// # Returns
+///
+/// The document as JSON, or an empty string when neither argument is provided.
+///
+/// # Errors
+///
+/// Returns `EXIT_INVALID_INPUT` when the document can't be read (see [`read_input`]) or isn't
+/// valid JSON or YAML.
pub fn get_input(input: Option<&String>, file: Option<&String>) -> Result {
+ let value = read_input(input, file)?;
+ if value.is_empty() {
+ return Ok(value);
+ }
+
+ match parse_input_to_json(&value) {
+ Ok(json) => Ok(json),
+ Err(err) => {
+ error!("{}: {err}", t!("util.failedToParseInput"));
+ Err(ExitCode::from(EXIT_INVALID_INPUT))
+ }
+ }
+}
+
+/// Read an input document as text without parsing it.
+///
+/// # Arguments
+///
+/// * `input` - The inline document, if provided.
+/// * `file` - The path to the document, or `-` to read it from STDIN. Extensions with the
+/// `import` capability get the first chance to handle a file path.
+///
+/// # Returns
+///
+/// The document text, or an empty string when neither argument is provided.
+///
+/// # Errors
+///
+/// Returns `EXIT_INVALID_INPUT` when the inline input is actually a path to an existing file, the
+/// file can't be read, STDIN isn't valid UTF-8, or the document is empty.
+pub fn read_input(input: Option<&String>, file: Option<&String>) -> Result {
trace!("Input: {input:?}, File: {file:?}");
let value = if let Some(input) = input {
debug!("{}", t!("util.readingInput"));
@@ -524,13 +577,7 @@ pub fn get_input(input: Option<&String>, file: Option<&String>) -> Result Ok(json),
- Err(err) => {
- error!("{}: {err}", t!("util.failedToParseInput"));
- Err(ExitCode::from(EXIT_INVALID_INPUT))
- }
- }
+ Ok(value)
}
/// Sets `DSC_CONFIG_ROOT` env var and makes path absolute.
diff --git a/dsc/tests/dsc_lint.tests.ps1 b/dsc/tests/dsc_lint.tests.ps1
new file mode 100644
index 000000000..9f1c3989c
--- /dev/null
+++ b/dsc/tests/dsc_lint.tests.ps1
@@ -0,0 +1,301 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+Describe 'dsc lint tests' {
+ BeforeAll {
+ $schemaUri = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json'
+ $cleanConfig = @"
+`$schema: $schemaUri
+parameters:
+ greeting:
+ type: string
+ defaultValue: hello
+resources:
+- name: Echo
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "[parameters('greeting')]"
+"@
+ # The document from https://github.com/PowerShell/DSC/issues/842
+ $duplicateConfig = @"
+`$schema: $schemaUri
+resources:
+- name: EnsureTest1Value
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: one
+- name: EnsureTest2Value
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: two
+- name: EnsureTest1Value
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: three
+"@
+ $unusedParameterConfig = @"
+`$schema: $schemaUri
+parameters:
+ unused:
+ type: string
+ defaultValue: x
+resources:
+- name: Echo
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: hello
+"@
+ }
+
+ It 'reports no diagnostics for a clean document' {
+ $result = $cleanConfig | dsc lint --file - 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw)
+ $result.valid | Should -BeTrue
+ $result.discovery | Should -BeTrue
+ $result.diagnostics.Count | Should -Be 0
+ $result.summary.errors | Should -Be 0
+ $result.summary.warnings | Should -Be 0
+ }
+
+ It 'reports a duplicate resource and exits with code 5' {
+ $result = $duplicateConfig | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw)
+ $result.valid | Should -BeFalse
+ $result.discovery | Should -BeFalse
+ $result.summary.errors | Should -Be 1
+ $result.diagnostics.Count | Should -Be 1
+ $result.diagnostics[0].rule | Should -BeExactly 'duplicate-resource'
+ $result.diagnostics[0].level | Should -BeExactly 'error'
+ $result.diagnostics[0].category | Should -BeExactly 'document'
+ $result.diagnostics[0].path | Should -BeExactly '/resources/2'
+ $result.diagnostics[0].message | Should -BeLike "*EnsureTest1Value*"
+ }
+
+ It 'accepts the document with --input' {
+ $result = dsc lint --input $duplicateConfig --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw)
+ $result.diagnostics[0].rule | Should -BeExactly 'duplicate-resource'
+ }
+
+ It 'treats warnings as non-fatal unless --fail-on warning is set' {
+ $result = $unusedParameterConfig | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw)
+ $result.valid | Should -BeTrue
+ $result.summary.warnings | Should -Be 1
+ $result.diagnostics[0].rule | Should -BeExactly 'no-unused-parameters'
+ $result.diagnostics[0].level | Should -BeExactly 'warning'
+ $result.diagnostics[0].path | Should -BeExactly '/parameters/unused'
+
+ $null = $unusedParameterConfig | dsc lint --file - --no-discovery --fail-on warning 2>$TestDrive/error.log
+ $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw)
+ }
+
+ It 'can change the level of a rule with --rule' {
+ $result = $unusedParameterConfig | dsc lint --file - --no-discovery --rule no-unused-parameters=off 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw)
+ $result.diagnostics.Count | Should -Be 0
+
+ $result = $unusedParameterConfig | dsc lint --file - --no-discovery --rule no-unused-parameters=error 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw)
+ $result.valid | Should -BeFalse
+ $result.diagnostics[0].level | Should -BeExactly 'error'
+ }
+
+ It 'rejects an unknown rule with exit code 1' {
+ $null = $cleanConfig | dsc lint --file - --no-discovery --rule no-such-rule=off 2>$TestDrive/error.log
+ $LASTEXITCODE | Should -Be 1
+ (Get-Content $TestDrive/error.log -Raw) | Should -BeLike '*no-such-rule*'
+ }
+
+ It 'reports a document that is not valid YAML as a diagnostic' {
+ $result = "resources:`n- name: a`n type: [unclosed" | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw)
+ $result.valid | Should -BeFalse
+ $result.diagnostics.Count | Should -Be 1
+ $result.diagnostics[0].rule | Should -BeExactly 'parse-error'
+ $result.diagnostics[0].path | Should -BeExactly ''
+ $result.diagnostics[0].location.line | Should -BeGreaterThan 0
+ }
+
+ It 'reports schema violations with the path of the problem' {
+ $config = @"
+`$schema: $schemaUri
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ unknownKey: true
+"@
+ $result = $config | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw)
+ $violations = @($result.diagnostics | Where-Object rule -eq 'schema-invalid')
+ $violations.Count | Should -BeGreaterThan 0
+ $violations.path | Should -Contain '/resources/0'
+ }
+
+ It 'reports at ' -TestCases @(
+ @{ rule = 'undefined-parameter'; path = '/resources/0/properties/output'; property = "[parameters('missing')]" }
+ @{ rule = 'undefined-variable'; path = '/resources/0/properties/output'; property = "[variables('missing')]" }
+ @{ rule = 'unknown-function'; path = '/resources/0/properties/output'; property = "[noSuchFunction()]" }
+ @{ rule = 'invalid-argument-count'; path = '/resources/0/properties/output'; property = "[int('1', '2')]" }
+ @{ rule = 'invalid-argument-type'; path = '/resources/0/properties/output'; property = "[int(true)]" }
+ @{ rule = 'invalid-expression'; path = '/resources/0/properties/output'; property = "[concat('unterminated)]" }
+ @{ rule = 'unresolved-dependency'; path = '/resources/0/properties/output'; property = "[reference(resourceId('Microsoft.DSC.Debug/Echo', 'nobody'))]" }
+ ) {
+ param($rule, $path, $property)
+ $config = @"
+`$schema: $schemaUri
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "$property"
+"@
+ $result = $config | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw)
+ $diagnostic = $result.diagnostics | Where-Object rule -eq $rule
+ $diagnostic | Should -Not -BeNullOrEmpty -Because ($result.diagnostics | ConvertTo-Json -Depth 5)
+ $diagnostic.path | Should -BeExactly $path
+ $diagnostic.level | Should -BeExactly 'error'
+ }
+
+ It 'reports circular dependencies' {
+ $config = @"
+`$schema: $schemaUri
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ dependsOn:
+ - "[resourceId('Microsoft.DSC.Debug/Echo', 'b')]"
+- name: b
+ type: Microsoft.DSC.Debug/Echo
+ dependsOn:
+ - "[resourceId('Microsoft.DSC.Debug/Echo', 'a')]"
+"@
+ $result = $config | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw)
+ $diagnostic = $result.diagnostics | Where-Object rule -eq 'circular-dependency'
+ $diagnostic.path | Should -BeExactly '/resources/0/dependsOn'
+ $diagnostic.message | Should -BeLike '*a -> b -> a*'
+ }
+
+ It 'warns when --system-root is given but systemRoot() is not used' {
+ $result = $cleanConfig | dsc lint --file - --no-discovery --system-root $TestDrive 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw)
+ $diagnostic = $result.diagnostics | Where-Object rule -eq 'system-root-unused'
+ $diagnostic | Should -Not -BeNullOrEmpty
+ $diagnostic.level | Should -BeExactly 'warning'
+ $diagnostic.path | Should -BeExactly ''
+
+ $config = @"
+`$schema: $schemaUri
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "[systemRoot()]"
+"@
+ $result = $config | dsc lint --file - --no-discovery --system-root $TestDrive 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw)
+ $result.diagnostics | Where-Object rule -eq 'system-root-unused' | Should -BeNullOrEmpty
+ }
+
+ It 'checks supplied parameter values' {
+ $config = @"
+`$schema: $schemaUri
+parameters:
+ count:
+ type: int
+ required:
+ type: string
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "[concat(string(parameters('count')), parameters('required'))]"
+"@
+ $result = $config | dsc lint --file - --no-discovery --parameters '{"parameters": {"count": "five", "extra": 1}}' 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw)
+ $result.diagnostics.rule | Should -Contain 'parameter-value-type'
+ $result.diagnostics.rule | Should -Contain 'missing-parameter-value'
+ $result.diagnostics.rule | Should -Contain 'undeclared-parameter-value'
+ }
+
+ It 'reports unknown resource types only when discovery runs' {
+ $config = @"
+`$schema: $schemaUri
+resources:
+- name: a
+ type: Not.Installed/Anywhere
+"@
+ $result = $config | dsc lint --file - 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw)
+ $result.discovery | Should -BeTrue
+ $diagnostic = $result.diagnostics | Where-Object rule -eq 'unknown-resource-type'
+ $diagnostic.level | Should -BeExactly 'warning'
+ $diagnostic.path | Should -BeExactly '/resources/0/type'
+
+ $result = $config | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw)
+ $result.discovery | Should -BeFalse
+ $result.diagnostics | Where-Object rule -eq 'unknown-resource-type' | Should -BeNullOrEmpty
+ }
+
+ It 'lints several files in one run and records the file on each diagnostic' {
+ Set-Content -Path "$TestDrive/clean.dsc.yaml" -Value $cleanConfig
+ Set-Content -Path "$TestDrive/duplicate.dsc.yaml" -Value $duplicateConfig
+ $result = dsc lint --file "$TestDrive/clean.dsc.yaml" --file "$TestDrive/duplicate.dsc.yaml" --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw)
+ $result.diagnostics.Count | Should -Be 1
+ $result.diagnostics[0].file | Should -BeLike '*duplicate.dsc.yaml'
+ }
+
+ It 'rejects a resource manifest' {
+ $manifest = @"
+`$schema: https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json
+type: Test/Example
+version: 1.0.0
+"@
+ $result = $manifest | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 5 -Because (Get-Content $TestDrive/error.log -Raw)
+ $result.diagnostics.Count | Should -Be 1
+ $result.diagnostics[0].rule | Should -BeExactly 'unsupported-document-type'
+ }
+
+ It 'lists the rules with their levels' {
+ $rules = dsc lint --list-rules --output-format json 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw)
+ $rules.id | Should -Contain 'duplicate-resource'
+ $rules.id | Should -Contain 'parse-error'
+ ($rules | Where-Object id -eq 'parse-error').fixed | Should -BeTrue
+ $duplicate = $rules | Where-Object id -eq 'duplicate-resource'
+ $duplicate.defaultLevel | Should -BeExactly 'error'
+ $duplicate.level | Should -BeExactly 'error'
+ $duplicate.tier | Should -BeExactly 'static'
+ ($rules | Where-Object id -eq 'unknown-resource-type').tier | Should -BeExactly 'resource'
+
+ $rules = dsc lint --list-rules --rule duplicate-resource=warning --output-format json 2>$TestDrive/error.log | ConvertFrom-Json
+ ($rules | Where-Object id -eq 'duplicate-resource').level | Should -BeExactly 'warning'
+ }
+
+ It 'exposes the output schemas' {
+ $schema = dsc schema --type lint-result 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw)
+ $schema.properties.diagnostics | Should -Not -BeNullOrEmpty
+ $schema.properties.summary | Should -Not -BeNullOrEmpty
+
+ $schema = dsc schema --type lint-rules 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw)
+ $schema.properties.defaultLevel | Should -Not -BeNullOrEmpty
+ }
+
+ It 'lints the shipped example without errors: ' -TestCases (
+ Get-ChildItem (Join-Path $PSScriptRoot '../examples') -Filter '*.dsc.*' |
+ Where-Object { $_.Extension -in @('.yaml', '.json') -and $_.Name -ne 'invalid_schema.dsc.yaml' } |
+ ForEach-Object { @{ name = $_.Name; path = $_.FullName } }
+ ) {
+ param($name, $path)
+ $result = dsc lint --file $path --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 0 -Because ($result.diagnostics | ConvertTo-Json -Depth 5)
+ $result.summary.errors | Should -Be 0
+ }
+}
diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml
index a376a7db7..eb0da3faa 100644
--- a/lib/dsc-lib/locales/en-us.toml
+++ b/lib/dsc-lib/locales/en-us.toml
@@ -1048,3 +1048,134 @@ invalidRegex = "wildcard type name '%{name}' could not be converted into a valid
invalidTypeName = "invalid wildcard type name '%{text}': %{err}"
missingNameSegment = "missing name segment when no wildcard in the pattern can match a name segment"
noWildcard = "wildcard type name '%{text}' doesn't contain any wildcard characters (`*`)"
+
+[lint.mod]
+unknownRule = "Unknown lint rule"
+invalidRuleOverride = "Invalid rule level override, expected '='"
+invalidRuleLevel = "Invalid rule level, expected 'off', 'info', 'warning', or 'error'"
+invalidSettings = "The 'lint' setting is invalid"
+invalidParameters = "The supplied parameters are invalid"
+fixedRuleOverrideIgnored = "The level of lint rule '%{rule}' is fixed and can't be changed; the override was ignored"
+unknownRuleInSettings = "The settings file configures unknown lint rule '%{rule}'; the entry was ignored"
+parseError = "The document isn't valid JSON or YAML: %{error}"
+parseErrorDescription = "The document must be valid JSON or YAML."
+schemaInvalidDescription = "The document must validate against the configuration document JSON schema."
+documentInvalid = "The document couldn't be loaded as a configuration: %{error}"
+unsupportedDocumentType = "Only configuration documents can be linted; the '$schema' identifies a %{kind}"
+unsupportedDocumentTypeDescription = "The document must be a configuration document; manifests aren't supported yet."
+discoveryFailed = "Resource discovery failed, so the rules that inspect installed resources were skipped: %{error}"
+discoveryFailedDescription = "Resource discovery must succeed for the rules that inspect installed resources to run."
+
+[lint.options]
+documentKindConfiguration = "configuration document"
+documentKindResourceManifest = "resource manifest"
+documentKindExtensionManifest = "extension manifest"
+
+[lint.rules.document]
+duplicateResourceDescription = "Resource names must be unique among resources of the same type at the same level."
+duplicateResource = "Resource '%{name}' of type '%{type_name}' is declared more than once"
+duplicateResourceNameDescription = "Resources at the same level should have distinct names even when their types differ, because dependencies are resolved by name first."
+duplicateResourceName = "Resource name '%{name}' is used by both '%{first_type}' and '%{second_type}'; dependencies are resolved by name first, so use distinct names"
+emptyResourcesDescription = "The configuration should declare at least one resource."
+emptyResources = "The configuration doesn't declare any resources"
+unknownMicrosoftDscMetadataDescription = "Keys under 'Microsoft.DSC' metadata must be ones DSC recognizes; unknown keys are silently ignored."
+unknownMicrosoftDscMetadata = "Unknown key '%{key}' under 'Microsoft.DSC' metadata is ignored"
+apiVersionAliasDescription = "Resources should use 'requireVersion' rather than its alias 'apiVersion'."
+apiVersionAlias = "'apiVersion' is an alias of 'requireVersion'; use 'requireVersion'"
+dateVersionDescription = "Version requirements should be semantic; date-based versions are deprecated."
+dateVersion = "Date-based version requirement '%{version}' is deprecated; use a semantic version requirement"
+missingRequireVersionDescription = "Resources should pin the version they require with 'requireVersion'."
+missingRequireVersion = "Resource '%{name}' doesn't pin the version of '%{type_name}' with 'requireVersion'"
+
+[lint.rules.expression]
+invalidExpressionDescription = "Strings that start with '[' must be valid expressions; escape a literal bracket with '[['."
+invalidExpression = "Expression '%{expression}' failed to parse: %{error}"
+unknownFunctionDescription = "Expressions may only call built-in functions or user-defined functions declared in 'functions'."
+unknownFunction = "Unknown function '%{name}'"
+unknownFunctionSuggestion = "Unknown function '%{name}'; function names are case-sensitive, did you mean '%{suggestion}'?"
+unknownUserFunction = "Unknown user-defined function '%{name}'; it isn't declared in 'functions'"
+invalidArgumentCountDescription = "Function calls must pass the number of arguments the function accepts."
+invalidArgumentTypeDescription = "Literal arguments must be of a kind the function accepts at that position."
+undefinedParameterDescription = "parameters() must reference a parameter declared in 'parameters'."
+undefinedParameter = "Parameter '%{name}' isn't declared in 'parameters'"
+undefinedVariableDescription = "variables() must reference a variable declared in 'variables'."
+undefinedVariable = "Variable '%{name}' isn't declared in 'variables'"
+conditionNotExpressionDescription = "A 'condition' must be an expression that evaluates to true; a plain string never does."
+conditionNotExpression = "Condition '%{condition}' is a plain string, so it never evaluates to true and the item is always skipped"
+
+[lint.rules.parameter]
+noUnusedParametersDescription = "Declared parameters should be referenced by at least one expression."
+noUnusedParameters = "Parameter '%{name}' is declared but never used"
+noUnusedVariablesDescription = "Declared variables should be referenced by at least one expression."
+noUnusedVariables = "Variable '%{name}' is declared but never used"
+parameterDefaultTypeDescription = "A literal default value must satisfy the parameter's type and constraints."
+parameterDefaultType = "The default value of parameter '%{name}' is invalid: %{reason}"
+valueNotOfType = "the value isn't of type '%{type_name}'"
+invalidParameterConstraintsDescription = "Parameter constraints must be consistent with each other and with the parameter type."
+minValueExceedsMaxValue = "Parameter '%{name}' has a minValue of %{min} that is greater than its maxValue of %{max}"
+minLengthExceedsMaxLength = "Parameter '%{name}' has a minLength of %{min} that is greater than its maxLength of %{max}"
+valueLimitOnNonInt = "Parameter '%{name}' has '%{constraint}' but its type is '%{type_name}'; value limits only apply to 'int' parameters"
+lengthLimitOnOtherType = "Parameter '%{name}' has '%{constraint}' but its type is '%{type_name}'; length limits only apply to 'string', 'secureString', and 'array' parameters"
+negativeLength = "Parameter '%{name}' has a negative '%{constraint}'"
+emptyAllowedValues = "Parameter '%{name}' has an empty 'allowedValues' list, so no value is allowed"
+allowedValueTypeMismatch = "Parameter '%{name}' allows a value that isn't of type '%{type_name}'"
+secureParameterDefaultDescription = "Secure parameters shouldn't have a hard-coded default value, because anyone with the document can read it."
+secureParameterDefault = "Secure parameter '%{name}' has a hard-coded default value"
+preferSecureTypeDescription = "Parameters whose names suggest a secret should use the 'secureString' or 'secureObject' type."
+preferSecureType = "Parameter '%{name}' looks like a secret; use type '%{secure_type}' instead of '%{type_name}'"
+secureValueInOutputDescription = "Outputs shouldn't expose secure values; DSC skips such outputs at runtime."
+secureValueInOutput = "Output '%{name}' exposes the secure value from %{source}, so DSC skips it at runtime"
+useParameterDescriptionDescription = "Parameters should have a description."
+useParameterDescription = "Parameter '%{name}' has no description"
+undeclaredParameterValueDescription = "Supplied parameter values must match a declared parameter."
+undeclaredParameterValue = "A value was supplied for parameter '%{name}', which isn't declared in 'parameters'"
+missingParameterValueDescription = "Parameters without a default value must be supplied a value."
+missingParameterValue = "Parameter '%{name}' has no default value and no value was supplied"
+parameterValueTypeDescription = "Supplied parameter values must satisfy the parameter's type and constraints."
+parameterValueType = "The supplied value for parameter '%{name}' is invalid: %{reason}"
+
+[lint.rules.dependency]
+unresolvedDependencyDescription = "dependsOn entries and reference() calls must point at a resource declared at the same level."
+unresolvedDependency = "Resource '%{name}' depends on '%{dependency_type}' named '%{dependency_name}', which isn't declared"
+dependencyTypeMismatch = "Resource '%{name}' depends on '%{dependency_type}' named '%{dependency_name}', but that resource has type '%{actual_type}'"
+circularDependencyDescription = "Resource dependencies must not form a cycle."
+circularDependency = "Resource '%{name}' is part of a dependency cycle: %{cycle}"
+referenceWithoutDependsOnDescription = "A resource that references another resource must list it in 'dependsOn' so the referenced resource runs first."
+referenceWithoutDependsOn = "Resource '%{name}' references '%{target}' but doesn't list it in 'dependsOn'; the reference fails unless that resource has already run"
+duplicateDependsOnDescription = "A dependency should be listed once."
+duplicateDependsOn = "Resource '%{name}' lists the dependency '%{target}' more than once"
+
+[lint.rules.deprecation]
+copyLoopDeprecatedDescription = "Copy loops are deprecated and will be removed in a future release."
+copyLoopDeprecated = "Copy loop '%{name}' is deprecated and will be removed in a future release; see https://github.com/PowerShell/DSC/issues/1429"
+copyOptionsUnsupportedDescription = "Copy loops don't support 'mode' or 'batchSize'."
+copyOptionsUnsupported = "Copy loop option '%{option}' isn't supported"
+securityContextInMetadataDescription = "The security context should be set with 'directives.securityContext' rather than 'Microsoft.DSC' metadata."
+securityContextInMetadata = "Setting the security context in 'Microsoft.DSC' metadata is deprecated; use 'directives.securityContext'"
+securityContextConflictDescription = "The security context in metadata and in directives must agree."
+securityContextConflict = "The security context '%{metadata}' in metadata conflicts with '%{directive}' in directives"
+dscVersionUnsatisfiedDescription = "The DSC version requirement in 'directives.version' should be satisfied by the version of DSC in use."
+dscVersionUnsatisfied = "The document requires DSC version '%{required}', but this is DSC version '%{current}'"
+
+[lint.rules.environment]
+systemRootUnusedDescription = "When a system root is specified, the document should use systemRoot() so its paths target that root."
+systemRootUnused = "A system root of '%{path}' was specified, but no expression calls systemRoot(), so the document doesn't target it"
+hardcodedSystemPathDescription = "Absolute operating system paths should be built with systemRoot() so the document can target another root."
+hardcodedSystemPath = "'%{value}' is an absolute operating system path; build it with systemRoot() so the document can target another root"
+
+[lint.rules.resource]
+unknownResourceTypeDescription = "Each resource type must be installed."
+unknownResourceType = "Resource type '%{type_name}' isn't installed"
+unsatisfiedRequireVersionDescription = "An installed version of the resource type must satisfy 'requireVersion'."
+unsatisfiedRequireVersion = "No installed version of '%{type_name}' satisfies the requirement '%{version}'"
+deprecatedResourceDescription = "Resources shouldn't use deprecated resource types."
+deprecatedResource = "Resource type '%{type_name}' is deprecated: %{message}"
+propertySchemaMismatchDescription = "Literal property values must validate against the resource's schema."
+propertySchemaMismatch = "Property doesn't validate against the schema of '%{type_name}': %{message}"
+propertySchemaCompileFailed = "The schema of '%{type_name}' couldn't be compiled, so its properties weren't checked: %{error}"
+nestedResourcesUnsupportedDescription = "Only group and adapter resources can declare nested resources."
+nestedResourcesUnsupported = "Resource '%{name}' declares nested resources, but '%{type_name}' is a '%{kind}' resource, not a group or adapter"
+existFalseUnsupportedDescription = "A resource type must support delete or handle _exist itself for '_exist: false' to work."
+existFalseUnsupported = "Resource type '%{type_name}' has neither the 'delete' nor the 'setHandlesExist' capability, so 'dsc config set' fails for '_exist: false'"
+canonicalTypeNameDescription = "Resource types should be written with the casing their manifest uses."
+canonicalTypeName = "Resource type '%{type_name}' is written as '%{canonical}' in its manifest"
diff --git a/lib/dsc-lib/locales/schemas.outputs.yaml b/lib/dsc-lib/locales/schemas.outputs.yaml
new file mode 100644
index 000000000..6220b1efd
--- /dev/null
+++ b/lib/dsc-lib/locales/schemas.outputs.yaml
@@ -0,0 +1,27 @@
+_version: 2
+schemas:
+ outputs:
+ lint:
+ result:
+ title:
+ en-us: dsc lint result
+ description:
+ en-us: >-
+ Describes the output of the `dsc lint` command.
+ markdownDescription:
+ en-us: |-
+ Describes the output of the `dsc lint` command: the diagnostics the lint rules
+ reported for the document, counts of the diagnostics by level, whether the document
+ is free of error-level diagnostics, and whether resource discovery ran.
+ rules:
+ title:
+ en-us: dsc lint rule
+ description:
+ en-us: >-
+ Describes a lint rule and the level it reports at, as listed by `dsc lint --list-rules`.
+ markdownDescription:
+ en-us: |-
+ Describes a lint rule and the level it reports at, as listed by
+ `dsc lint --list-rules`. The `defaultLevel` is the level the rule reports at unless
+ it is overridden; `level` is the level in effect after the settings file and any
+ `--rule` overrides are applied.
diff --git a/lib/dsc-lib/src/configure/mod.rs b/lib/dsc-lib/src/configure/mod.rs
index 3ed7fd523..a612970fc 100644
--- a/lib/dsc-lib/src/configure/mod.rs
+++ b/lib/dsc-lib/src/configure/mod.rs
@@ -331,7 +331,16 @@ fn add_metadata(dsc_resource: &DscResource, mut properties: Option) -> Option {
+/// Get the adapter a resource requires from its directives, if one is specified.
+///
+/// # Arguments
+///
+/// * `resource_directives` - The directives of the resource.
+///
+/// # Returns
+///
+/// The required adapter type name, or `None` when the directives don't require one.
+pub(crate) fn get_require_adapter_from_directive(resource_directives: &Option) -> Option {
if let Some(directives) = resource_directives
&& let Some(require_adapter) = &directives.require_adapter {
return Some(require_adapter.clone());
diff --git a/lib/dsc-lib/src/dscerror.rs b/lib/dsc-lib/src/dscerror.rs
index 70d75140f..9a430b8ff 100644
--- a/lib/dsc-lib/src/dscerror.rs
+++ b/lib/dsc-lib/src/dscerror.rs
@@ -110,6 +110,9 @@ pub enum DscError {
#[error("{t}: {0}", t = t!("dscerror.operation"))]
Operation(String),
+ #[error(transparent)]
+ Lint(#[from] crate::lint::LintError),
+
#[error("{t}: {0}", t = t!("dscerror.parser"))]
Parser(String),
diff --git a/lib/dsc-lib/src/dscresources/dscresource.rs b/lib/dsc-lib/src/dscresources/dscresource.rs
index 735dbf634..4b5f8f677 100644
--- a/lib/dsc-lib/src/dscresources/dscresource.rs
+++ b/lib/dsc-lib/src/dscresources/dscresource.rs
@@ -938,6 +938,53 @@ pub fn validate_json(source: &str, schema: &Value, json: &Value) -> Result<(), D
Ok(())
}
+/// A single violation found when validating JSON against a schema.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SchemaViolation {
+ /// JSON pointer (RFC 6901) to the location in the instance that failed validation.
+ pub instance_path: String,
+ /// JSON pointer to the keyword in the schema that the instance violated.
+ pub schema_path: String,
+ /// The validation error message.
+ pub message: String,
+}
+
+/// Validate the JSON against the schema and return every violation.
+///
+/// Unlike [`validate_json`], which stops at the first error, this function collects all
+/// violations so callers can report each one with its location.
+///
+/// # Arguments
+///
+/// * `source` - The source of the JSON, used for logging
+/// * `schema` - The schema to validate against
+/// * `json` - The JSON to validate
+///
+/// # Returns
+///
+/// Every violation found; an empty list means the JSON is valid.
+///
+/// # Errors
+///
+/// * `DscError::Validation` - The schema failed to compile
+pub fn validate_json_all(source: &str, schema: &Value, json: &Value) -> Result, DscError> {
+ debug!("{}: {source}", t!("dscresources.dscresource.validatingSchema"));
+ trace!("{}", t!("dscresources.dscresource.json", json = json));
+ trace!("{}", t!("dscresources.dscresource.schema", schema = schema));
+ let compiled_schema = match Validator::new(schema) {
+ Ok(compiled_schema) => compiled_schema,
+ Err(err) => {
+ return Err(DscError::Validation(format!("{}: {err}", t!("dscresources.dscresource.failedToCompileSchema"))));
+ }
+ };
+
+ Ok(compiled_schema.iter_errors(json).map(|error| SchemaViolation {
+ instance_path: error.instance_path().to_string(),
+ schema_path: error.schema_path().to_string(),
+ message: error.to_string(),
+ }).collect())
+}
+
/// Compares two arrays independent of order
fn is_same_array(expected: &Vec, actual: &Vec) -> bool {
if expected.len() != actual.len() {
diff --git a/lib/dsc-lib/src/functions/mod.rs b/lib/dsc-lib/src/functions/mod.rs
index 0afa178ea..6a85cbe62 100644
--- a/lib/dsc-lib/src/functions/mod.rs
+++ b/lib/dsc-lib/src/functions/mod.rs
@@ -286,22 +286,7 @@ impl FunctionDispatcher {
let metadata = function.get_metadata();
- // check if arg number are valid
- let min_args = metadata.min_args;
- let max_args = metadata.max_args;
- if args.len() < min_args || args.len() > max_args {
- if max_args == 0 {
- return Err(DscError::Parser(t!("functions.noArgsAccepted", name = name).to_string()));
- }
- else if min_args == max_args {
- return Err(DscError::Parser(t!("functions.invalidArgCount", name = name, count = min_args).to_string()));
- }
- else if max_args == usize::MAX {
- return Err(DscError::Parser(t!("functions.minArgsRequired", name = name, count = min_args).to_string()));
- }
-
- return Err(DscError::Parser(t!("functions.argCountRequired", name = name, min = min_args, max = max_args).to_string()));
- }
+ Self::check_arg_count(name, &metadata, args.len())?;
for (index, value) in args.iter().enumerate() {
if index >= metadata.accepted_arg_ordered_types.len() {
@@ -330,6 +315,98 @@ impl FunctionDispatcher {
}
}
+ /// Get the metadata of a built-in function.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the function; names are case-sensitive.
+ ///
+ /// # Returns
+ ///
+ /// The metadata of the function, or `None` when no built-in function has that name.
+ #[must_use]
+ pub fn get_metadata(&self, name: &str) -> Option {
+ self.functions.get(name).map(|function| function.get_metadata())
+ }
+
+ /// Validate that a built-in function accepts the given number of arguments without invoking it.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the function; names are case-sensitive.
+ /// * `count` - The number of arguments that would be passed.
+ ///
+ /// # Errors
+ ///
+ /// Returns `DscError::Parser` when the function is unknown or the count is outside the range
+ /// the function accepts. The messages are the same ones [`invoke`](Self::invoke) raises.
+ pub fn validate_arg_count(&self, name: &str, count: usize) -> Result<(), DscError> {
+ let Some(metadata) = self.get_metadata(name) else {
+ return Err(DscError::Parser(t!("functions.unknownFunction", name = name).to_string()));
+ };
+ Self::check_arg_count(name, &metadata, count)
+ }
+
+ /// Validate literal arguments against the kinds a built-in function accepts without invoking it.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the function; names are case-sensitive.
+ /// * `args` - One entry per argument in order. `None` marks an argument whose value isn't
+ /// known statically, such as a nested expression; those entries are skipped.
+ ///
+ /// # Errors
+ ///
+ /// Returns `DscError::Parser` when the function is unknown or a literal argument has a kind
+ /// the function doesn't accept at that position. The messages are the same ones
+ /// [`invoke`](Self::invoke) raises.
+ pub fn validate_literal_args(&self, name: &str, args: &[Option<&Value>]) -> Result<(), DscError> {
+ let Some(metadata) = self.get_metadata(name) else {
+ return Err(DscError::Parser(t!("functions.unknownFunction", name = name).to_string()));
+ };
+ for (index, arg) in args.iter().enumerate() {
+ let Some(value) = arg else {
+ continue;
+ };
+ if let Some(expected_types) = metadata.accepted_arg_ordered_types.get(index) {
+ Self::check_arg_against_expected_types(name, value, expected_types)?;
+ } else if let Some(remaining_types) = &metadata.remaining_arg_accepted_types {
+ Self::check_arg_against_expected_types(name, value, remaining_types)?;
+ }
+ }
+ Ok(())
+ }
+
+ /// Check that an argument count is within the range a function accepts.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the function, used in the error message.
+ /// * `metadata` - The metadata of the function.
+ /// * `count` - The number of arguments.
+ ///
+ /// # Errors
+ ///
+ /// Returns `DscError::Parser` describing the accepted range when `count` is outside it.
+ fn check_arg_count(name: &str, metadata: &FunctionMetadata, count: usize) -> Result<(), DscError> {
+ let min_args = metadata.min_args;
+ let max_args = metadata.max_args;
+ if count < min_args || count > max_args {
+ if max_args == 0 {
+ return Err(DscError::Parser(t!("functions.noArgsAccepted", name = name).to_string()));
+ }
+ else if min_args == max_args {
+ return Err(DscError::Parser(t!("functions.invalidArgCount", name = name, count = min_args).to_string()));
+ }
+ else if max_args == usize::MAX {
+ return Err(DscError::Parser(t!("functions.minArgsRequired", name = name, count = min_args).to_string()));
+ }
+
+ return Err(DscError::Parser(t!("functions.argCountRequired", name = name, min = min_args, max = max_args).to_string()));
+ }
+ Ok(())
+ }
+
fn check_arg_against_expected_types(name: &str, arg: &Value, expected_types: &[FunctionArgKind]) -> Result<(), DscError> {
let is_lambda = arg.as_str().is_some_and(|s| s.starts_with("__lambda_"));
diff --git a/lib/dsc-lib/src/lib.rs b/lib/dsc-lib/src/lib.rs
index 9540b2385..d6df364f7 100644
--- a/lib/dsc-lib/src/lib.rs
+++ b/lib/dsc-lib/src/lib.rs
@@ -17,6 +17,7 @@ pub mod dscerror;
pub mod dscresources;
pub mod extensions;
pub mod functions;
+pub mod lint;
pub mod parser;
pub mod progress;
pub mod schemas;
diff --git a/lib/dsc-lib/src/lint/context.rs b/lib/dsc-lib/src/lint/context.rs
new file mode 100644
index 000000000..77e251df6
--- /dev/null
+++ b/lib/dsc-lib/src/lint/context.rs
@@ -0,0 +1,202 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use std::collections::{HashMap, HashSet};
+
+use serde_json::Value;
+
+use crate::{
+ configure::config_doc::{Configuration, Parameter, Resource},
+ dscerror::DscError,
+ dscresources::dscresource::DscResource,
+ functions::FunctionDispatcher,
+};
+
+use super::{expression_index::ExpressionIndex, options::LintOptions, pointer::JsonPointer};
+
+/// A resource declaration and where it sits in the document.
+pub struct ResourceSite<'a> {
+ /// The pointer to the resource declaration.
+ pub pointer: JsonPointer,
+ /// The resource declaration.
+ pub resource: &'a Resource,
+ /// The index of the resource that declares this one in its nested `resources`, if any.
+ pub parent: Option,
+}
+
+/// The outcome of discovering the resource type a declaration uses.
+pub struct ResolvedResource {
+ /// The installed resource that satisfies the declaration, if one was found.
+ pub found: Option,
+ /// Whether any version of the resource type is installed, regardless of `requireVersion`.
+ pub type_exists: bool,
+}
+
+/// Everything a rule can inspect.
+pub struct LintContext<'a> {
+ /// The document as parsed, for checks that depend on how it was written.
+ pub raw: &'a Value,
+ /// The document as a typed configuration.
+ pub config: &'a Configuration,
+ /// Every resource declaration, depth first, with nested resources after their parent.
+ pub resources: Vec>,
+ /// Every expression in the document, parsed once.
+ pub expressions: ExpressionIndex,
+ /// The options of the lint run.
+ pub options: &'a LintOptions,
+ /// The built-in functions, for checking calls without evaluating them.
+ pub functions: &'a FunctionDispatcher,
+ /// The parameter values supplied for the run, when any were.
+ pub supplied_parameters: Option>,
+ /// The discovered resource for each entry of `resources`, when discovery ran.
+ pub discovered: Option>,
+}
+
+impl LintContext<'_> {
+ /// Whether a string is an expression, as opposed to a plain or escaped (`[[`) string.
+ ///
+ /// # Arguments
+ ///
+ /// * `text` - The string to classify.
+ #[must_use]
+ pub fn is_expression(text: &str) -> bool {
+ text.starts_with('[') && !text.starts_with("[[")
+ }
+
+ /// Group the resources that share a parent, in document order.
+ ///
+ /// # Returns
+ ///
+ /// The indexes into `resources` for each group, top-level resources first.
+ #[must_use]
+ pub fn sibling_groups(&self) -> Vec> {
+ let mut groups: Vec<(Option, Vec)> = Vec::new();
+ for (index, site) in self.resources.iter().enumerate() {
+ match groups.iter_mut().find(|(parent, _)| *parent == site.parent) {
+ Some((_, members)) => members.push(index),
+ None => groups.push((site.parent, vec![index])),
+ }
+ }
+ groups.into_iter().map(|(_, members)| members).collect()
+ }
+
+ /// The raw value of a resource declaration.
+ ///
+ /// # Arguments
+ ///
+ /// * `index` - The index into `resources`.
+ #[must_use]
+ pub fn raw_resource(&self, index: usize) -> Option<&Value> {
+ self.resources.get(index).and_then(|site| site.pointer.resolve(self.raw))
+ }
+
+ /// The declared parameter with the given name.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The parameter name.
+ #[must_use]
+ pub fn declared_parameter(&self, name: &str) -> Option<&Parameter> {
+ self.config.parameters.as_ref().and_then(|parameters| parameters.get(name))
+ }
+
+ /// The fully qualified names (`namespace.member`) of the user-defined functions.
+ #[must_use]
+ pub fn user_function_names(&self) -> HashSet {
+ let mut names = HashSet::new();
+ if let Some(functions) = &self.config.functions {
+ for function in functions {
+ for member in function.members.keys() {
+ names.insert(format!("{}.{member}", function.namespace));
+ }
+ }
+ }
+ names
+ }
+
+ /// The text of an error without the error-kind prefix, for use in a diagnostic message.
+ ///
+ /// # Arguments
+ ///
+ /// * `error` - The error to describe.
+ #[must_use]
+ pub fn error_message(error: &DscError) -> String {
+ match error {
+ DscError::Parser(message) | DscError::Validation(message) => message.clone(),
+ other => other.to_string(),
+ }
+ }
+}
+
+/// Flatten the resources of a configuration into sites, depth first.
+///
+/// # Arguments
+///
+/// * `config` - The configuration.
+#[must_use]
+pub fn collect_resource_sites(config: &Configuration) -> Vec> {
+ let mut sites = Vec::new();
+ collect_sites(&config.resources, &JsonPointer::root().push("resources"), None, &mut sites);
+ sites
+}
+
+/// Append the sites for a list of resources and their nested resources.
+///
+/// # Arguments
+///
+/// * `resources` - The resources to add.
+/// * `pointer` - The pointer to the `resources` array.
+/// * `parent` - The index of the resource that declares this list, if any.
+/// * `sites` - Where sites are appended.
+fn collect_sites<'a>(resources: &'a [Resource], pointer: &JsonPointer, parent: Option, sites: &mut Vec>) {
+ for (index, resource) in resources.iter().enumerate() {
+ let resource_pointer = pointer.push_index(index);
+ let site_index = sites.len();
+ sites.push(ResourceSite {
+ pointer: resource_pointer.clone(),
+ resource,
+ parent,
+ });
+ if let Some(children) = &resource.resources {
+ collect_sites(children, &resource_pointer.push("resources"), Some(site_index), sites);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn collects_nested_resources_depth_first() {
+ let config: Configuration = serde_yaml::from_str(r"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: group
+ type: Microsoft.DSC/Group
+ properties:
+ resources:
+ - name: inner
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: hi
+ resources:
+ - name: nested
+ type: Microsoft.DSC.Debug/Echo
+- name: last
+ type: Microsoft.DSC.Debug/Echo
+").unwrap();
+ let sites = collect_resource_sites(&config);
+ let pointers: Vec<&str> = sites.iter().map(|site| site.pointer.as_str()).collect();
+ assert_eq!(pointers, vec!["/resources/0", "/resources/0/resources/0", "/resources/1"]);
+ assert_eq!(sites[1].parent, Some(0));
+ assert_eq!(sites[2].parent, None);
+ }
+
+ #[test]
+ fn classifies_expressions() {
+ assert!(LintContext::is_expression("[parameters('x')]"));
+ assert!(!LintContext::is_expression("[[literal]"));
+ assert!(!LintContext::is_expression("plain"));
+ }
+}
diff --git a/lib/dsc-lib/src/lint/diagnostic.rs b/lib/dsc-lib/src/lint/diagnostic.rs
new file mode 100644
index 000000000..01a20a742
--- /dev/null
+++ b/lib/dsc-lib/src/lint/diagnostic.rs
@@ -0,0 +1,234 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use std::fmt::Display;
+
+use clap::ValueEnum;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+use crate::schemas::{dsc_repo::DscRepoSchema, transforms::idiomaticize_string_enum};
+
+/// The severity of a diagnostic.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, JsonSchema, ValueEnum)]
+#[serde(rename_all = "camelCase")]
+#[schemars(inline, transform = idiomaticize_string_enum)]
+pub enum LintLevel {
+ /// A suggestion that doesn't affect whether the document works.
+ Info,
+ /// A problem that is likely to cause unexpected behavior.
+ Warning,
+ /// A problem that prevents the document from working.
+ Error,
+}
+
+impl Display for LintLevel {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let text = match self {
+ LintLevel::Info => "info",
+ LintLevel::Warning => "warning",
+ LintLevel::Error => "error",
+ };
+ write!(f, "{text}")
+ }
+}
+
+/// The area of a document that a rule inspects.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+#[schemars(inline, transform = idiomaticize_string_enum)]
+pub enum LintCategory {
+ /// The structure of the document and its resources.
+ Document,
+ /// Expressions and the functions they call.
+ Expression,
+ /// Parameter and variable declarations and their values.
+ Parameter,
+ /// Dependencies between resources.
+ Dependency,
+ /// Features that DSC has deprecated or doesn't support.
+ Deprecation,
+ /// The environment the document targets.
+ Environment,
+ /// Checks that need the installed resources.
+ Resource,
+}
+
+impl Display for LintCategory {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let text = match self {
+ LintCategory::Document => "document",
+ LintCategory::Expression => "expression",
+ LintCategory::Parameter => "parameter",
+ LintCategory::Dependency => "dependency",
+ LintCategory::Deprecation => "deprecation",
+ LintCategory::Environment => "environment",
+ LintCategory::Resource => "resource",
+ };
+ write!(f, "{text}")
+ }
+}
+
+/// A position in the source text.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
+#[serde(deny_unknown_fields)]
+#[schemars(inline)]
+pub struct SourceLocation {
+ /// The line number, starting at 1.
+ pub line: usize,
+ /// The column number, starting at 1.
+ pub column: usize,
+}
+
+/// A problem found in a document.
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
+#[serde(deny_unknown_fields)]
+#[schemars(inline)]
+pub struct LintDiagnostic {
+ /// The severity of the problem.
+ pub level: LintLevel,
+ /// The identifier of the rule that reported the problem.
+ pub rule: String,
+ /// The category of the rule that reported the problem.
+ pub category: LintCategory,
+ /// A description of the problem.
+ pub message: String,
+ /// JSON pointer (RFC 6901) to the location of the problem in the document. An empty string
+ /// identifies the document root.
+ pub path: String,
+ /// The file the document was read from, when known.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub file: Option,
+ /// The position of the problem in the source text, when known.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub location: Option,
+}
+
+/// Counts of diagnostics by level.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
+#[serde(deny_unknown_fields)]
+#[schemars(inline)]
+pub struct LintSummary {
+ /// The number of error-level diagnostics.
+ pub errors: usize,
+ /// The number of warning-level diagnostics.
+ pub warnings: usize,
+ /// The number of info-level diagnostics.
+ pub info: usize,
+}
+
+/// The result of linting one or more documents.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema, DscRepoSchema)]
+#[serde(deny_unknown_fields)]
+#[dsc_repo_schema(base_name = "result", folder_path = "outputs/lint")]
+#[schemars(
+ transform = LintResult::transform_export_schema_uris,
+ transform = LintResult::transform_schema_docs
+)]
+pub struct LintResult {
+ /// The problems found, ordered by path and rule.
+ pub diagnostics: Vec,
+ /// Counts of the problems found by level.
+ pub summary: LintSummary,
+ /// Whether no error-level problems were found.
+ pub valid: bool,
+ /// Whether resource discovery ran, which enables the rules that inspect installed resources.
+ pub discovery: bool,
+}
+
+impl LintResult {
+ /// Build a result from a set of diagnostics, sorting them and computing the summary.
+ ///
+ /// # Arguments
+ ///
+ /// * `diagnostics` - The diagnostics found.
+ /// * `discovery` - Whether resource discovery ran.
+ #[must_use]
+ pub fn from_diagnostics(mut diagnostics: Vec, discovery: bool) -> Self {
+ diagnostics.sort_by(|a, b| a.file.cmp(&b.file).then_with(|| a.path.cmp(&b.path)).then_with(|| a.rule.cmp(&b.rule)));
+ let mut summary = LintSummary::default();
+ for diagnostic in &diagnostics {
+ match diagnostic.level {
+ LintLevel::Error => summary.errors += 1,
+ LintLevel::Warning => summary.warnings += 1,
+ LintLevel::Info => summary.info += 1,
+ }
+ }
+ Self {
+ valid: summary.errors == 0,
+ discovery,
+ summary,
+ diagnostics,
+ }
+ }
+
+ /// Combine another result into this one, for example when linting several files.
+ ///
+ /// # Arguments
+ ///
+ /// * `other` - The result to merge; its diagnostics are appended and re-sorted.
+ pub fn merge(&mut self, other: LintResult) {
+ let discovery = self.discovery && other.discovery;
+ let mut diagnostics = std::mem::take(&mut self.diagnostics);
+ diagnostics.extend(other.diagnostics);
+ *self = Self::from_diagnostics(diagnostics, discovery);
+ }
+
+ /// Whether any diagnostic is at or above the given level.
+ ///
+ /// # Arguments
+ ///
+ /// * `level` - The threshold.
+ #[must_use]
+ pub fn has_diagnostic_at_least(&self, level: LintLevel) -> bool {
+ self.diagnostics.iter().any(|diagnostic| diagnostic.level >= level)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn diagnostic(level: LintLevel, rule: &str, path: &str) -> LintDiagnostic {
+ LintDiagnostic {
+ level,
+ rule: rule.to_string(),
+ category: LintCategory::Document,
+ message: String::new(),
+ path: path.to_string(),
+ file: None,
+ location: None,
+ }
+ }
+
+ #[test]
+ fn from_diagnostics_sorts_and_summarizes() {
+ let result = LintResult::from_diagnostics(vec![
+ diagnostic(LintLevel::Warning, "b", "/resources/1"),
+ diagnostic(LintLevel::Error, "a", "/resources/0"),
+ diagnostic(LintLevel::Info, "a", "/resources/1"),
+ ], true);
+ assert_eq!(result.summary, LintSummary { errors: 1, warnings: 1, info: 1 });
+ assert!(!result.valid);
+ assert!(result.discovery);
+ let order: Vec<(&str, &str)> = result.diagnostics.iter().map(|d| (d.path.as_str(), d.rule.as_str())).collect();
+ assert_eq!(order, vec![("/resources/0", "a"), ("/resources/1", "a"), ("/resources/1", "b")]);
+ }
+
+ #[test]
+ fn merge_combines_results() {
+ let mut first = LintResult::from_diagnostics(vec![diagnostic(LintLevel::Warning, "a", "")], true);
+ let second = LintResult::from_diagnostics(vec![diagnostic(LintLevel::Error, "b", "")], false);
+ first.merge(second);
+ assert_eq!(first.diagnostics.len(), 2);
+ assert!(!first.valid);
+ assert!(!first.discovery);
+ assert!(first.has_diagnostic_at_least(LintLevel::Error));
+ }
+
+ #[test]
+ fn levels_are_ordered() {
+ assert!(LintLevel::Error > LintLevel::Warning);
+ assert!(LintLevel::Warning > LintLevel::Info);
+ }
+}
diff --git a/lib/dsc-lib/src/lint/expression_index.rs b/lib/dsc-lib/src/lint/expression_index.rs
new file mode 100644
index 000000000..864c1b3ec
--- /dev/null
+++ b/lib/dsc-lib/src/lint/expression_index.rs
@@ -0,0 +1,366 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use std::collections::HashMap;
+
+use serde_json::Value;
+
+use crate::{
+ dscerror::DscError,
+ parser::{expressions::{Accessor, Expression}, functions::{Function, FunctionArg}, ParsedStatement, Statement},
+};
+
+use super::{context::ResourceSite, pointer::JsonPointer};
+
+/// Where in the document an expression appears, which decides how its functions are resolved.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum ExpressionScope {
+ /// Inside the resource with the given index in the context's resource list.
+ Resource(usize),
+ /// Inside the default value of the named parameter.
+ Parameter(String),
+ /// Inside the named variable.
+ Variable(String),
+ /// Inside the named output.
+ Output(String),
+ /// Inside the body of a user-defined function, where `parameters()` refers to the
+ /// function's own parameters.
+ UserFunction,
+}
+
+/// One expression found in the document.
+pub struct ExpressionSite {
+ /// The pointer to the string that holds the expression.
+ pub pointer: JsonPointer,
+ /// The expression text as written.
+ pub text: String,
+ /// Where the expression appears.
+ pub scope: ExpressionScope,
+ /// The parsed expression, or the parse error.
+ pub parsed: Result,
+}
+
+/// A function call found inside an expression.
+pub struct FunctionCall<'a> {
+ /// The expression the call appears in.
+ pub site: &'a ExpressionSite,
+ /// The call.
+ pub function: &'a Function,
+}
+
+/// Every expression in a document, parsed once so rules can inspect them without re-parsing.
+pub struct ExpressionIndex {
+ sites: Vec,
+ by_pointer: HashMap,
+}
+
+impl ExpressionIndex {
+ /// Find and parse every expression in a document.
+ ///
+ /// Strings are walked in the places DSC evaluates expressions: parameter default values,
+ /// variables, outputs, user-defined function bodies, and for each resource its name,
+ /// condition, `dependsOn` entries, copy counts, and properties.
+ ///
+ /// # Arguments
+ ///
+ /// * `raw` - The document.
+ /// * `resources` - The resource sites of the document, in the order the context lists them.
+ /// * `parser` - The parser to parse expressions with.
+ #[must_use]
+ pub fn build(raw: &Value, resources: &[ResourceSite<'_>], parser: &mut Statement) -> Self {
+ let mut index = Self {
+ sites: Vec::new(),
+ by_pointer: HashMap::new(),
+ };
+ let root = JsonPointer::root();
+
+ if let Some(parameters) = raw.get("parameters").and_then(Value::as_object) {
+ let base = root.push("parameters");
+ for (name, parameter) in parameters {
+ if let Some(default_value) = parameter.get("defaultValue") {
+ index.walk(default_value, &base.push(name).push("defaultValue"), &ExpressionScope::Parameter(name.clone()), parser);
+ }
+ }
+ }
+
+ if let Some(variables) = raw.get("variables").and_then(Value::as_object) {
+ let base = root.push("variables");
+ for (name, variable) in variables {
+ index.walk(variable, &base.push(name), &ExpressionScope::Variable(name.clone()), parser);
+ }
+ }
+
+ if let Some(outputs) = raw.get("outputs").and_then(Value::as_object) {
+ let base = root.push("outputs");
+ for (name, output) in outputs {
+ let scope = ExpressionScope::Output(name.clone());
+ for key in ["value", "condition"] {
+ if let Some(member) = output.get(key) {
+ index.walk(member, &base.push(name).push(key), &scope, parser);
+ }
+ }
+ }
+ }
+
+ if let Some(functions) = raw.get("functions").and_then(Value::as_array) {
+ let base = root.push("functions");
+ for (position, function) in functions.iter().enumerate() {
+ let Some(members) = function.get("members").and_then(Value::as_object) else {
+ continue;
+ };
+ for (member_name, member) in members {
+ if let Some(value) = member.get("output").and_then(|output| output.get("value")) {
+ let pointer = base.push_index(position).push("members").push(member_name).push("output").push("value");
+ index.walk(value, &pointer, &ExpressionScope::UserFunction, parser);
+ }
+ }
+ }
+ }
+
+ for (position, site) in resources.iter().enumerate() {
+ let Some(resource) = site.pointer.resolve(raw) else {
+ continue;
+ };
+ let scope = ExpressionScope::Resource(position);
+ for key in ["name", "condition", "dependsOn", "properties"] {
+ if let Some(member) = resource.get(key) {
+ index.walk(member, &site.pointer.push(key), &scope, parser);
+ }
+ }
+ if let Some(copy) = resource.get("copy") {
+ for key in ["count", "batchSize"] {
+ if let Some(member) = copy.get(key) {
+ index.walk(member, &site.pointer.push("copy").push(key), &scope, parser);
+ }
+ }
+ }
+ }
+
+ index
+ }
+
+ /// Walk a value, parsing every string that starts with `[`.
+ ///
+ /// # Arguments
+ ///
+ /// * `value` - The value to walk.
+ /// * `pointer` - The pointer to `value`.
+ /// * `scope` - The scope every expression found belongs to.
+ /// * `parser` - The parser to parse expressions with.
+ fn walk(&mut self, value: &Value, pointer: &JsonPointer, scope: &ExpressionScope, parser: &mut Statement) {
+ match value {
+ Value::String(text) if text.starts_with('[') => {
+ self.by_pointer.insert(pointer.to_string(), self.sites.len());
+ self.sites.push(ExpressionSite {
+ pointer: pointer.clone(),
+ text: text.clone(),
+ scope: scope.clone(),
+ parsed: parser.parse(text),
+ });
+ },
+ Value::Array(items) => {
+ for (position, item) in items.iter().enumerate() {
+ self.walk(item, &pointer.push_index(position), scope, parser);
+ }
+ },
+ Value::Object(members) => {
+ for (key, member) in members {
+ self.walk(member, &pointer.push(key), scope, parser);
+ }
+ },
+ _ => {},
+ }
+ }
+
+ /// Every expression site in document order.
+ #[must_use]
+ pub fn sites(&self) -> &[ExpressionSite] {
+ &self.sites
+ }
+
+ /// The expression site at a pointer, if the string there is an expression.
+ ///
+ /// # Arguments
+ ///
+ /// * `pointer` - The pointer to the string.
+ #[must_use]
+ pub fn site_at(&self, pointer: &JsonPointer) -> Option<&ExpressionSite> {
+ self.by_pointer.get(pointer.as_str()).map(|position| &self.sites[*position])
+ }
+
+ /// Every function call in every successfully parsed expression, outermost first.
+ #[must_use]
+ pub fn calls(&self) -> Vec> {
+ let mut calls = Vec::new();
+ for site in &self.sites {
+ if let Ok(ParsedStatement::Expression(expression)) = &site.parsed {
+ visit_expression(expression, &mut |function| calls.push(FunctionCall { site, function }));
+ }
+ }
+ calls
+ }
+
+ /// Every call of the named function; names are case-sensitive, as at runtime.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The function name.
+ #[must_use]
+ pub fn calls_named(&self, name: &str) -> Vec> {
+ self.calls().into_iter().filter(|call| call.function.name() == name).collect()
+ }
+}
+
+/// Visit a function and, depth first, every function nested in its arguments and accessors.
+///
+/// # Arguments
+///
+/// * `expression` - The expression to visit.
+/// * `visit` - Called with each function found, outermost first.
+pub fn visit_expression<'a>(expression: &'a Expression, visit: &mut dyn FnMut(&'a Function)) {
+ let function = expression.function();
+ visit(function);
+ if let Some(args) = function.args() {
+ for arg in args {
+ match arg {
+ FunctionArg::Expression(nested) => visit_expression(nested, visit),
+ FunctionArg::Lambda(lambda) => visit_expression(&lambda.body, visit),
+ FunctionArg::Value(_) => {},
+ }
+ }
+ }
+ for accessor in expression.accessors() {
+ if let Accessor::IndexExpression(nested) = accessor {
+ visit_expression(nested, visit);
+ }
+ }
+}
+
+/// The literal string argument at a position of a call, if that argument is a literal string.
+///
+/// # Arguments
+///
+/// * `function` - The call.
+/// * `position` - The zero-based argument position.
+#[must_use]
+pub fn literal_string_arg(function: &Function, position: usize) -> Option<&str> {
+ match function.args()?.get(position)? {
+ FunctionArg::Value(Value::String(text)) => Some(text),
+ _ => None,
+ }
+}
+
+/// The literal values of a call's arguments, with `None` for arguments that are nested
+/// expressions.
+///
+/// # Arguments
+///
+/// * `function` - The call.
+#[must_use]
+pub fn literal_args(function: &Function) -> Vec> {
+ function.args().map_or_else(Vec::new, |args| {
+ args.iter().map(|arg| match arg {
+ FunctionArg::Value(value) => Some(value),
+ _ => None,
+ }).collect()
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{configure::config_doc::Configuration, lint::context::collect_resource_sites};
+
+ fn index_for(yaml: &str) -> (Value, ExpressionIndex) {
+ let raw: Value = serde_yaml::from_str(yaml).unwrap();
+ let config: Configuration = serde_json::from_value(raw.clone()).unwrap();
+ let sites = collect_resource_sites(&config);
+ let mut parser = Statement::new().unwrap();
+ let index = ExpressionIndex::build(&raw, &sites, &mut parser);
+ (raw, index)
+ }
+
+ #[test]
+ fn indexes_expressions_in_every_scope() {
+ let (_, index) = index_for(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+parameters:
+ p:
+ type: string
+ defaultValue: "[concat('a', 'b')]"
+variables:
+ v: "[parameters('p')]"
+functions:
+- namespace: my
+ members:
+ twice:
+ output:
+ type: string
+ value: "[concat(parameters('x'), parameters('x'))]"
+outputs:
+ o:
+ type: string
+ value: "[variables('v')]"
+ condition: "[true()]"
+resources:
+- name: "[concat('n', '1')]"
+ type: Microsoft.DSC.Debug/Echo
+ condition: "[true()]"
+ dependsOn:
+ - "[resourceId('Microsoft.DSC.Debug/Echo', 'other')]"
+ properties:
+ output:
+ nested: "[parameters('p')]"
+ literal: "[[escaped]"
+- name: other
+ type: Microsoft.DSC.Debug/Echo
+"#);
+ let scopes: Vec<(&str, &ExpressionScope)> = index.sites().iter().map(|site| (site.pointer.as_str(), &site.scope)).collect();
+ assert!(scopes.contains(&("/parameters/p/defaultValue", &ExpressionScope::Parameter("p".to_string()))));
+ assert!(scopes.contains(&("/variables/v", &ExpressionScope::Variable("v".to_string()))));
+ assert!(scopes.contains(&("/functions/0/members/twice/output/value", &ExpressionScope::UserFunction)));
+ assert!(scopes.contains(&("/outputs/o/value", &ExpressionScope::Output("o".to_string()))));
+ assert!(scopes.contains(&("/outputs/o/condition", &ExpressionScope::Output("o".to_string()))));
+ assert!(scopes.contains(&("/resources/0/name", &ExpressionScope::Resource(0))));
+ assert!(scopes.contains(&("/resources/0/condition", &ExpressionScope::Resource(0))));
+ assert!(scopes.contains(&("/resources/0/dependsOn/0", &ExpressionScope::Resource(0))));
+ assert!(scopes.contains(&("/resources/0/properties/output/nested", &ExpressionScope::Resource(0))));
+ // escaped strings are indexed so rules can tell them apart from literals
+ let escaped = index.site_at(&JsonPointer::from_pointer("/resources/0/properties/output/literal")).unwrap();
+ assert!(matches!(escaped.parsed, Ok(ParsedStatement::Escaped(_))));
+ assert!(index.sites().iter().all(|site| site.parsed.is_ok()));
+ }
+
+ #[test]
+ fn collects_nested_calls() {
+ let (_, index) = index_for(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "[concat(parameters('p'), variables('v')[int('0')])]"
+"#);
+ let names: Vec<&str> = index.calls().iter().map(|call| call.function.name()).collect();
+ assert_eq!(names, vec!["concat", "parameters", "variables", "int"]);
+ let parameters = index.calls_named("parameters");
+ assert_eq!(parameters.len(), 1);
+ assert_eq!(literal_string_arg(parameters[0].function, 0), Some("p"));
+ assert_eq!(literal_args(index.calls_named("concat")[0].function), vec![None, None]);
+ }
+
+ #[test]
+ fn records_parse_failures() {
+ let (_, index) = index_for(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "[concat('unterminated)]"
+"#);
+ assert_eq!(index.sites().len(), 1);
+ assert!(index.sites()[0].parsed.is_err());
+ assert!(index.calls().is_empty());
+ }
+}
diff --git a/lib/dsc-lib/src/lint/mod.rs b/lib/dsc-lib/src/lint/mod.rs
new file mode 100644
index 000000000..ca48fcc32
--- /dev/null
+++ b/lib/dsc-lib/src/lint/mod.rs
@@ -0,0 +1,818 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Static analysis for DSC configuration documents.
+//!
+//! The linter parses a document, validates it against the configuration document schema, and
+//! runs a set of rules that find problems before the document is applied. Rules never invoke
+//! resource operations; with discovery enabled they only read the manifests of installed
+//! resources.
+//!
+//! ```rust,ignore
+//! use dsc_lib::lint::{Linter, LintOptions};
+//!
+//! let mut linter = Linter::new(LintOptions::default())?;
+//! let result = linter.lint(document_text)?;
+//! for diagnostic in &result.diagnostics {
+//! println!("{} {} {}: {}", diagnostic.level, diagnostic.rule, diagnostic.path, diagnostic.message);
+//! }
+//! ```
+
+use std::collections::HashMap;
+
+use miette::Diagnostic;
+use rust_i18n::t;
+use serde_json::Value;
+use thiserror::Error;
+use tracing::{debug, warn};
+
+use crate::{
+ DscManager,
+ configure::{
+ config_doc::{Configuration, Resource, ResourceDiscoveryMode},
+ get_require_adapter_from_directive,
+ parameters::import_parameters,
+ },
+ discovery::discovery_trait::DiscoveryFilter,
+ dscerror::DscError,
+ dscresources::{dscresource::validate_json_all, resource_manifest::ResourceManifest},
+ extensions::extension_manifest::ExtensionManifest,
+ parser::Statement,
+ progress::ProgressFormat,
+ schemas::dsc_repo::{DscRepoSchema, RecognizedSchemaVersion, SchemaForm, SchemaUriPrefix},
+ util::{get_setting, parse_input_to_value},
+};
+
+pub mod context;
+pub mod diagnostic;
+pub mod expression_index;
+pub mod options;
+pub mod pointer;
+pub mod rule;
+pub mod rules;
+
+pub use diagnostic::{LintCategory, LintDiagnostic, LintLevel, LintResult, LintSummary, SourceLocation};
+pub use options::{DocumentKind, LintOptions, LintSettings, RuleLevel, RuleLevelOverride, RuleSetting};
+pub use pointer::JsonPointer;
+pub use rule::{CheckFn, DiagnosticSink, LintRuleInfo, Rule, RuleTier};
+
+use context::{collect_resource_sites, LintContext, ResolvedResource};
+use expression_index::ExpressionIndex;
+
+/// The fixed rule that reports a document that isn't valid JSON or YAML.
+pub const PARSE_ERROR_RULE: &str = "parse-error";
+/// The fixed rule that reports violations of the configuration document schema.
+pub const SCHEMA_INVALID_RULE: &str = "schema-invalid";
+/// The fixed rule that reports a document kind the linter doesn't support yet.
+pub const UNSUPPORTED_DOCUMENT_TYPE_RULE: &str = "unsupported-document-type";
+/// The fixed rule that reports a resource discovery failure.
+pub const DISCOVERY_FAILED_RULE: &str = "discovery-failed";
+
+/// The rules whose level can't be changed.
+const FIXED_RULES: [&str; 4] = [PARSE_ERROR_RULE, SCHEMA_INVALID_RULE, UNSUPPORTED_DOCUMENT_TYPE_RULE, DISCOVERY_FAILED_RULE];
+
+/// Errors raised by the linter itself, as opposed to problems found in a document.
+#[derive(Debug, Error, Diagnostic)]
+pub enum LintError {
+ /// A rule level override names a rule that doesn't exist.
+ #[error("{t}: '{0}'", t = t!("lint.mod.unknownRule"))]
+ UnknownRule(String),
+ /// A rule level override isn't written as `=`.
+ #[error("{t}: '{0}'", t = t!("lint.mod.invalidRuleOverride"))]
+ InvalidRuleOverride(String),
+ /// A rule level isn't `off`, `info`, `warning`, or `error`.
+ #[error("{t}: '{0}'", t = t!("lint.mod.invalidRuleLevel"))]
+ InvalidRuleLevel(String),
+ /// The `lint` section of the settings file can't be read.
+ #[error("{t}: {0}", t = t!("lint.mod.invalidSettings"))]
+ InvalidSettings(String),
+ /// The supplied parameters aren't in a format DSC accepts.
+ #[error("{t}: {0}", t = t!("lint.mod.invalidParameters"))]
+ InvalidParameters(String),
+}
+
+/// A rule together with the level it reports at in this run.
+struct ActiveRule {
+ rule: Rule,
+ level: LintLevel,
+}
+
+/// Lints configuration documents.
+///
+/// Create one with [`Linter::new`] and reuse it for several documents; it holds the compiled
+/// expression parser and the effective rule levels.
+pub struct Linter {
+ options: LintOptions,
+ rules: Vec,
+ levels: HashMap<&'static str, RuleLevel>,
+ parser: Statement,
+ config_schema: Value,
+}
+
+impl Linter {
+ /// Create a linter.
+ ///
+ /// # Arguments
+ ///
+ /// * `options` - The options of the run.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`LintError::UnknownRule`] when an override names an unknown rule,
+ /// [`LintError::InvalidSettings`] when the settings file's `lint` section is malformed, or
+ /// another `DscError` when the expression parser or the document schema can't be prepared.
+ pub fn new(options: LintOptions) -> Result {
+ let levels = effective_levels(&options)?;
+ let rules = rules::all_rules().into_iter().filter_map(|rule| {
+ let level = levels.get(rule.id).copied().unwrap_or(rule.default_level).as_lint_level()?;
+ Some(ActiveRule { rule, level })
+ }).collect();
+ let config_schema = serde_json::to_value(Configuration::generate_schema(
+ RecognizedSchemaVersion::default(),
+ SchemaForm::Bundled,
+ SchemaUriPrefix::AkaDotMs,
+ ))?;
+ Ok(Self {
+ options,
+ rules,
+ levels,
+ parser: Statement::new()?,
+ config_schema,
+ })
+ }
+
+ /// The options of the run.
+ #[must_use]
+ pub fn options(&self) -> &LintOptions {
+ &self.options
+ }
+
+ /// Set the file name recorded on the diagnostics of the documents linted from now on.
+ ///
+ /// # Arguments
+ ///
+ /// * `file` - The file the next documents are read from, or `None` for inline input.
+ pub fn set_file(&mut self, file: Option) {
+ self.options.file = file;
+ }
+
+ /// Describe every rule, fixed and configurable, with the level it reports at in this run.
+ #[must_use]
+ pub fn rules(&self) -> Vec {
+ let mut list = fixed_rule_infos();
+ for rule in rules::all_rules() {
+ let level = self.levels.get(rule.id).copied().unwrap_or(rule.default_level);
+ list.push(LintRuleInfo::from_rule(&rule, level));
+ }
+ list.sort_by(|a, b| a.id.cmp(&b.id));
+ list
+ }
+
+ /// Lint a document given as JSON or YAML text.
+ ///
+ /// Problems in the document are reported as diagnostics, including a document that doesn't
+ /// parse.
+ ///
+ /// # Arguments
+ ///
+ /// * `document` - The document text.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`LintError::InvalidParameters`] when the supplied parameters can't be imported,
+ /// a `DscError::Validation` when the configuration document schema can't be compiled, or
+ /// another `DscError` when discovery itself fails in a way that isn't reported as a
+ /// diagnostic.
+ pub fn lint(&mut self, document: &str) -> Result {
+ match parse_document(document) {
+ Ok(raw) => self.lint_parsed(&raw, Vec::new()),
+ Err(diagnostic) => Ok(LintResult::from_diagnostics(stamp_file(vec![*diagnostic], &self.options.file), false)),
+ }
+ }
+
+ /// Lint a document that is already parsed.
+ ///
+ /// # Arguments
+ ///
+ /// * `document` - The document.
+ ///
+ /// # Errors
+ ///
+ /// See [`lint`](Self::lint).
+ pub fn lint_value(&mut self, document: &Value) -> Result {
+ self.lint_parsed(document, Vec::new())
+ }
+
+ /// Run the pipeline on a parsed document.
+ ///
+ /// # Arguments
+ ///
+ /// * `raw` - The document.
+ /// * `diagnostics` - Diagnostics already collected for the document.
+ fn lint_parsed(&mut self, raw: &Value, mut diagnostics: Vec) -> Result {
+ if let Some(diagnostic) = self.check_document_kind(raw) {
+ diagnostics.push(diagnostic);
+ return Ok(LintResult::from_diagnostics(stamp_file(diagnostics, &self.options.file), false));
+ }
+
+ for violation in validate_json_all("Configuration", &self.config_schema, raw)? {
+ diagnostics.push(fixed_diagnostic(
+ SCHEMA_INVALID_RULE,
+ LintCategory::Document,
+ LintLevel::Error,
+ &JsonPointer::from_pointer(&violation.instance_path),
+ None,
+ violation.message,
+ ));
+ }
+
+ let config: Configuration = match serde_json::from_value(raw.clone()) {
+ Ok(config) => config,
+ Err(err) => {
+ if diagnostics.is_empty() {
+ diagnostics.push(fixed_diagnostic(
+ SCHEMA_INVALID_RULE,
+ LintCategory::Document,
+ LintLevel::Error,
+ &JsonPointer::root(),
+ None,
+ t!("lint.mod.documentInvalid", error = err).to_string(),
+ ));
+ }
+ return Ok(LintResult::from_diagnostics(stamp_file(diagnostics, &self.options.file), false));
+ },
+ };
+
+ let supplied_parameters = match &self.options.parameters {
+ Some(parameters) => Some(import_parameters(parameters).map_err(|err| LintError::InvalidParameters(err.to_string()))?),
+ None => None,
+ };
+
+ let Self { options, rules, parser, .. } = self;
+ let sites = collect_resource_sites(&config);
+ let expressions = ExpressionIndex::build(raw, &sites, parser);
+ let mut context = LintContext {
+ raw,
+ config: &config,
+ resources: sites,
+ expressions,
+ options,
+ functions: parser.function_dispatcher(),
+ supplied_parameters,
+ discovered: None,
+ };
+
+ for active in rules.iter().filter(|active| active.rule.tier == RuleTier::Static) {
+ run_rule(active, &context, &mut diagnostics);
+ }
+
+ let mut discovery_ran = false;
+ if options.discovery && !skips_discovery(&config) {
+ match discover(&context, options.progress_format) {
+ Ok(discovered) => {
+ context.discovered = Some(discovered);
+ discovery_ran = true;
+ for active in rules.iter().filter(|active| active.rule.tier == RuleTier::Resource) {
+ run_rule(active, &context, &mut diagnostics);
+ }
+ },
+ Err(err) => {
+ debug!("{}", t!("lint.mod.discoveryFailed", error = err));
+ diagnostics.push(fixed_diagnostic(
+ DISCOVERY_FAILED_RULE,
+ LintCategory::Resource,
+ LintLevel::Warning,
+ &JsonPointer::root(),
+ None,
+ t!("lint.mod.discoveryFailed", error = err).to_string(),
+ ));
+ },
+ }
+ }
+
+ Ok(LintResult::from_diagnostics(stamp_file(diagnostics, &options.file), discovery_ran))
+ }
+
+ /// Report a document whose kind the linter doesn't support.
+ ///
+ /// # Arguments
+ ///
+ /// * `raw` - The document.
+ fn check_document_kind(&self, raw: &Value) -> Option {
+ let kind = self.options.document_kind.or_else(|| detect_document_kind(raw))?;
+ if kind == DocumentKind::Configuration {
+ return None;
+ }
+ Some(fixed_diagnostic(
+ UNSUPPORTED_DOCUMENT_TYPE_RULE,
+ LintCategory::Document,
+ LintLevel::Error,
+ &JsonPointer::root().push("$schema"),
+ None,
+ t!("lint.mod.unsupportedDocumentType", kind = kind).to_string(),
+ ))
+ }
+}
+
+/// Parse a document as JSON or YAML, reporting a failure as a diagnostic.
+///
+/// Input that starts with `{` is reported with the JSON parser's message and position so the
+/// message matches what the author wrote; everything else uses the YAML parser's.
+///
+/// # Arguments
+///
+/// * `document` - The document text.
+///
+/// # Errors
+///
+/// Returns the `parse-error` diagnostic when the text is neither valid JSON nor valid YAML.
+fn parse_document(document: &str) -> Result> {
+ let yaml_error = match parse_input_to_value(document) {
+ Ok(value) => return Ok(value),
+ Err(err) => err,
+ };
+ let (message, location) = match (document.trim_start().starts_with('{'), serde_json::from_str::(document), &yaml_error) {
+ (true, Err(err), _) => (err.to_string(), Some(SourceLocation { line: err.line(), column: err.column() })),
+ (_, _, DscError::Yaml(err)) => (err.to_string(), err.location().map(|location| SourceLocation { line: location.line(), column: location.column() })),
+ (_, _, other) => (other.to_string(), None),
+ };
+ Err(Box::new(fixed_diagnostic(
+ PARSE_ERROR_RULE,
+ LintCategory::Document,
+ LintLevel::Error,
+ &JsonPointer::root(),
+ location,
+ t!("lint.mod.parseError", error = message).to_string(),
+ )))
+}
+
+/// Detect the kind of document from its `$schema` URI.
+///
+/// # Arguments
+///
+/// * `raw` - The document.
+///
+/// # Returns
+///
+/// The kind, or `None` when there is no recognized `$schema`.
+fn detect_document_kind(raw: &Value) -> Option {
+ let uri = raw.get("$schema")?.as_str()?.to_string();
+ if Configuration::is_recognized_schema_uri(&uri) {
+ Some(DocumentKind::Configuration)
+ } else if ResourceManifest::is_recognized_schema_uri(&uri) {
+ Some(DocumentKind::ResourceManifest)
+ } else if ExtensionManifest::is_recognized_schema_uri(&uri) {
+ Some(DocumentKind::ExtensionManifest)
+ } else {
+ None
+ }
+}
+
+/// Whether the document asks DSC to discover resources during deployment instead of up front.
+///
+/// # Arguments
+///
+/// * `config` - The configuration.
+fn skips_discovery(config: &Configuration) -> bool {
+ config.directives.as_ref().and_then(|directives| directives.resource_discovery.as_ref()) == Some(&ResourceDiscoveryMode::DuringDeployment)
+}
+
+/// The discovery filter for a resource declaration, matching what `dsc config` uses.
+///
+/// # Arguments
+///
+/// * `resource` - The resource declaration.
+fn filter_for(resource: &Resource) -> DiscoveryFilter {
+ DiscoveryFilter::new(
+ &resource.resource_type,
+ resource.require_version.clone(),
+ get_require_adapter_from_directive(&resource.directives),
+ )
+}
+
+/// Discover the resource types the document uses.
+///
+/// # Arguments
+///
+/// * `context` - The lint context, for its resource declarations.
+/// * `progress_format` - How to report discovery progress.
+///
+/// # Returns
+///
+/// One entry per resource declaration, in the context's order.
+///
+/// # Errors
+///
+/// Returns the discovery error when resources can't be discovered at all.
+fn discover(context: &LintContext, progress_format: ProgressFormat) -> Result, DscError> {
+ let mut manager = DscManager::new();
+ let mut filters: Vec = Vec::new();
+ for site in &context.resources {
+ let filter = filter_for(site.resource);
+ if !filters.contains(&filter) {
+ filters.push(filter);
+ }
+ }
+ manager.find_resources(&filters, progress_format)?;
+
+ let mut resolved = Vec::with_capacity(context.resources.len());
+ for site in &context.resources {
+ let found = manager.find_resource(&filter_for(site.resource))?.cloned();
+ let type_exists = if found.is_some() {
+ true
+ } else if site.resource.require_version.is_some() {
+ let any_version = DiscoveryFilter::new(&site.resource.resource_type, None, get_require_adapter_from_directive(&site.resource.directives));
+ manager.find_resource(&any_version)?.is_some()
+ } else {
+ false
+ };
+ resolved.push(ResolvedResource { found, type_exists });
+ }
+ Ok(resolved)
+}
+
+/// Run one rule, collecting what it reports.
+///
+/// # Arguments
+///
+/// * `active` - The rule and its level.
+/// * `context` - The lint context.
+/// * `diagnostics` - Where reported diagnostics are appended.
+fn run_rule(active: &ActiveRule, context: &LintContext, diagnostics: &mut Vec) {
+ let mut sink = DiagnosticSink::new(active.rule.id, active.rule.category, active.level, diagnostics);
+ (active.rule.check)(context, &mut sink);
+}
+
+/// Build a diagnostic for one of the fixed rules.
+///
+/// # Arguments
+///
+/// * `rule` - The rule identifier.
+/// * `category` - The rule category.
+/// * `level` - The level to report at.
+/// * `path` - The location of the problem.
+/// * `location` - The position in the source text, when known.
+/// * `message` - A description of the problem.
+fn fixed_diagnostic(rule: &str, category: LintCategory, level: LintLevel, path: &JsonPointer, location: Option, message: String) -> LintDiagnostic {
+ LintDiagnostic {
+ level,
+ rule: rule.to_string(),
+ category,
+ message,
+ path: path.to_string(),
+ file: None,
+ location,
+ }
+}
+
+/// Record the source file on diagnostics that don't have one yet.
+///
+/// # Arguments
+///
+/// * `diagnostics` - The diagnostics.
+/// * `file` - The file the document was read from, if known.
+fn stamp_file(mut diagnostics: Vec, file: &Option) -> Vec {
+ for diagnostic in &mut diagnostics {
+ if diagnostic.file.is_none() {
+ diagnostic.file.clone_from(file);
+ }
+ }
+ diagnostics
+}
+
+/// Whether a rule's level is fixed.
+///
+/// # Arguments
+///
+/// * `id` - The rule identifier.
+fn is_fixed_rule(id: &str) -> bool {
+ FIXED_RULES.contains(&id)
+}
+
+/// Describe the fixed rules.
+fn fixed_rule_infos() -> Vec {
+ let info = |id: &str, category: LintCategory, level: LintLevel, description: String| LintRuleInfo {
+ id: id.to_string(),
+ category,
+ tier: if category == LintCategory::Resource { RuleTier::Resource } else { RuleTier::Static },
+ default_level: level.into(),
+ level: level.into(),
+ fixed: true,
+ description,
+ };
+ vec![
+ info(PARSE_ERROR_RULE, LintCategory::Document, LintLevel::Error, t!("lint.mod.parseErrorDescription").to_string()),
+ info(SCHEMA_INVALID_RULE, LintCategory::Document, LintLevel::Error, t!("lint.mod.schemaInvalidDescription").to_string()),
+ info(UNSUPPORTED_DOCUMENT_TYPE_RULE, LintCategory::Document, LintLevel::Error, t!("lint.mod.unsupportedDocumentTypeDescription").to_string()),
+ info(DISCOVERY_FAILED_RULE, LintCategory::Resource, LintLevel::Warning, t!("lint.mod.discoveryFailedDescription").to_string()),
+ ]
+}
+
+/// Compute the level of every configurable rule for a run.
+///
+/// Precedence, lowest first: the rule's default, the settings file, the overrides in the
+/// options, and the policy file.
+///
+/// # Arguments
+///
+/// * `options` - The options of the run.
+///
+/// # Errors
+///
+/// Returns [`LintError::UnknownRule`] when an override names an unknown rule, or
+/// [`LintError::InvalidSettings`] when the settings can't be read.
+fn effective_levels(options: &LintOptions) -> Result, LintError> {
+ let mut levels: HashMap<&'static str, RuleLevel> = rules::all_rules().iter().map(|rule| (rule.id, rule.default_level)).collect();
+ let (settings, policy) = if options.use_settings { load_settings()? } else { (None, None) };
+ if let Some(settings) = settings {
+ apply_settings(&mut levels, &settings);
+ }
+ for override_ in &options.rule_levels {
+ if is_fixed_rule(&override_.id) {
+ warn!("{}", t!("lint.mod.fixedRuleOverrideIgnored", rule = override_.id));
+ continue;
+ }
+ let Some(level) = levels.get_mut(override_.id.as_str()) else {
+ return Err(LintError::UnknownRule(override_.id.clone()));
+ };
+ *level = override_.level;
+ }
+ if let Some(policy) = policy {
+ apply_settings(&mut levels, &policy);
+ }
+ Ok(levels)
+}
+
+/// Read the `lint` section of the settings and policy files.
+///
+/// # Returns
+///
+/// The settings-file section and the policy-file section; either is `None` when not configured.
+///
+/// # Errors
+///
+/// Returns [`LintError::InvalidSettings`] when a section isn't a valid `lint` setting.
+fn load_settings() -> Result<(Option, Option), LintError> {
+ let Ok(value) = get_setting("lint") else {
+ return Ok((None, None));
+ };
+ Ok((parse_settings(value.setting)?, parse_settings(value.policy)?))
+}
+
+/// Parse one `lint` settings value.
+///
+/// # Arguments
+///
+/// * `value` - The value, or `Null` when the file doesn't configure linting.
+///
+/// # Errors
+///
+/// Returns [`LintError::InvalidSettings`] when the value isn't a valid `lint` setting.
+fn parse_settings(value: Value) -> Result, LintError> {
+ if value.is_null() {
+ return Ok(None);
+ }
+ serde_json::from_value(value).map(Some).map_err(|err| LintError::InvalidSettings(err.to_string()))
+}
+
+/// Apply the rule levels from a settings section, ignoring fixed and unknown rules.
+///
+/// # Arguments
+///
+/// * `levels` - The levels to update.
+/// * `settings` - The settings section.
+fn apply_settings(levels: &mut HashMap<&'static str, RuleLevel>, settings: &LintSettings) {
+ for (id, setting) in &settings.rules {
+ let Some(level) = setting.level else {
+ continue;
+ };
+ if is_fixed_rule(id) {
+ warn!("{}", t!("lint.mod.fixedRuleOverrideIgnored", rule = id));
+ continue;
+ }
+ match levels.get_mut(id.as_str()) {
+ Some(current) => *current = level,
+ None => warn!("{}", t!("lint.mod.unknownRuleInSettings", rule = id)),
+ }
+ }
+}
+
+#[cfg(test)]
+pub(crate) mod test_util {
+ use super::{LintOptions, LintResult, Linter};
+
+ /// Lint a document with discovery and the settings file disabled.
+ pub(crate) fn lint_yaml(document: &str) -> LintResult {
+ lint_with(document, LintOptions {
+ discovery: false,
+ use_settings: false,
+ ..Default::default()
+ })
+ }
+
+ /// Lint a document with the given options.
+ pub(crate) fn lint_with(document: &str, options: LintOptions) -> LintResult {
+ Linter::new(options).unwrap().lint(document).unwrap()
+ }
+
+ /// The paths of the diagnostics a rule reported, in result order.
+ pub(crate) fn rule_paths(result: &LintResult, rule: &str) -> Vec {
+ result.diagnostics.iter().filter(|diagnostic| diagnostic.rule == rule).map(|diagnostic| diagnostic.path.clone()).collect()
+ }
+
+ /// The messages of the diagnostics a rule reported, in result order.
+ pub(crate) fn rule_messages(result: &LintResult, rule: &str) -> Vec {
+ result.diagnostics.iter().filter(|diagnostic| diagnostic.rule == rule).map(|diagnostic| diagnostic.message.clone()).collect()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+
+ use super::{test_util::{lint_with, lint_yaml, rule_paths}, *};
+
+ const CLEAN: &str = r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+parameters:
+ greeting:
+ type: string
+ defaultValue: hello
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "[parameters('greeting')]"
+"#;
+
+ #[test]
+ fn clean_document_is_valid() {
+ let result = lint_yaml(CLEAN);
+ assert!(result.valid, "{:?}", result.diagnostics);
+ assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
+ assert!(!result.discovery);
+ }
+
+ #[test]
+ fn reports_yaml_parse_errors_with_location() {
+ let result = lint_yaml("resources:\n- name: a\n type: [unclosed\n");
+ assert_eq!(rule_paths(&result, PARSE_ERROR_RULE), vec![""]);
+ assert!(result.diagnostics[0].location.is_some());
+ assert!(!result.valid);
+ }
+
+ #[test]
+ fn reports_json_parse_errors_with_json_message() {
+ let result = lint_yaml("{\"resources\": [}");
+ assert_eq!(rule_paths(&result, PARSE_ERROR_RULE), vec![""]);
+ let location = result.diagnostics[0].location.unwrap();
+ assert_eq!(location.line, 1);
+ assert!(location.column > 1);
+ }
+
+ #[test]
+ fn reports_schema_violations_with_paths() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ unknownKey: true
+"#);
+ let paths = rule_paths(&result, SCHEMA_INVALID_RULE);
+ assert!(paths.iter().any(|path| path == "/resources/0"), "{:?}", result.diagnostics);
+ assert!(!result.valid);
+ }
+
+ #[test]
+ fn reports_missing_schema_uri() {
+ let result = lint_yaml("resources: []\n");
+ assert!(!rule_paths(&result, SCHEMA_INVALID_RULE).is_empty(), "{:?}", result.diagnostics);
+ }
+
+ #[test]
+ fn rejects_manifests() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json
+type: Test/Example
+version: 1.0.0
+"#);
+ assert_eq!(rule_paths(&result, UNSUPPORTED_DOCUMENT_TYPE_RULE), vec!["/$schema"]);
+ assert_eq!(result.diagnostics.len(), 1);
+ }
+
+ #[test]
+ fn overrides_change_levels_and_disable_rules() {
+ let document = r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+parameters:
+ unused:
+ type: string
+ defaultValue: x
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+"#;
+ let warned = lint_yaml(document);
+ assert_eq!(rule_paths(&warned, "no-unused-parameters"), vec!["/parameters/unused"]);
+ assert_eq!(warned.diagnostics[0].level, LintLevel::Warning);
+
+ let raised = lint_with(document, LintOptions {
+ discovery: false,
+ use_settings: false,
+ rule_levels: vec!["no-unused-parameters=error".parse().unwrap()],
+ ..Default::default()
+ });
+ assert_eq!(raised.diagnostics[0].level, LintLevel::Error);
+ assert!(!raised.valid);
+
+ let silenced = lint_with(document, LintOptions {
+ discovery: false,
+ use_settings: false,
+ rule_levels: vec!["no-unused-parameters=off".parse().unwrap()],
+ ..Default::default()
+ });
+ assert!(rule_paths(&silenced, "no-unused-parameters").is_empty());
+ }
+
+ #[test]
+ fn unknown_rule_override_is_an_error() {
+ let result = Linter::new(LintOptions {
+ use_settings: false,
+ rule_levels: vec!["no-such-rule=off".parse().unwrap()],
+ ..Default::default()
+ });
+ assert!(matches!(result, Err(DscError::Lint(LintError::UnknownRule(id))) if id == "no-such-rule"));
+ }
+
+ #[test]
+ fn fixed_rule_override_is_ignored() {
+ let linter = Linter::new(LintOptions {
+ use_settings: false,
+ rule_levels: vec!["parse-error=off".parse().unwrap()],
+ ..Default::default()
+ }).unwrap();
+ let result = linter.rules();
+ let parse_error = result.iter().find(|rule| rule.id == PARSE_ERROR_RULE).unwrap();
+ assert!(parse_error.fixed);
+ assert_eq!(parse_error.level, RuleLevel::Error);
+ }
+
+ #[test]
+ fn lists_rules_sorted_with_effective_levels() {
+ let linter = Linter::new(LintOptions {
+ use_settings: false,
+ rule_levels: vec!["duplicate-resource=info".parse().unwrap()],
+ ..Default::default()
+ }).unwrap();
+ let rules = linter.rules();
+ let ids: Vec<&str> = rules.iter().map(|rule| rule.id.as_str()).collect();
+ let mut sorted = ids.clone();
+ sorted.sort_unstable();
+ assert_eq!(ids, sorted);
+ let duplicate = rules.iter().find(|rule| rule.id == "duplicate-resource").unwrap();
+ assert_eq!(duplicate.default_level, RuleLevel::Error);
+ assert_eq!(duplicate.level, RuleLevel::Info);
+ assert!(!duplicate.fixed);
+ assert!(rules.iter().any(|rule| rule.tier == RuleTier::Resource));
+ }
+
+ #[test]
+ fn invalid_supplied_parameters_are_an_error() {
+ let result = Linter::new(LintOptions {
+ discovery: false,
+ use_settings: false,
+ parameters: Some(json!({"not": "parameters"})),
+ ..Default::default()
+ }).unwrap().lint(CLEAN);
+ assert!(matches!(result, Err(DscError::Lint(LintError::InvalidParameters(_)))));
+ }
+
+ #[test]
+ fn records_the_file_on_diagnostics() {
+ let result = lint_with("resources: []\n", LintOptions {
+ discovery: false,
+ use_settings: false,
+ file: Some("config.yaml".to_string()),
+ ..Default::default()
+ });
+ assert!(result.diagnostics.iter().all(|diagnostic| diagnostic.file.as_deref() == Some("config.yaml")));
+ }
+
+ #[test]
+ fn during_deployment_discovery_skips_the_resource_tier() {
+ let result = lint_with(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+directives:
+ resourceDiscovery: duringDeployment
+resources:
+- name: a
+ type: Not.Installed/Anywhere
+"#, LintOptions {
+ use_settings: false,
+ ..Default::default()
+ });
+ assert!(!result.discovery);
+ assert!(rule_paths(&result, "unknown-resource-type").is_empty());
+ }
+}
diff --git a/lib/dsc-lib/src/lint/options.rs b/lib/dsc-lib/src/lint/options.rs
new file mode 100644
index 000000000..6acaae222
--- /dev/null
+++ b/lib/dsc-lib/src/lint/options.rs
@@ -0,0 +1,220 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use std::{collections::HashMap, fmt::Display, path::PathBuf, str::FromStr};
+
+use clap::ValueEnum;
+use rust_i18n::t;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::{progress::ProgressFormat, schemas::transforms::idiomaticize_string_enum, types::SemanticVersion};
+
+use super::{diagnostic::LintLevel, LintError};
+
+/// The kind of DSC document being linted.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DocumentKind {
+ /// A configuration document.
+ Configuration,
+ /// A resource manifest.
+ ResourceManifest,
+ /// An extension manifest.
+ ExtensionManifest,
+}
+
+impl Display for DocumentKind {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let text = match self {
+ DocumentKind::Configuration => t!("lint.options.documentKindConfiguration"),
+ DocumentKind::ResourceManifest => t!("lint.options.documentKindResourceManifest"),
+ DocumentKind::ExtensionManifest => t!("lint.options.documentKindExtensionManifest"),
+ };
+ write!(f, "{text}")
+ }
+}
+
+/// The level a rule reports at, or `off` to disable the rule.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema, ValueEnum)]
+#[serde(rename_all = "camelCase")]
+#[schemars(inline, transform = idiomaticize_string_enum)]
+pub enum RuleLevel {
+ /// The rule doesn't run.
+ Off,
+ /// The rule reports at the info level.
+ Info,
+ /// The rule reports at the warning level.
+ Warning,
+ /// The rule reports at the error level.
+ Error,
+}
+
+impl RuleLevel {
+ /// The diagnostic level for this rule level, or `None` when the rule is off.
+ #[must_use]
+ pub fn as_lint_level(self) -> Option {
+ match self {
+ RuleLevel::Off => None,
+ RuleLevel::Info => Some(LintLevel::Info),
+ RuleLevel::Warning => Some(LintLevel::Warning),
+ RuleLevel::Error => Some(LintLevel::Error),
+ }
+ }
+}
+
+impl From for RuleLevel {
+ fn from(level: LintLevel) -> Self {
+ match level {
+ LintLevel::Info => RuleLevel::Info,
+ LintLevel::Warning => RuleLevel::Warning,
+ LintLevel::Error => RuleLevel::Error,
+ }
+ }
+}
+
+impl Display for RuleLevel {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let text = match self {
+ RuleLevel::Off => "off",
+ RuleLevel::Info => "info",
+ RuleLevel::Warning => "warning",
+ RuleLevel::Error => "error",
+ };
+ write!(f, "{text}")
+ }
+}
+
+impl FromStr for RuleLevel {
+ type Err = LintError;
+
+ fn from_str(text: &str) -> Result {
+ match text.trim().to_ascii_lowercase().as_str() {
+ "off" => Ok(RuleLevel::Off),
+ "info" => Ok(RuleLevel::Info),
+ "warning" => Ok(RuleLevel::Warning),
+ "error" => Ok(RuleLevel::Error),
+ _ => Err(LintError::InvalidRuleLevel(text.to_string())),
+ }
+ }
+}
+
+/// Overrides the level of one rule, written as `=` on the command line.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RuleLevelOverride {
+ /// The identifier of the rule.
+ pub id: String,
+ /// The level the rule reports at.
+ pub level: RuleLevel,
+}
+
+impl FromStr for RuleLevelOverride {
+ type Err = LintError;
+
+ fn from_str(text: &str) -> Result {
+ let Some((id, level)) = text.split_once('=') else {
+ return Err(LintError::InvalidRuleOverride(text.to_string()));
+ };
+ let id = id.trim();
+ if id.is_empty() {
+ return Err(LintError::InvalidRuleOverride(text.to_string()));
+ }
+ Ok(Self {
+ id: id.to_string(),
+ level: level.parse()?,
+ })
+ }
+}
+
+impl Display for RuleLevelOverride {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}={}", self.id, self.level)
+ }
+}
+
+/// The `lint` section of the DSC settings file.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+pub struct LintSettings {
+ /// Per-rule settings keyed by rule identifier.
+ #[serde(default)]
+ pub rules: HashMap,
+}
+
+/// The settings for one rule in the DSC settings file.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
+#[serde(deny_unknown_fields)]
+pub struct RuleSetting {
+ /// The level the rule reports at.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub level: Option,
+}
+
+/// Options that control a lint run.
+#[derive(Debug, Clone)]
+pub struct LintOptions {
+ /// The kind of document; detected from `$schema` when `None`.
+ pub document_kind: Option,
+ /// Whether to discover installed resources and run the rules that inspect them.
+ pub discovery: bool,
+ /// Whether to read rule levels from the DSC settings files.
+ pub use_settings: bool,
+ /// Rule level overrides, applied after the settings file and before the policy file.
+ pub rule_levels: Vec,
+ /// Parameter values that would be passed to the configuration, in either input format that
+ /// `dsc config` accepts.
+ pub parameters: Option,
+ /// The system root the configuration would be applied with, if not the running OS.
+ pub system_root: Option,
+ /// The DSC version to check `directives.version` against.
+ pub dsc_version: Option,
+ /// How to report progress during resource discovery.
+ pub progress_format: ProgressFormat,
+ /// The file the document was read from, recorded on each diagnostic.
+ pub file: Option,
+}
+
+impl Default for LintOptions {
+ fn default() -> Self {
+ Self {
+ document_kind: None,
+ discovery: true,
+ use_settings: true,
+ rule_levels: Vec::new(),
+ parameters: None,
+ system_root: None,
+ dsc_version: None,
+ progress_format: ProgressFormat::None,
+ file: None,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use test_case::test_case;
+
+ #[test_case("no-unused-parameters=off", "no-unused-parameters", RuleLevel::Off; "off")]
+ #[test_case("duplicate-resource = Warning", "duplicate-resource", RuleLevel::Warning; "spaces and case")]
+ #[test_case("x=ERROR", "x", RuleLevel::Error; "error")]
+ fn parses_rule_overrides(text: &str, id: &str, level: RuleLevel) {
+ let parsed: RuleLevelOverride = text.parse().unwrap();
+ assert_eq!(parsed, RuleLevelOverride { id: id.to_string(), level });
+ }
+
+ #[test_case("no-equals"; "missing separator")]
+ #[test_case("=off"; "missing id")]
+ #[test_case("rule=loud"; "unknown level")]
+ fn rejects_invalid_rule_overrides(text: &str) {
+ assert!(text.parse::().is_err());
+ }
+
+ #[test]
+ fn settings_deserialize() {
+ let settings: LintSettings = serde_json::from_value(serde_json::json!({
+ "rules": { "no-unused-variables": { "level": "info" } }
+ })).unwrap();
+ assert_eq!(settings.rules["no-unused-variables"].level, Some(RuleLevel::Info));
+ }
+}
diff --git a/lib/dsc-lib/src/lint/pointer.rs b/lib/dsc-lib/src/lint/pointer.rs
new file mode 100644
index 000000000..2724f7df9
--- /dev/null
+++ b/lib/dsc-lib/src/lint/pointer.rs
@@ -0,0 +1,165 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use std::fmt::Display;
+
+use serde_json::Value;
+
+/// A JSON pointer (RFC 6901) that identifies a location in a document.
+///
+/// The empty pointer identifies the document root.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct JsonPointer(String);
+
+impl JsonPointer {
+ /// The pointer to the document root.
+ #[must_use]
+ pub fn root() -> Self {
+ Self(String::new())
+ }
+
+ /// Create a pointer from an existing pointer string, such as one produced by a JSON Schema
+ /// validator.
+ ///
+ /// # Arguments
+ ///
+ /// * `pointer` - The pointer text, which must be empty or start with `/`.
+ #[must_use]
+ pub fn from_pointer(pointer: &str) -> Self {
+ Self(pointer.to_string())
+ }
+
+ /// Return a pointer to the member `key` of the object at this pointer.
+ ///
+ /// # Arguments
+ ///
+ /// * `key` - The member name; `~` and `/` are escaped as the RFC requires.
+ #[must_use]
+ pub fn push(&self, key: &str) -> Self {
+ let escaped = key.replace('~', "~0").replace('/', "~1");
+ Self(format!("{}/{escaped}", self.0))
+ }
+
+ /// Return a pointer to the element `index` of the array at this pointer.
+ ///
+ /// # Arguments
+ ///
+ /// * `index` - The zero-based element index.
+ #[must_use]
+ pub fn push_index(&self, index: usize) -> Self {
+ Self(format!("{}/{index}", self.0))
+ }
+
+ /// Return a pointer that appends another pointer to this one.
+ ///
+ /// # Arguments
+ ///
+ /// * `other` - The pointer text to append, which must be empty or start with `/`.
+ #[must_use]
+ pub fn join(&self, other: &str) -> Self {
+ Self(format!("{}{other}", self.0))
+ }
+
+ /// The pointer text.
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+
+ /// Whether this pointer identifies `other` or a location below it.
+ ///
+ /// # Arguments
+ ///
+ /// * `other` - The pointer to compare against.
+ #[must_use]
+ pub fn starts_with(&self, other: &JsonPointer) -> bool {
+ self.0 == other.0 || (self.0.starts_with(&other.0) && self.0[other.0.len()..].starts_with('/'))
+ }
+
+ /// Resolve the pointer against a document.
+ ///
+ /// # Arguments
+ ///
+ /// * `document` - The document to look in.
+ ///
+ /// # Returns
+ ///
+ /// The value at this pointer, or `None` when the document has no such location.
+ #[must_use]
+ pub fn resolve<'a>(&self, document: &'a Value) -> Option<&'a Value> {
+ document.pointer(&self.0)
+ }
+}
+
+impl Display for JsonPointer {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+/// Call `visit` for every string value in a document.
+///
+/// # Arguments
+///
+/// * `value` - The value to walk.
+/// * `pointer` - The pointer to `value`.
+/// * `visit` - Called with the pointer and text of each string found.
+pub fn for_each_string(value: &Value, pointer: &JsonPointer, visit: &mut dyn FnMut(&JsonPointer, &str)) {
+ match value {
+ Value::String(text) => visit(pointer, text),
+ Value::Array(items) => {
+ for (index, item) in items.iter().enumerate() {
+ for_each_string(item, &pointer.push_index(index), visit);
+ }
+ },
+ Value::Object(members) => {
+ for (key, member) in members {
+ for_each_string(member, &pointer.push(key), visit);
+ }
+ },
+ _ => {},
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ #[test]
+ fn push_escapes_special_characters() {
+ let pointer = JsonPointer::root().push("a/b").push("c~d");
+ assert_eq!(pointer.as_str(), "/a~1b/c~0d");
+ }
+
+ #[test]
+ fn push_index_and_join() {
+ let pointer = JsonPointer::root().push("resources").push_index(2).join("/properties/path");
+ assert_eq!(pointer.to_string(), "/resources/2/properties/path");
+ }
+
+ #[test]
+ fn starts_with_matches_self_and_descendants_only() {
+ let base = JsonPointer::root().push("resources").push_index(1);
+ assert!(base.starts_with(&base));
+ assert!(base.push("name").starts_with(&base));
+ assert!(base.starts_with(&JsonPointer::root()));
+ assert!(!JsonPointer::root().push("resources").push_index(10).starts_with(&base));
+ }
+
+ #[test]
+ fn resolve_finds_values() {
+ let document = json!({"resources": [{"name": "first"}]});
+ let pointer = JsonPointer::root().push("resources").push_index(0).push("name");
+ assert_eq!(pointer.resolve(&document), Some(&json!("first")));
+ assert_eq!(JsonPointer::root().push("missing").resolve(&document), None);
+ }
+
+ #[test]
+ fn for_each_string_visits_nested_strings() {
+ let document = json!({"a": ["x", {"b": "y"}], "c": 1});
+ let mut found = Vec::new();
+ for_each_string(&document, &JsonPointer::root(), &mut |pointer, text| found.push((pointer.to_string(), text.to_string())));
+ assert_eq!(found, vec![("/a/0".to_string(), "x".to_string()), ("/a/1/b".to_string(), "y".to_string())]);
+ }
+}
diff --git a/lib/dsc-lib/src/lint/rule.rs b/lib/dsc-lib/src/lint/rule.rs
new file mode 100644
index 000000000..da6f06882
--- /dev/null
+++ b/lib/dsc-lib/src/lint/rule.rs
@@ -0,0 +1,158 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+use crate::schemas::{dsc_repo::DscRepoSchema, transforms::idiomaticize_string_enum};
+
+use super::{
+ context::LintContext,
+ diagnostic::{LintCategory, LintDiagnostic, LintLevel, SourceLocation},
+ options::RuleLevel,
+ pointer::JsonPointer,
+};
+
+/// When a rule can run.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+#[schemars(inline, transform = idiomaticize_string_enum)]
+pub enum RuleTier {
+ /// The rule only needs the document.
+ Static,
+ /// The rule needs the installed resources, so it only runs when discovery is enabled.
+ Resource,
+}
+
+impl std::fmt::Display for RuleTier {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let text = match self {
+ RuleTier::Static => "static",
+ RuleTier::Resource => "resource",
+ };
+ write!(f, "{text}")
+ }
+}
+
+/// The signature of a rule's check function.
+pub type CheckFn = fn(&LintContext<'_>, &mut DiagnosticSink<'_>);
+
+/// A lint rule.
+pub struct Rule {
+ /// The kebab-case identifier of the rule, such as `duplicate-resource`.
+ pub id: &'static str,
+ /// The area of the document the rule inspects.
+ pub category: LintCategory,
+ /// When the rule can run.
+ pub tier: RuleTier,
+ /// The level the rule reports at unless overridden.
+ pub default_level: RuleLevel,
+ /// Returns the localized description of what the rule checks.
+ pub description: fn() -> String,
+ /// Runs the rule against a document, reporting problems to the sink.
+ pub check: CheckFn,
+}
+
+/// Describes a rule and the level it is configured to report at.
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, DscRepoSchema)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+#[dsc_repo_schema(base_name = "rules", folder_path = "outputs/lint")]
+#[schemars(
+ transform = LintRuleInfo::transform_export_schema_uris,
+ transform = LintRuleInfo::transform_schema_docs
+)]
+pub struct LintRuleInfo {
+ /// The identifier of the rule.
+ pub id: String,
+ /// The area of the document the rule inspects.
+ pub category: LintCategory,
+ /// When the rule can run.
+ pub tier: RuleTier,
+ /// The level the rule reports at unless overridden.
+ pub default_level: RuleLevel,
+ /// The level the rule is configured to report at.
+ pub level: RuleLevel,
+ /// Whether the level of the rule can't be changed.
+ pub fixed: bool,
+ /// What the rule checks.
+ pub description: String,
+}
+
+impl LintRuleInfo {
+ /// Describe a rule at its configured level.
+ ///
+ /// # Arguments
+ ///
+ /// * `rule` - The rule.
+ /// * `level` - The level the rule is configured to report at.
+ #[must_use]
+ pub fn from_rule(rule: &Rule, level: RuleLevel) -> Self {
+ Self {
+ id: rule.id.to_string(),
+ category: rule.category,
+ tier: rule.tier,
+ default_level: rule.default_level,
+ level,
+ fixed: false,
+ description: (rule.description)(),
+ }
+ }
+}
+
+/// Collects the diagnostics one rule reports, stamping each with the rule's identity and level
+/// so a rule can't report under another name.
+pub struct DiagnosticSink<'a> {
+ rule: &'static str,
+ category: LintCategory,
+ level: LintLevel,
+ diagnostics: &'a mut Vec,
+}
+
+impl<'a> DiagnosticSink<'a> {
+ /// Create a sink for one rule.
+ ///
+ /// # Arguments
+ ///
+ /// * `rule` - The identifier of the rule that reports through this sink.
+ /// * `category` - The category of the rule.
+ /// * `level` - The level the rule reports at.
+ /// * `diagnostics` - Where reported diagnostics are appended.
+ #[must_use]
+ pub fn new(rule: &'static str, category: LintCategory, level: LintLevel, diagnostics: &'a mut Vec) -> Self {
+ Self {
+ rule,
+ category,
+ level,
+ diagnostics,
+ }
+ }
+
+ /// Report a problem at a location in the document.
+ ///
+ /// # Arguments
+ ///
+ /// * `path` - The location of the problem.
+ /// * `message` - A description of the problem.
+ pub fn report(&mut self, path: &JsonPointer, message: String) {
+ self.report_at(path, None, message);
+ }
+
+ /// Report a problem at a location in the document with a position in the source text.
+ ///
+ /// # Arguments
+ ///
+ /// * `path` - The location of the problem.
+ /// * `location` - The position in the source text, when known.
+ /// * `message` - A description of the problem.
+ pub fn report_at(&mut self, path: &JsonPointer, location: Option, message: String) {
+ self.diagnostics.push(LintDiagnostic {
+ level: self.level,
+ rule: self.rule.to_string(),
+ category: self.category,
+ message,
+ path: path.to_string(),
+ file: None,
+ location,
+ });
+ }
+}
diff --git a/lib/dsc-lib/src/lint/rules/dependency.rs b/lib/dsc-lib/src/lint/rules/dependency.rs
new file mode 100644
index 000000000..678a95b77
--- /dev/null
+++ b/lib/dsc-lib/src/lint/rules/dependency.rs
@@ -0,0 +1,438 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Rules about the dependencies between resources.
+
+use std::collections::{HashMap, HashSet};
+
+use rust_i18n::t;
+use serde_json::Value;
+
+use crate::{
+ lint::{
+ context::LintContext,
+ diagnostic::LintCategory,
+ expression_index::ExpressionScope,
+ options::RuleLevel,
+ pointer::JsonPointer,
+ rule::{DiagnosticSink, Rule, RuleTier},
+ },
+ parser::{expressions::Expression, functions::FunctionArg, ParsedStatement},
+};
+
+/// The rules in this module.
+#[must_use]
+pub fn rules() -> Vec {
+ vec![
+ Rule {
+ id: "unresolved-dependency",
+ category: LintCategory::Dependency,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.dependency.unresolvedDependencyDescription").to_string(),
+ check: unresolved_dependency,
+ },
+ Rule {
+ id: "circular-dependency",
+ category: LintCategory::Dependency,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.dependency.circularDependencyDescription").to_string(),
+ check: circular_dependency,
+ },
+ Rule {
+ id: "reference-without-depends-on",
+ category: LintCategory::Dependency,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.dependency.referenceWithoutDependsOnDescription").to_string(),
+ check: reference_without_depends_on,
+ },
+ Rule {
+ id: "duplicate-depends-on",
+ category: LintCategory::Dependency,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Info,
+ description: || t!("lint.rules.dependency.duplicateDependsOnDescription").to_string(),
+ check: duplicate_depends_on,
+ },
+ ]
+}
+
+/// Identifies a resource by type and name, as `resourceId()` does.
+#[derive(Clone, Debug)]
+struct ResourceKey {
+ /// The resource type as written; compared case-insensitively.
+ type_name: String,
+ /// The resource name; compared exactly.
+ name: String,
+}
+
+impl ResourceKey {
+ /// Whether this key identifies the same resource as another.
+ ///
+ /// # Arguments
+ ///
+ /// * `other` - The key to compare with.
+ fn same_as(&self, other: &ResourceKey) -> bool {
+ self.name == other.name && self.type_name.eq_ignore_ascii_case(&other.type_name)
+ }
+
+ /// The key as `type/name`, for messages.
+ fn display(&self) -> String {
+ format!("{} named '{}'", self.type_name, self.name)
+ }
+}
+
+/// How a dependency key resolved against the sibling resources.
+enum Resolution {
+ /// The resource at the given index matches by name and type.
+ Found(usize),
+ /// A resource at the given index has the name but a different type.
+ TypeMismatch(usize),
+ /// No sibling has the name.
+ NotFound,
+}
+
+/// The key of a `resourceId('type', 'name')` expression with literal arguments and no accessors.
+///
+/// # Arguments
+///
+/// * `expression` - The expression.
+fn resource_id_key(expression: &Expression) -> Option {
+ if !expression.accessors().is_empty() || expression.function().name() != "resourceId" {
+ return None;
+ }
+ let args = expression.function().args()?;
+ match args {
+ [FunctionArg::Value(Value::String(type_name)), FunctionArg::Value(Value::String(name))] => Some(ResourceKey {
+ type_name: type_name.clone(),
+ name: name.clone(),
+ }),
+ _ => None,
+ }
+}
+
+/// The literal `dependsOn` entries of a resource with their pointers.
+///
+/// Entries that aren't a literal `resourceId()` call are skipped; the schema already rejects
+/// entries that aren't `resourceId()` expressions.
+///
+/// # Arguments
+///
+/// * `context` - The lint context.
+/// * `index` - The index of the resource in the context's resource list.
+fn literal_dependencies(context: &LintContext, index: usize) -> Vec<(JsonPointer, ResourceKey)> {
+ let site = &context.resources[index];
+ let Some(depends_on) = &site.resource.depends_on else {
+ return Vec::new();
+ };
+ let base = site.pointer.push("dependsOn");
+ (0..depends_on.len()).filter_map(|position| {
+ let pointer = base.push_index(position);
+ let parsed = context.expressions.site_at(&pointer)?.parsed.as_ref().ok()?;
+ let ParsedStatement::Expression(expression) = parsed else {
+ return None;
+ };
+ resource_id_key(expression).map(|key| (pointer, key))
+ }).collect()
+}
+
+/// The resources a resource references through `reference(resourceId('type', 'name'))`, with
+/// the pointer of each referencing expression.
+///
+/// # Arguments
+///
+/// * `context` - The lint context.
+/// * `index` - The index of the resource in the context's resource list.
+fn reference_targets(context: &LintContext, index: usize) -> Vec<(JsonPointer, ResourceKey)> {
+ context.expressions.calls_named("reference").into_iter()
+ .filter(|call| call.site.scope == ExpressionScope::Resource(index))
+ .filter_map(|call| {
+ let Some([FunctionArg::Expression(argument), ..]) = call.function.args() else {
+ return None;
+ };
+ resource_id_key(argument).map(|key| (call.site.pointer.clone(), key))
+ })
+ .collect()
+}
+
+/// Resolve a dependency key against a group of sibling resources.
+///
+/// # Arguments
+///
+/// * `context` - The lint context.
+/// * `group` - The indexes of the sibling resources.
+/// * `key` - The dependency key.
+fn resolve(context: &LintContext, group: &[usize], key: &ResourceKey) -> Resolution {
+ let by_name: Vec = group.iter().copied().filter(|&index| context.resources[index].resource.name == key.name).collect();
+ if let Some(&index) = by_name.iter().find(|&&index| context.resources[index].resource.resource_type.as_ref().eq_ignore_ascii_case(&key.type_name)) {
+ return Resolution::Found(index);
+ }
+ match by_name.first() {
+ Some(&index) => Resolution::TypeMismatch(index),
+ None => Resolution::NotFound,
+ }
+}
+
+/// Report `dependsOn` entries and `reference()` calls whose target isn't a sibling resource.
+///
+/// Resources in copy loops are skipped, and a missing target isn't reported when a sibling is
+/// a copy loop, because copy loops produce names that are only known at runtime.
+fn unresolved_dependency(context: &LintContext, sink: &mut DiagnosticSink) {
+ for group in context.sibling_groups() {
+ let has_copy = group.iter().any(|&index| context.resources[index].resource.copy.is_some());
+ for &index in &group {
+ let site = &context.resources[index];
+ if site.resource.copy.is_some() {
+ continue;
+ }
+ for (pointer, key) in literal_dependencies(context, index).into_iter().chain(reference_targets(context, index)) {
+ match resolve(context, &group, &key) {
+ Resolution::Found(_) => {},
+ Resolution::TypeMismatch(actual) => sink.report(
+ &pointer,
+ t!(
+ "lint.rules.dependency.dependencyTypeMismatch",
+ name = site.resource.name,
+ dependency_type = key.type_name,
+ dependency_name = key.name,
+ actual_type = context.resources[actual].resource.resource_type
+ ).to_string(),
+ ),
+ Resolution::NotFound => {
+ if !has_copy {
+ sink.report(
+ &pointer,
+ t!(
+ "lint.rules.dependency.unresolvedDependency",
+ name = site.resource.name,
+ dependency_type = key.type_name,
+ dependency_name = key.name
+ ).to_string(),
+ );
+ }
+ },
+ }
+ }
+ }
+ }
+}
+
+/// Report resources whose `dependsOn` entries form a cycle.
+fn circular_dependency(context: &LintContext, sink: &mut DiagnosticSink) {
+ for group in context.sibling_groups() {
+ let mut edges: HashMap> = HashMap::new();
+ for &index in &group {
+ if context.resources[index].resource.copy.is_some() {
+ continue;
+ }
+ let targets = literal_dependencies(context, index).into_iter().filter_map(|(_, key)| match resolve(context, &group, &key) {
+ Resolution::Found(target) => Some(target),
+ _ => None,
+ }).collect();
+ edges.insert(index, targets);
+ }
+
+ let mut reported: HashSet> = HashSet::new();
+ for &start in &group {
+ let Some(cycle) = find_cycle(start, &edges) else {
+ continue;
+ };
+ let mut members = cycle.clone();
+ members.sort_unstable();
+ if !reported.insert(members) {
+ continue;
+ }
+ let site = &context.resources[start];
+ let path: Vec<&str> = cycle.iter().chain(std::iter::once(&start)).map(|&index| context.resources[index].resource.name.as_str()).collect();
+ sink.report(
+ &site.pointer.push("dependsOn"),
+ t!("lint.rules.dependency.circularDependency", name = site.resource.name, cycle = path.join(" -> ")).to_string(),
+ );
+ }
+ }
+}
+
+/// Find a dependency path that leads from a resource back to itself.
+///
+/// # Arguments
+///
+/// * `start` - The resource to start from.
+/// * `edges` - The resolved dependencies of each resource.
+///
+/// # Returns
+///
+/// The resources on the cycle, starting with `start`, or `None` when there is no cycle.
+fn find_cycle(start: usize, edges: &HashMap>) -> Option> {
+ let mut path = Vec::new();
+ let mut visited = HashSet::new();
+ if walk(start, start, edges, &mut path, &mut visited) { Some(path) } else { None }
+}
+
+/// Depth-first search for a path from `node` back to `start`.
+///
+/// # Arguments
+///
+/// * `node` - The current resource.
+/// * `start` - The resource the search is looking for.
+/// * `edges` - The resolved dependencies of each resource.
+/// * `path` - The resources on the current path; holds the cycle when the search succeeds.
+/// * `visited` - The resources already explored.
+fn walk(node: usize, start: usize, edges: &HashMap>, path: &mut Vec, visited: &mut HashSet) -> bool {
+ path.push(node);
+ visited.insert(node);
+ for &next in edges.get(&node).map(Vec::as_slice).unwrap_or(&[]) {
+ if next == start {
+ return true;
+ }
+ if !visited.contains(&next) && walk(next, start, edges, path, visited) {
+ return true;
+ }
+ }
+ path.pop();
+ false
+}
+
+/// Report `reference()` calls to resources that aren't listed in `dependsOn`.
+fn reference_without_depends_on(context: &LintContext, sink: &mut DiagnosticSink) {
+ for index in 0..context.resources.len() {
+ let site = &context.resources[index];
+ let dependencies = literal_dependencies(context, index);
+ for (pointer, target) in reference_targets(context, index) {
+ if !dependencies.iter().any(|(_, dependency)| dependency.same_as(&target)) {
+ sink.report(
+ &pointer,
+ t!("lint.rules.dependency.referenceWithoutDependsOn", name = site.resource.name, target = target.display()).to_string(),
+ );
+ }
+ }
+ }
+}
+
+/// Report `dependsOn` entries that repeat an earlier entry.
+fn duplicate_depends_on(context: &LintContext, sink: &mut DiagnosticSink) {
+ for index in 0..context.resources.len() {
+ let site = &context.resources[index];
+ let mut seen: Vec = Vec::new();
+ for (pointer, key) in literal_dependencies(context, index) {
+ if seen.iter().any(|earlier| earlier.same_as(&key)) {
+ sink.report(
+ &pointer,
+ t!("lint.rules.dependency.duplicateDependsOn", name = site.resource.name, target = key.display()).to_string(),
+ );
+ } else {
+ seen.push(key);
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::lint::test_util::{lint_yaml, rule_messages, rule_paths};
+
+ #[test]
+ fn reports_unresolved_and_mismatched_dependencies() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: first
+ type: Microsoft.DSC.Debug/Echo
+- name: second
+ type: Microsoft.DSC.Debug/Echo
+ dependsOn:
+ - "[resourceId('microsoft.dsc.debug/echo', 'first')]"
+ - "[resourceId('Microsoft.DSC.Debug/Echo', 'missing')]"
+ - "[resourceId('Microsoft/OSInfo', 'first')]"
+ properties:
+ output: "[reference(resourceId('Microsoft.DSC.Debug/Echo', 'nobody'))]"
+"#);
+ assert_eq!(
+ rule_paths(&result, "unresolved-dependency"),
+ vec!["/resources/1/dependsOn/1", "/resources/1/dependsOn/2", "/resources/1/properties/output"]
+ );
+ assert!(rule_messages(&result, "unresolved-dependency")[1].contains("Microsoft.DSC.Debug/Echo"));
+ }
+
+ #[test]
+ fn copy_loops_suppress_unresolved_dependencies() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: "[concat('item', copyIndex())]"
+ type: Microsoft.DSC.Debug/Echo
+ copy:
+ name: items
+ count: 2
+- name: consumer
+ type: Microsoft.DSC.Debug/Echo
+ dependsOn:
+ - "[resourceId('Microsoft.DSC.Debug/Echo', 'item0')]"
+"#);
+ assert!(rule_paths(&result, "unresolved-dependency").is_empty());
+ }
+
+ #[test]
+ fn reports_cycles_once() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ dependsOn:
+ - "[resourceId('Microsoft.DSC.Debug/Echo', 'b')]"
+- name: b
+ type: Microsoft.DSC.Debug/Echo
+ dependsOn:
+ - "[resourceId('Microsoft.DSC.Debug/Echo', 'a')]"
+- name: self
+ type: Microsoft.DSC.Debug/Echo
+ dependsOn:
+ - "[resourceId('Microsoft.DSC.Debug/Echo', 'self')]"
+- name: fine
+ type: Microsoft.DSC.Debug/Echo
+ dependsOn:
+ - "[resourceId('Microsoft.DSC.Debug/Echo', 'a')]"
+"#);
+ assert_eq!(rule_paths(&result, "circular-dependency"), vec!["/resources/0/dependsOn", "/resources/2/dependsOn"]);
+ assert!(rule_messages(&result, "circular-dependency")[0].contains("a -> b -> a"));
+ }
+
+ #[test]
+ fn reports_references_without_depends_on() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: source
+ type: Microsoft.DSC.Debug/Echo
+- name: listed
+ type: Microsoft.DSC.Debug/Echo
+ dependsOn:
+ - "[resourceId('Microsoft.DSC.Debug/Echo', 'source')]"
+ properties:
+ output: "[reference(resourceId('Microsoft.DSC.Debug/Echo', 'source')).output]"
+- name: unlisted
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "[reference(resourceId('Microsoft.DSC.Debug/Echo', 'source')).output]"
+"#);
+ assert_eq!(rule_paths(&result, "reference-without-depends-on"), vec!["/resources/2/properties/output"]);
+ }
+
+ #[test]
+ fn reports_duplicate_depends_on_entries() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: source
+ type: Microsoft.DSC.Debug/Echo
+- name: twice
+ type: Microsoft.DSC.Debug/Echo
+ dependsOn:
+ - "[resourceId('Microsoft.DSC.Debug/Echo', 'source')]"
+ - "[resourceId('Microsoft.DSC.Debug/Echo', 'source')]"
+"#);
+ assert_eq!(rule_paths(&result, "duplicate-depends-on"), vec!["/resources/1/dependsOn/1"]);
+ }
+}
diff --git a/lib/dsc-lib/src/lint/rules/deprecation.rs b/lib/dsc-lib/src/lint/rules/deprecation.rs
new file mode 100644
index 000000000..070bdec7b
--- /dev/null
+++ b/lib/dsc-lib/src/lint/rules/deprecation.rs
@@ -0,0 +1,206 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Rules about features DSC has deprecated or doesn't support.
+
+use rust_i18n::t;
+
+use crate::{
+ configure::config_doc::ValueOrCopy,
+ lint::{
+ context::LintContext,
+ diagnostic::LintCategory,
+ options::RuleLevel,
+ pointer::JsonPointer,
+ rule::{DiagnosticSink, Rule, RuleTier},
+ },
+};
+
+/// The rules in this module.
+#[must_use]
+pub fn rules() -> Vec {
+ vec![
+ Rule {
+ id: "copy-loop-deprecated",
+ category: LintCategory::Deprecation,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.deprecation.copyLoopDeprecatedDescription").to_string(),
+ check: copy_loop_deprecated,
+ },
+ Rule {
+ id: "copy-options-unsupported",
+ category: LintCategory::Deprecation,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.deprecation.copyOptionsUnsupportedDescription").to_string(),
+ check: copy_options_unsupported,
+ },
+ Rule {
+ id: "security-context-in-metadata",
+ category: LintCategory::Deprecation,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.deprecation.securityContextInMetadataDescription").to_string(),
+ check: security_context_in_metadata,
+ },
+ Rule {
+ id: "security-context-conflict",
+ category: LintCategory::Deprecation,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.deprecation.securityContextConflictDescription").to_string(),
+ check: security_context_conflict,
+ },
+ Rule {
+ id: "dsc-version-unsatisfied",
+ category: LintCategory::Deprecation,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.deprecation.dscVersionUnsatisfiedDescription").to_string(),
+ check: dsc_version_unsatisfied,
+ },
+ ]
+}
+
+/// Report copy loops on resources and outputs.
+fn copy_loop_deprecated(context: &LintContext, sink: &mut DiagnosticSink) {
+ for site in &context.resources {
+ if let Some(copy) = &site.resource.copy {
+ sink.report(&site.pointer.push("copy"), t!("lint.rules.deprecation.copyLoopDeprecated", name = copy.name).to_string());
+ }
+ }
+ if let Some(outputs) = &context.config.outputs {
+ for (name, output) in outputs {
+ if let ValueOrCopy::Copy(copy) = &output.value_or_copy {
+ sink.report(
+ &JsonPointer::root().push("outputs").push(name).push("copy"),
+ t!("lint.rules.deprecation.copyLoopDeprecated", name = copy.name).to_string(),
+ );
+ }
+ }
+ }
+}
+
+/// Report copy loop options that DSC rejects.
+fn copy_options_unsupported(context: &LintContext, sink: &mut DiagnosticSink) {
+ for site in &context.resources {
+ let Some(copy) = &site.resource.copy else {
+ continue;
+ };
+ let base = site.pointer.push("copy");
+ if copy.mode.is_some() {
+ sink.report(&base.push("mode"), t!("lint.rules.deprecation.copyOptionsUnsupported", option = "mode").to_string());
+ }
+ if copy.batch_size.is_some() {
+ sink.report(&base.push("batchSize"), t!("lint.rules.deprecation.copyOptionsUnsupported", option = "batchSize").to_string());
+ }
+ }
+}
+
+/// Report a security context set through `Microsoft.DSC` metadata.
+fn security_context_in_metadata(context: &LintContext, sink: &mut DiagnosticSink) {
+ if metadata_security_context(context).is_some() {
+ sink.report(
+ &JsonPointer::root().push("metadata").push("Microsoft.DSC").push("securityContext"),
+ t!("lint.rules.deprecation.securityContextInMetadata").to_string(),
+ );
+ }
+}
+
+/// Report a security context in metadata that contradicts the directive.
+fn security_context_conflict(context: &LintContext, sink: &mut DiagnosticSink) {
+ let directive = context.config.directives.as_ref().and_then(|directives| directives.security_context.as_ref());
+ if let (Some(metadata), Some(directive)) = (metadata_security_context(context), directive)
+ && metadata != directive {
+ sink.report(
+ &JsonPointer::root().push("directives").push("securityContext"),
+ t!("lint.rules.deprecation.securityContextConflict", metadata = metadata, directive = directive).to_string(),
+ );
+ }
+}
+
+/// Report a `directives.version` requirement the linting DSC doesn't satisfy.
+fn dsc_version_unsatisfied(context: &LintContext, sink: &mut DiagnosticSink) {
+ let Some(current) = &context.options.dsc_version else {
+ return;
+ };
+ let Some(required) = context.config.directives.as_ref().and_then(|directives| directives.version.as_ref()) else {
+ return;
+ };
+ if !required.matches(current) {
+ sink.report(
+ &JsonPointer::root().push("directives").push("version"),
+ t!("lint.rules.deprecation.dscVersionUnsatisfied", required = required, current = current).to_string(),
+ );
+ }
+}
+
+/// The security context set through `Microsoft.DSC` metadata, if any.
+///
+/// # Arguments
+///
+/// * `context` - The lint context.
+fn metadata_security_context<'a>(context: &'a LintContext<'_>) -> Option<&'a crate::configure::config_doc::SecurityContextKind> {
+ context.config.metadata.as_ref()?.microsoft.as_ref()?.security_context.as_ref()
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::{lint::{options::LintOptions, test_util::{lint_with, lint_yaml, rule_paths}}, types::SemanticVersion};
+
+ #[test]
+ fn reports_copy_loops_and_their_options() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+outputs:
+ items:
+ type: array
+ copy:
+ name: outer
+ count: 2
+resources:
+- name: "[concat('a', copyIndex())]"
+ type: Microsoft.DSC.Debug/Echo
+ copy:
+ name: loop
+ count: 2
+ mode: serial
+ batchSize: 1
+"#);
+ assert_eq!(rule_paths(&result, "copy-loop-deprecated"), vec!["/outputs/items/copy", "/resources/0/copy"]);
+ assert_eq!(rule_paths(&result, "copy-options-unsupported"), vec!["/resources/0/copy/batchSize", "/resources/0/copy/mode"]);
+ }
+
+ #[test]
+ fn reports_security_context_in_metadata_and_conflicts() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+metadata:
+ Microsoft.DSC:
+ securityContext: elevated
+directives:
+ securityContext: restricted
+resources: []
+"#);
+ assert_eq!(rule_paths(&result, "security-context-in-metadata"), vec!["/metadata/Microsoft.DSC/securityContext"]);
+ assert_eq!(rule_paths(&result, "security-context-conflict"), vec!["/directives/securityContext"]);
+ }
+
+ #[test]
+ fn reports_unsatisfied_dsc_version() {
+ let options = LintOptions {
+ discovery: false,
+ use_settings: false,
+ dsc_version: Some(SemanticVersion::parse("3.1.0").unwrap()),
+ ..Default::default()
+ };
+ let result = lint_with(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+directives:
+ version: ">=4.0.0"
+resources: []
+"#, options);
+ assert_eq!(rule_paths(&result, "dsc-version-unsatisfied"), vec!["/directives/version"]);
+ }
+}
diff --git a/lib/dsc-lib/src/lint/rules/document.rs b/lib/dsc-lib/src/lint/rules/document.rs
new file mode 100644
index 000000000..05820ecbf
--- /dev/null
+++ b/lib/dsc-lib/src/lint/rules/document.rs
@@ -0,0 +1,364 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Rules about the structure of a configuration document and its resource declarations.
+
+use rust_i18n::t;
+use serde_json::Value;
+
+use crate::{
+ lint::{
+ context::LintContext,
+ diagnostic::LintCategory,
+ options::RuleLevel,
+ pointer::JsonPointer,
+ rule::{DiagnosticSink, Rule, RuleTier},
+ },
+ types::ResourceVersionReq,
+};
+
+/// The keys DSC recognizes under `Microsoft.DSC` metadata.
+const MICROSOFT_DSC_METADATA_KEYS: [&str; 9] = [
+ "duration",
+ "endDatetime",
+ "executionType",
+ "operation",
+ "restartRequired",
+ "copyLoops",
+ "securityContext",
+ "startDatetime",
+ "version",
+];
+
+/// The rules in this module.
+#[must_use]
+pub fn rules() -> Vec {
+ vec![
+ Rule {
+ id: "duplicate-resource",
+ category: LintCategory::Document,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.document.duplicateResourceDescription").to_string(),
+ check: duplicate_resource,
+ },
+ Rule {
+ id: "duplicate-resource-name",
+ category: LintCategory::Document,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.document.duplicateResourceNameDescription").to_string(),
+ check: duplicate_resource_name,
+ },
+ Rule {
+ id: "empty-resources",
+ category: LintCategory::Document,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.document.emptyResourcesDescription").to_string(),
+ check: empty_resources,
+ },
+ Rule {
+ id: "unknown-microsoft-dsc-metadata",
+ category: LintCategory::Document,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.document.unknownMicrosoftDscMetadataDescription").to_string(),
+ check: unknown_microsoft_dsc_metadata,
+ },
+ Rule {
+ id: "api-version-alias",
+ category: LintCategory::Document,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Info,
+ description: || t!("lint.rules.document.apiVersionAliasDescription").to_string(),
+ check: api_version_alias,
+ },
+ Rule {
+ id: "date-version",
+ category: LintCategory::Document,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.document.dateVersionDescription").to_string(),
+ check: date_version,
+ },
+ Rule {
+ id: "missing-require-version",
+ category: LintCategory::Document,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Off,
+ description: || t!("lint.rules.document.missingRequireVersionDescription").to_string(),
+ check: missing_require_version,
+ },
+ ]
+}
+
+/// Report resources that share a name and a type with an earlier sibling.
+///
+/// Resources in copy loops are skipped because their names are only known at runtime.
+fn duplicate_resource(context: &LintContext, sink: &mut DiagnosticSink) {
+ for group in context.sibling_groups() {
+ for (position, &later) in group.iter().enumerate() {
+ let later_site = &context.resources[later];
+ if later_site.resource.copy.is_some() {
+ continue;
+ }
+ let duplicate = group[..position].iter().any(|&earlier| {
+ let earlier_site = &context.resources[earlier];
+ earlier_site.resource.copy.is_none()
+ && earlier_site.resource.name == later_site.resource.name
+ && earlier_site.resource.resource_type == later_site.resource.resource_type
+ });
+ if duplicate {
+ sink.report(
+ &later_site.pointer,
+ t!(
+ "lint.rules.document.duplicateResource",
+ name = later_site.resource.name,
+ type_name = later_site.resource.resource_type
+ ).to_string(),
+ );
+ }
+ }
+ }
+}
+
+/// Report resources that share a name with an earlier sibling of a different type.
+fn duplicate_resource_name(context: &LintContext, sink: &mut DiagnosticSink) {
+ for group in context.sibling_groups() {
+ for (position, &later) in group.iter().enumerate() {
+ let later_site = &context.resources[later];
+ if later_site.resource.copy.is_some() {
+ continue;
+ }
+ let same_name = group[..position].iter().map(|&earlier| &context.resources[earlier]).find(|earlier_site| {
+ earlier_site.resource.copy.is_none()
+ && earlier_site.resource.name == later_site.resource.name
+ && earlier_site.resource.resource_type != later_site.resource.resource_type
+ });
+ if let Some(earlier_site) = same_name {
+ sink.report(
+ &later_site.pointer,
+ t!(
+ "lint.rules.document.duplicateResourceName",
+ name = later_site.resource.name,
+ first_type = earlier_site.resource.resource_type,
+ second_type = later_site.resource.resource_type
+ ).to_string(),
+ );
+ }
+ }
+ }
+}
+
+/// Report a configuration without resources.
+fn empty_resources(context: &LintContext, sink: &mut DiagnosticSink) {
+ if context.config.resources.is_empty() {
+ sink.report(&JsonPointer::root().push("resources"), t!("lint.rules.document.emptyResources").to_string());
+ }
+}
+
+/// Report unrecognized keys under `Microsoft.DSC` metadata on the document and on each resource.
+fn unknown_microsoft_dsc_metadata(context: &LintContext, sink: &mut DiagnosticSink) {
+ check_microsoft_dsc_metadata(context.raw, &JsonPointer::root(), sink);
+ for (index, site) in context.resources.iter().enumerate() {
+ if let Some(resource) = context.raw_resource(index) {
+ check_microsoft_dsc_metadata(resource, &site.pointer, sink);
+ }
+ }
+}
+
+/// Report the unrecognized keys under the `Microsoft.DSC` metadata of one node.
+///
+/// # Arguments
+///
+/// * `node` - The document or resource node.
+/// * `base` - The pointer to `node`.
+/// * `sink` - Where to report.
+fn check_microsoft_dsc_metadata(node: &Value, base: &JsonPointer, sink: &mut DiagnosticSink) {
+ let Some(metadata) = node.get("metadata").and_then(|metadata| metadata.get("Microsoft.DSC")).and_then(Value::as_object) else {
+ return;
+ };
+ let pointer = base.push("metadata").push("Microsoft.DSC");
+ for key in metadata.keys() {
+ if !MICROSOFT_DSC_METADATA_KEYS.contains(&key.as_str()) {
+ sink.report(&pointer.push(key), t!("lint.rules.document.unknownMicrosoftDscMetadata", key = key).to_string());
+ }
+ }
+}
+
+/// Report resources written with `apiVersion` instead of `requireVersion`.
+fn api_version_alias(context: &LintContext, sink: &mut DiagnosticSink) {
+ for (index, site) in context.resources.iter().enumerate() {
+ if context.raw_resource(index).is_some_and(|resource| resource.get("apiVersion").is_some()) {
+ sink.report(&site.pointer.push("apiVersion"), t!("lint.rules.document.apiVersionAlias").to_string());
+ }
+ }
+}
+
+/// Report resources that require a date-based version.
+fn date_version(context: &LintContext, sink: &mut DiagnosticSink) {
+ for (index, site) in context.resources.iter().enumerate() {
+ if let Some(version @ ResourceVersionReq::Date(_)) = &site.resource.require_version {
+ sink.report(&version_pointer(context, index), t!("lint.rules.document.dateVersion", version = version).to_string());
+ }
+ }
+}
+
+/// Report resources that don't pin a version.
+fn missing_require_version(context: &LintContext, sink: &mut DiagnosticSink) {
+ for site in &context.resources {
+ if site.resource.require_version.is_none() {
+ sink.report(
+ &site.pointer,
+ t!(
+ "lint.rules.document.missingRequireVersion",
+ name = site.resource.name,
+ type_name = site.resource.resource_type
+ ).to_string(),
+ );
+ }
+ }
+}
+
+/// The pointer to the version requirement of a resource, whichever key the author used.
+///
+/// # Arguments
+///
+/// * `context` - The lint context.
+/// * `index` - The index of the resource in the context's resource list.
+fn version_pointer(context: &LintContext, index: usize) -> JsonPointer {
+ let site = &context.resources[index];
+ if context.raw_resource(index).is_some_and(|resource| resource.get("apiVersion").is_some()) {
+ site.pointer.push("apiVersion")
+ } else {
+ site.pointer.push("requireVersion")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::lint::test_util::{lint_yaml, rule_paths};
+
+ #[test]
+ fn reports_duplicate_resources_case_insensitively() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: Same
+ type: Microsoft.DSC.Debug/Echo
+- name: Same
+ type: microsoft.dsc.debug/echo
+- name: Other
+ type: Microsoft.DSC.Debug/Echo
+"#);
+ assert_eq!(rule_paths(&result, "duplicate-resource"), vec!["/resources/1"]);
+ assert!(rule_paths(&result, "duplicate-resource-name").is_empty());
+ }
+
+ #[test]
+ fn skips_copy_loops_for_duplicates() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: "[concat('a', copyIndex())]"
+ type: Microsoft.DSC.Debug/Echo
+ copy:
+ name: loop
+ count: 2
+- name: "[concat('a', copyIndex())]"
+ type: Microsoft.DSC.Debug/Echo
+ copy:
+ name: loop
+ count: 2
+"#);
+ assert!(rule_paths(&result, "duplicate-resource").is_empty());
+ }
+
+ #[test]
+ fn reports_duplicate_names_across_types() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: Same
+ type: Microsoft.DSC.Debug/Echo
+- name: Same
+ type: Microsoft/OSInfo
+"#);
+ assert_eq!(rule_paths(&result, "duplicate-resource-name"), vec!["/resources/1"]);
+ assert!(rule_paths(&result, "duplicate-resource").is_empty());
+ }
+
+ #[test]
+ fn nested_resources_are_separate_groups() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: Same
+ type: Microsoft.DSC/Group
+ resources:
+ - name: Same
+ type: Microsoft.DSC/Group
+"#);
+ assert!(rule_paths(&result, "duplicate-resource").is_empty());
+ }
+
+ #[test]
+ fn reports_empty_resources() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources: []
+"#);
+ assert_eq!(rule_paths(&result, "empty-resources"), vec!["/resources"]);
+ }
+
+ #[test]
+ fn reports_unknown_microsoft_dsc_metadata_keys() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+metadata:
+ Microsoft.DSC:
+ securityContext: elevated
+ customKey: 1
+ Other:
+ anything: true
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ metadata:
+ Microsoft.DSC:
+ nope: 2
+"#);
+ assert_eq!(
+ rule_paths(&result, "unknown-microsoft-dsc-metadata"),
+ vec!["/metadata/Microsoft.DSC/customKey", "/resources/0/metadata/Microsoft.DSC/nope"]
+ );
+ }
+
+ #[test]
+ fn reports_api_version_alias_and_date_versions() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ apiVersion: "2025-01-01"
+- name: b
+ type: Microsoft.DSC.Debug/Echo
+ requireVersion: "^1.0"
+"#);
+ assert_eq!(rule_paths(&result, "api-version-alias"), vec!["/resources/0/apiVersion"]);
+ assert_eq!(rule_paths(&result, "date-version"), vec!["/resources/0/apiVersion"]);
+ }
+
+ #[test]
+ fn missing_require_version_is_off_by_default() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+"#);
+ assert!(rule_paths(&result, "missing-require-version").is_empty());
+ }
+}
diff --git a/lib/dsc-lib/src/lint/rules/environment.rs b/lib/dsc-lib/src/lint/rules/environment.rs
new file mode 100644
index 000000000..ae77eb3b5
--- /dev/null
+++ b/lib/dsc-lib/src/lint/rules/environment.rs
@@ -0,0 +1,124 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Rules about the environment a configuration document targets.
+
+use std::sync::LazyLock;
+
+use regex::Regex;
+use rust_i18n::t;
+
+use crate::lint::{
+ context::LintContext,
+ diagnostic::LintCategory,
+ options::RuleLevel,
+ pointer::{for_each_string, JsonPointer},
+ rule::{DiagnosticSink, Rule, RuleTier},
+};
+
+/// Absolute paths rooted in an operating system drive or system directory.
+static SYSTEM_PATH_PATTERN: LazyLock = LazyLock::new(|| {
+ Regex::new(r"^(?:[A-Za-z]:[\\/]|/(?:etc|usr|var|opt|bin|sbin|lib|home)/)")
+ .expect("the system path pattern is a valid regex")
+});
+
+/// The rules in this module.
+#[must_use]
+pub fn rules() -> Vec {
+ vec![
+ Rule {
+ id: "system-root-unused",
+ category: LintCategory::Environment,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.environment.systemRootUnusedDescription").to_string(),
+ check: system_root_unused,
+ },
+ Rule {
+ id: "hardcoded-system-path",
+ category: LintCategory::Environment,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Off,
+ description: || t!("lint.rules.environment.hardcodedSystemPathDescription").to_string(),
+ check: hardcoded_system_path,
+ },
+ ]
+}
+
+/// Report a document that doesn't call `systemRoot()` although a system root was specified.
+fn system_root_unused(context: &LintContext, sink: &mut DiagnosticSink) {
+ let Some(system_root) = &context.options.system_root else {
+ return;
+ };
+ if context.expressions.calls_named("systemRoot").is_empty() {
+ sink.report(
+ &JsonPointer::root(),
+ t!("lint.rules.environment.systemRootUnused", path = system_root.display()).to_string(),
+ );
+ }
+}
+
+/// Report literal absolute operating system paths in resource properties.
+fn hardcoded_system_path(context: &LintContext, sink: &mut DiagnosticSink) {
+ for (index, site) in context.resources.iter().enumerate() {
+ let Some(properties) = context.raw_resource(index).and_then(|resource| resource.get("properties")) else {
+ continue;
+ };
+ for_each_string(properties, &site.pointer.push("properties"), &mut |pointer, text| {
+ if !text.starts_with('[') && SYSTEM_PATH_PATTERN.is_match(text) {
+ sink.report(pointer, t!("lint.rules.environment.hardcodedSystemPath", value = text).to_string());
+ }
+ });
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::path::PathBuf;
+
+ use crate::lint::{options::{LintOptions, RuleLevel, RuleLevelOverride}, test_util::{lint_with, rule_paths}};
+
+ fn options_with_root() -> LintOptions {
+ LintOptions {
+ discovery: false,
+ use_settings: false,
+ system_root: Some(PathBuf::from("/mnt/image")),
+ rule_levels: vec![RuleLevelOverride { id: "hardcoded-system-path".to_string(), level: RuleLevel::Info }],
+ ..Default::default()
+ }
+ }
+
+ #[test]
+ fn reports_unused_system_root_and_hardcoded_paths() {
+ let result = lint_with(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output:
+ windows: 'C:\Windows\System32'
+ unix: /etc/ssh/sshd_config
+ relative: config/sshd_config
+ escaped: "[[C:\\not-an-expression]"
+"#, options_with_root());
+ assert_eq!(rule_paths(&result, "system-root-unused"), vec![""]);
+ assert_eq!(
+ rule_paths(&result, "hardcoded-system-path"),
+ vec!["/resources/0/properties/output/unix", "/resources/0/properties/output/windows"]
+ );
+ }
+
+ #[test]
+ fn system_root_call_satisfies_the_rule() {
+ let result = lint_with(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "[concat(systemRoot(), 'etc')]"
+"#, options_with_root());
+ assert!(rule_paths(&result, "system-root-unused").is_empty());
+ }
+}
diff --git a/lib/dsc-lib/src/lint/rules/expression.rs b/lib/dsc-lib/src/lint/rules/expression.rs
new file mode 100644
index 000000000..d3f961484
--- /dev/null
+++ b/lib/dsc-lib/src/lint/rules/expression.rs
@@ -0,0 +1,331 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Rules about expressions and the functions they call.
+
+use rust_i18n::t;
+
+use crate::lint::{
+ context::LintContext,
+ diagnostic::LintCategory,
+ expression_index::{literal_args, literal_string_arg, ExpressionScope, FunctionCall},
+ options::RuleLevel,
+ pointer::JsonPointer,
+ rule::{DiagnosticSink, Rule, RuleTier},
+};
+
+/// The rules in this module.
+#[must_use]
+pub fn rules() -> Vec {
+ vec![
+ Rule {
+ id: "invalid-expression",
+ category: LintCategory::Expression,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.expression.invalidExpressionDescription").to_string(),
+ check: invalid_expression,
+ },
+ Rule {
+ id: "unknown-function",
+ category: LintCategory::Expression,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.expression.unknownFunctionDescription").to_string(),
+ check: unknown_function,
+ },
+ Rule {
+ id: "invalid-argument-count",
+ category: LintCategory::Expression,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.expression.invalidArgumentCountDescription").to_string(),
+ check: invalid_argument_count,
+ },
+ Rule {
+ id: "invalid-argument-type",
+ category: LintCategory::Expression,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.expression.invalidArgumentTypeDescription").to_string(),
+ check: invalid_argument_type,
+ },
+ Rule {
+ id: "undefined-parameter",
+ category: LintCategory::Expression,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.expression.undefinedParameterDescription").to_string(),
+ check: undefined_parameter,
+ },
+ Rule {
+ id: "undefined-variable",
+ category: LintCategory::Expression,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.expression.undefinedVariableDescription").to_string(),
+ check: undefined_variable,
+ },
+ Rule {
+ id: "condition-not-expression",
+ category: LintCategory::Expression,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.expression.conditionNotExpressionDescription").to_string(),
+ check: condition_not_expression,
+ },
+ ]
+}
+
+/// Whether a call is to `lambda()`, which the runtime dispatches specially with no arguments.
+///
+/// # Arguments
+///
+/// * `call` - The call.
+fn is_lambda(call: &FunctionCall) -> bool {
+ call.function.name().eq_ignore_ascii_case("lambda")
+}
+
+/// Whether a call can be checked against built-in function metadata.
+///
+/// User-defined functions (`namespace.member`) and `lambda()` are excluded.
+///
+/// # Arguments
+///
+/// * `context` - The lint context.
+/// * `call` - The call.
+fn is_checkable_builtin(context: &LintContext, call: &FunctionCall) -> bool {
+ let name = call.function.name();
+ !name.contains('.') && !is_lambda(call) && context.functions.get_metadata(name).is_some()
+}
+
+/// Report expressions that fail to parse.
+fn invalid_expression(context: &LintContext, sink: &mut DiagnosticSink) {
+ for site in context.expressions.sites() {
+ if let Err(err) = &site.parsed {
+ sink.report(
+ &site.pointer,
+ t!(
+ "lint.rules.expression.invalidExpression",
+ expression = site.text,
+ error = LintContext::error_message(err)
+ ).to_string(),
+ );
+ }
+ }
+}
+
+/// Report calls to functions that are neither built in nor declared in `functions`.
+fn unknown_function(context: &LintContext, sink: &mut DiagnosticSink) {
+ let user_functions = context.user_function_names();
+ for call in context.expressions.calls() {
+ let name = call.function.name();
+ if name.contains('.') {
+ if !user_functions.contains(name) {
+ sink.report(&call.site.pointer, t!("lint.rules.expression.unknownUserFunction", name = name).to_string());
+ }
+ continue;
+ }
+ if is_lambda(&call) || context.functions.get_metadata(name).is_some() {
+ continue;
+ }
+ let suggestion = context.functions.list().into_iter().map(|function| function.name).find(|known| known.eq_ignore_ascii_case(name));
+ let message = match suggestion {
+ Some(suggestion) => t!("lint.rules.expression.unknownFunctionSuggestion", name = name, suggestion = suggestion),
+ None => t!("lint.rules.expression.unknownFunction", name = name),
+ };
+ sink.report(&call.site.pointer, message.to_string());
+ }
+}
+
+/// Report calls that pass a number of arguments the function doesn't accept.
+fn invalid_argument_count(context: &LintContext, sink: &mut DiagnosticSink) {
+ for call in context.expressions.calls() {
+ if !is_checkable_builtin(context, &call) {
+ continue;
+ }
+ let count = call.function.args().map_or(0, <[_]>::len);
+ if let Err(err) = context.functions.validate_arg_count(call.function.name(), count) {
+ sink.report(&call.site.pointer, LintContext::error_message(&err));
+ }
+ }
+}
+
+/// Report calls whose literal arguments have a kind the function doesn't accept.
+fn invalid_argument_type(context: &LintContext, sink: &mut DiagnosticSink) {
+ for call in context.expressions.calls() {
+ if !is_checkable_builtin(context, &call) {
+ continue;
+ }
+ let args = literal_args(call.function);
+ if let Err(err) = context.functions.validate_literal_args(call.function.name(), &args) {
+ sink.report(&call.site.pointer, LintContext::error_message(&err));
+ }
+ }
+}
+
+/// Report `parameters()` calls that name an undeclared parameter.
+///
+/// Calls inside user-defined function bodies refer to the function's own parameters and are
+/// skipped.
+fn undefined_parameter(context: &LintContext, sink: &mut DiagnosticSink) {
+ for call in context.expressions.calls_named("parameters") {
+ if call.site.scope == ExpressionScope::UserFunction {
+ continue;
+ }
+ let Some(name) = literal_string_arg(call.function, 0) else {
+ continue;
+ };
+ if context.declared_parameter(name).is_none() {
+ sink.report(&call.site.pointer, t!("lint.rules.expression.undefinedParameter", name = name).to_string());
+ }
+ }
+}
+
+/// Report `variables()` calls that name an undeclared variable.
+fn undefined_variable(context: &LintContext, sink: &mut DiagnosticSink) {
+ for call in context.expressions.calls_named("variables") {
+ let Some(name) = literal_string_arg(call.function, 0) else {
+ continue;
+ };
+ let declared = context.config.variables.as_ref().is_some_and(|variables| variables.contains_key(name));
+ if !declared {
+ sink.report(&call.site.pointer, t!("lint.rules.expression.undefinedVariable", name = name).to_string());
+ }
+ }
+}
+
+/// Report resource and output conditions that are plain strings rather than expressions.
+fn condition_not_expression(context: &LintContext, sink: &mut DiagnosticSink) {
+ for site in &context.resources {
+ if let Some(condition) = &site.resource.condition
+ && !LintContext::is_expression(condition) {
+ sink.report(
+ &site.pointer.push("condition"),
+ t!("lint.rules.expression.conditionNotExpression", condition = condition).to_string(),
+ );
+ }
+ }
+ if let Some(outputs) = &context.config.outputs {
+ for (name, output) in outputs {
+ if let Some(condition) = &output.condition
+ && !LintContext::is_expression(condition) {
+ sink.report(
+ &JsonPointer::root().push("outputs").push(name).push("condition"),
+ t!("lint.rules.expression.conditionNotExpression", condition = condition).to_string(),
+ );
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::lint::test_util::{lint_yaml, rule_messages, rule_paths};
+
+ #[test]
+ fn reports_invalid_expressions() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "[concat('unterminated)]"
+ fine: "[[not an expression]"
+"#);
+ assert_eq!(rule_paths(&result, "invalid-expression"), vec!["/resources/0/properties/output"]);
+ }
+
+ #[test]
+ fn reports_unknown_functions_with_case_suggestion() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+functions:
+- namespace: my
+ members:
+ hello:
+ output:
+ type: string
+ value: "[concat('hi', parameters('who'))]"
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output:
+ a: "[Concat('a', 'b')]"
+ b: "[nosuchfunction()]"
+ c: "[my.hello('x')]"
+ d: "[my.missing('x')]"
+ e: "[map(createArray(1), lambda('item', lambdaVariables('item')))]"
+"#);
+ assert_eq!(
+ rule_paths(&result, "unknown-function"),
+ vec!["/resources/0/properties/output/a", "/resources/0/properties/output/b", "/resources/0/properties/output/d"]
+ );
+ let messages = rule_messages(&result, "unknown-function");
+ assert!(messages[0].contains("'concat'"), "expected a case suggestion, got {}", messages[0]);
+ assert!(rule_paths(&result, "undefined-parameter").is_empty(), "user function parameters must not be checked");
+ }
+
+ #[test]
+ fn reports_argument_count_and_type() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output:
+ count: "[int('1', '2')]"
+ kind: "[int(true)]"
+ nested: "[int(concat('1', '2'))]"
+"#);
+ assert_eq!(rule_paths(&result, "invalid-argument-count"), vec!["/resources/0/properties/output/count"]);
+ assert_eq!(rule_paths(&result, "invalid-argument-type"), vec!["/resources/0/properties/output/kind"]);
+ }
+
+ #[test]
+ fn reports_undefined_parameters_and_variables() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+parameters:
+ known:
+ type: string
+ defaultValue: x
+variables:
+ v: 1
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output:
+ p1: "[parameters('known')]"
+ p2: "[parameters('unknown')]"
+ v1: "[variables('v')]"
+ v2: "[variables('w')]"
+"#);
+ assert_eq!(rule_paths(&result, "undefined-parameter"), vec!["/resources/0/properties/output/p2"]);
+ assert_eq!(rule_paths(&result, "undefined-variable"), vec!["/resources/0/properties/output/v2"]);
+ }
+
+ #[test]
+ fn reports_plain_string_conditions() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+outputs:
+ o:
+ type: string
+ value: x
+ condition: "true"
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ condition: "true"
+- name: b
+ type: Microsoft.DSC.Debug/Echo
+ condition: "[true()]"
+"#);
+ assert_eq!(rule_paths(&result, "condition-not-expression"), vec!["/outputs/o/condition", "/resources/0/condition"]);
+ }
+}
diff --git a/lib/dsc-lib/src/lint/rules/mod.rs b/lib/dsc-lib/src/lint/rules/mod.rs
new file mode 100644
index 000000000..d6bb0645b
--- /dev/null
+++ b/lib/dsc-lib/src/lint/rules/mod.rs
@@ -0,0 +1,49 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! The lint rules, grouped by the area of the document they inspect.
+
+use super::rule::Rule;
+
+pub mod dependency;
+pub mod deprecation;
+pub mod document;
+pub mod environment;
+pub mod expression;
+pub mod parameter;
+pub mod resource;
+
+/// Every rule, grouped by category.
+#[must_use]
+pub fn all_rules() -> Vec {
+ let mut rules = Vec::new();
+ rules.extend(document::rules());
+ rules.extend(expression::rules());
+ rules.extend(parameter::rules());
+ rules.extend(dependency::rules());
+ rules.extend(deprecation::rules());
+ rules.extend(environment::rules());
+ rules.extend(resource::rules());
+ rules
+}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::HashSet;
+
+ use super::*;
+
+ #[test]
+ fn rule_ids_are_unique_and_kebab_case() {
+ let mut seen = HashSet::new();
+ for rule in all_rules() {
+ assert!(seen.insert(rule.id), "duplicate rule id {}", rule.id);
+ assert!(
+ rule.id.chars().all(|c| c.is_ascii_lowercase() || c == '-'),
+ "rule id {} must be kebab-case",
+ rule.id
+ );
+ assert!(!(rule.description)().is_empty(), "rule {} has no description", rule.id);
+ }
+ }
+}
diff --git a/lib/dsc-lib/src/lint/rules/parameter.rs b/lib/dsc-lib/src/lint/rules/parameter.rs
new file mode 100644
index 000000000..3471a0624
--- /dev/null
+++ b/lib/dsc-lib/src/lint/rules/parameter.rs
@@ -0,0 +1,648 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Rules about parameter and variable declarations and the values supplied for them.
+
+use std::{collections::HashSet, sync::LazyLock};
+
+use regex::Regex;
+use rust_i18n::t;
+use serde_json::Value;
+
+use crate::{
+ configure::{
+ config_doc::{DataType, Parameter, ValueOrCopy},
+ constraints::{check_allowed_values, check_length, check_number_limits},
+ },
+ lint::{
+ context::LintContext,
+ diagnostic::LintCategory,
+ expression_index::{literal_string_arg, ExpressionScope},
+ options::RuleLevel,
+ pointer::JsonPointer,
+ rule::{DiagnosticSink, Rule, RuleTier},
+ },
+};
+
+/// Parameter names that suggest the value is a secret.
+static SECRET_NAME_PATTERN: LazyLock = LazyLock::new(|| {
+ Regex::new(r"(?i)(password|passwd|secret|token|apikey|api[-_]?key|credential|connectionstring)")
+ .expect("the secret name pattern is a valid regex")
+});
+
+/// The rules in this module.
+#[must_use]
+pub fn rules() -> Vec {
+ vec![
+ Rule {
+ id: "no-unused-parameters",
+ category: LintCategory::Parameter,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.parameter.noUnusedParametersDescription").to_string(),
+ check: no_unused_parameters,
+ },
+ Rule {
+ id: "no-unused-variables",
+ category: LintCategory::Parameter,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.parameter.noUnusedVariablesDescription").to_string(),
+ check: no_unused_variables,
+ },
+ Rule {
+ id: "parameter-default-type",
+ category: LintCategory::Parameter,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.parameter.parameterDefaultTypeDescription").to_string(),
+ check: parameter_default_type,
+ },
+ Rule {
+ id: "invalid-parameter-constraints",
+ category: LintCategory::Parameter,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.parameter.invalidParameterConstraintsDescription").to_string(),
+ check: invalid_parameter_constraints,
+ },
+ Rule {
+ id: "secure-parameter-default",
+ category: LintCategory::Parameter,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.parameter.secureParameterDefaultDescription").to_string(),
+ check: secure_parameter_default,
+ },
+ Rule {
+ id: "prefer-secure-type",
+ category: LintCategory::Parameter,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.parameter.preferSecureTypeDescription").to_string(),
+ check: prefer_secure_type,
+ },
+ Rule {
+ id: "secure-value-in-output",
+ category: LintCategory::Parameter,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.parameter.secureValueInOutputDescription").to_string(),
+ check: secure_value_in_output,
+ },
+ Rule {
+ id: "use-parameter-description",
+ category: LintCategory::Parameter,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Off,
+ description: || t!("lint.rules.parameter.useParameterDescriptionDescription").to_string(),
+ check: use_parameter_description,
+ },
+ Rule {
+ id: "undeclared-parameter-value",
+ category: LintCategory::Parameter,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.parameter.undeclaredParameterValueDescription").to_string(),
+ check: undeclared_parameter_value,
+ },
+ Rule {
+ id: "missing-parameter-value",
+ category: LintCategory::Parameter,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.parameter.missingParameterValueDescription").to_string(),
+ check: missing_parameter_value,
+ },
+ Rule {
+ id: "parameter-value-type",
+ category: LintCategory::Parameter,
+ tier: RuleTier::Static,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.parameter.parameterValueTypeDescription").to_string(),
+ check: parameter_value_type,
+ },
+ ]
+}
+
+/// The names passed as the literal first argument of every call of a function, outside
+/// user-defined function bodies.
+///
+/// # Arguments
+///
+/// * `context` - The lint context.
+/// * `function_name` - The function, such as `parameters` or `variables`.
+fn referenced_names(context: &LintContext, function_name: &str) -> HashSet {
+ context.expressions.calls_named(function_name).into_iter()
+ .filter(|call| call.site.scope != ExpressionScope::UserFunction)
+ .filter_map(|call| literal_string_arg(call.function, 0).map(String::from))
+ .collect()
+}
+
+/// The pointer to a declared parameter.
+///
+/// # Arguments
+///
+/// * `name` - The parameter name.
+fn parameter_pointer(name: &str) -> JsonPointer {
+ JsonPointer::root().push("parameters").push(name)
+}
+
+/// Whether a value is acceptable for a parameter type.
+///
+/// Secure types accept the plain value as well as the wrapped form (`{"secureString": ...}`),
+/// because DSC wraps plain values when it imports parameters.
+///
+/// # Arguments
+///
+/// * `value` - The value.
+/// * `data_type` - The parameter type.
+fn value_matches_type(value: &Value, data_type: &DataType) -> bool {
+ match data_type {
+ DataType::String => value.is_string(),
+ DataType::SecureString => value.is_string() || wrapped_secure_value(value, "secureString").is_some_and(Value::is_string),
+ DataType::Int => value.is_i64(),
+ DataType::Bool => value.is_boolean(),
+ DataType::Object => value.is_object(),
+ DataType::SecureObject => value.is_object(),
+ DataType::Array => value.is_array(),
+ }
+}
+
+/// The inner value of a wrapped secure value such as `{"secureString": "..."}`.
+///
+/// # Arguments
+///
+/// * `value` - The value to unwrap.
+/// * `key` - The wrapper key, `secureString` or `secureObject`.
+fn wrapped_secure_value<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
+ let object = value.as_object()?;
+ if object.len() == 1 { object.get(key) } else { None }
+}
+
+/// Check a literal value against a parameter's type and constraints.
+///
+/// # Arguments
+///
+/// * `name` - The parameter name.
+/// * `value` - The value.
+/// * `parameter` - The parameter declaration.
+///
+/// # Errors
+///
+/// Returns the reason the value is invalid.
+fn check_value(name: &str, value: &Value, parameter: &Parameter) -> Result<(), String> {
+ if !value_matches_type(value, ¶meter.parameter_type) {
+ return Err(t!("lint.rules.parameter.valueNotOfType", type_name = parameter.parameter_type).to_string());
+ }
+ check_allowed_values(name, value, parameter).map_err(|err| LintContext::error_message(&err))?;
+ if parameter.parameter_type == DataType::Int {
+ check_number_limits(name, value, parameter).map_err(|err| LintContext::error_message(&err))?;
+ }
+ if (value.is_string() || value.is_array()) && has_length_type(parameter) {
+ check_length(name, value, parameter).map_err(|err| LintContext::error_message(&err))?;
+ }
+ Ok(())
+}
+
+/// Whether length constraints apply to the parameter's type.
+///
+/// # Arguments
+///
+/// * `parameter` - The parameter declaration.
+fn has_length_type(parameter: &Parameter) -> bool {
+ matches!(parameter.parameter_type, DataType::String | DataType::SecureString | DataType::Array)
+}
+
+/// Whether a parameter has a secure type.
+///
+/// # Arguments
+///
+/// * `parameter` - The parameter declaration.
+fn is_secure(parameter: &Parameter) -> bool {
+ matches!(parameter.parameter_type, DataType::SecureString | DataType::SecureObject)
+}
+
+/// Report declared parameters that no expression references.
+fn no_unused_parameters(context: &LintContext, sink: &mut DiagnosticSink) {
+ let Some(parameters) = &context.config.parameters else {
+ return;
+ };
+ let referenced = referenced_names(context, "parameters");
+ for name in parameters.keys() {
+ if !referenced.contains(name) {
+ sink.report(¶meter_pointer(name), t!("lint.rules.parameter.noUnusedParameters", name = name).to_string());
+ }
+ }
+}
+
+/// Report declared variables that no expression references.
+fn no_unused_variables(context: &LintContext, sink: &mut DiagnosticSink) {
+ let Some(variables) = &context.config.variables else {
+ return;
+ };
+ let referenced = referenced_names(context, "variables");
+ for name in variables.keys() {
+ if !referenced.contains(name) {
+ sink.report(&JsonPointer::root().push("variables").push(name), t!("lint.rules.parameter.noUnusedVariables", name = name).to_string());
+ }
+ }
+}
+
+/// Report literal default values that don't satisfy the parameter's type or constraints.
+fn parameter_default_type(context: &LintContext, sink: &mut DiagnosticSink) {
+ let Some(parameters) = &context.config.parameters else {
+ return;
+ };
+ for (name, parameter) in parameters {
+ let Some(value) = ¶meter.default_value else {
+ continue;
+ };
+ if value.as_str().is_some_and(LintContext::is_expression) {
+ continue;
+ }
+ if let Err(reason) = check_value(name, value, parameter) {
+ sink.report(
+ ¶meter_pointer(name).push("defaultValue"),
+ t!("lint.rules.parameter.parameterDefaultType", name = name, reason = reason).to_string(),
+ );
+ }
+ }
+}
+
+/// Report constraints that contradict each other or don't apply to the parameter's type.
+fn invalid_parameter_constraints(context: &LintContext, sink: &mut DiagnosticSink) {
+ let Some(parameters) = &context.config.parameters else {
+ return;
+ };
+ for (name, parameter) in parameters {
+ let base = parameter_pointer(name);
+ let type_name = ¶meter.parameter_type;
+ if let (Some(min), Some(max)) = (parameter.min_value, parameter.max_value)
+ && min > max {
+ sink.report(&base.push("minValue"), t!("lint.rules.parameter.minValueExceedsMaxValue", name = name, min = min, max = max).to_string());
+ }
+ if let (Some(min), Some(max)) = (parameter.min_length, parameter.max_length)
+ && min > max {
+ sink.report(&base.push("minLength"), t!("lint.rules.parameter.minLengthExceedsMaxLength", name = name, min = min, max = max).to_string());
+ }
+ if *type_name != DataType::Int {
+ for (constraint, limit) in [("minValue", parameter.min_value), ("maxValue", parameter.max_value)] {
+ if limit.is_some() {
+ sink.report(&base.push(constraint), t!("lint.rules.parameter.valueLimitOnNonInt", name = name, constraint = constraint, type_name = type_name).to_string());
+ }
+ }
+ }
+ for (constraint, limit) in [("minLength", parameter.min_length), ("maxLength", parameter.max_length)] {
+ let Some(limit) = limit else {
+ continue;
+ };
+ if !has_length_type(parameter) {
+ sink.report(&base.push(constraint), t!("lint.rules.parameter.lengthLimitOnOtherType", name = name, constraint = constraint, type_name = type_name).to_string());
+ } else if limit < 0 {
+ sink.report(&base.push(constraint), t!("lint.rules.parameter.negativeLength", name = name, constraint = constraint).to_string());
+ }
+ }
+ if let Some(allowed_values) = ¶meter.allowed_values {
+ if allowed_values.is_empty() {
+ sink.report(&base.push("allowedValues"), t!("lint.rules.parameter.emptyAllowedValues", name = name).to_string());
+ }
+ for (position, allowed) in allowed_values.iter().enumerate() {
+ if !value_matches_type(allowed, type_name) {
+ sink.report(
+ &base.push("allowedValues").push_index(position),
+ t!("lint.rules.parameter.allowedValueTypeMismatch", name = name, type_name = type_name).to_string(),
+ );
+ }
+ }
+ }
+ }
+}
+
+/// Report secure parameters with a hard-coded default value.
+fn secure_parameter_default(context: &LintContext, sink: &mut DiagnosticSink) {
+ let Some(parameters) = &context.config.parameters else {
+ return;
+ };
+ for (name, parameter) in parameters {
+ if !is_secure(parameter) {
+ continue;
+ }
+ let Some(value) = ¶meter.default_value else {
+ continue;
+ };
+ let harmless = value.as_str().is_some_and(|text| text.is_empty() || LintContext::is_expression(text));
+ if !harmless {
+ sink.report(¶meter_pointer(name).push("defaultValue"), t!("lint.rules.parameter.secureParameterDefault", name = name).to_string());
+ }
+ }
+}
+
+/// Report parameters whose names suggest a secret but whose type isn't secure.
+fn prefer_secure_type(context: &LintContext, sink: &mut DiagnosticSink) {
+ let Some(parameters) = &context.config.parameters else {
+ return;
+ };
+ for (name, parameter) in parameters {
+ let secure_type = match parameter.parameter_type {
+ DataType::String => DataType::SecureString,
+ DataType::Object => DataType::SecureObject,
+ _ => continue,
+ };
+ if SECRET_NAME_PATTERN.is_match(name) {
+ sink.report(
+ ¶meter_pointer(name),
+ t!("lint.rules.parameter.preferSecureType", name = name, secure_type = secure_type, type_name = parameter.parameter_type).to_string(),
+ );
+ }
+ }
+}
+
+/// Report outputs whose value references a secure parameter or a secret.
+fn secure_value_in_output(context: &LintContext, sink: &mut DiagnosticSink) {
+ let Some(outputs) = &context.config.outputs else {
+ return;
+ };
+ for (name, output) in outputs {
+ if !matches!(output.value_or_copy, ValueOrCopy::Value(_)) {
+ continue;
+ }
+ let pointer = JsonPointer::root().push("outputs").push(name).push("value");
+ let calls = context.expressions.calls().into_iter().filter(|call| call.site.pointer == pointer);
+ for call in calls {
+ let source = match call.function.name() {
+ "parameters" => {
+ let Some(parameter_name) = literal_string_arg(call.function, 0) else {
+ continue;
+ };
+ if !context.declared_parameter(parameter_name).is_some_and(is_secure) {
+ continue;
+ }
+ format!("parameters('{parameter_name}')")
+ },
+ "secret" => "secret()".to_string(),
+ _ => continue,
+ };
+ sink.report(&pointer, t!("lint.rules.parameter.secureValueInOutput", name = name, source = source).to_string());
+ break;
+ }
+ }
+}
+
+/// Report parameters without a description.
+fn use_parameter_description(context: &LintContext, sink: &mut DiagnosticSink) {
+ let Some(parameters) = &context.config.parameters else {
+ return;
+ };
+ for (name, parameter) in parameters {
+ if parameter.description.as_ref().is_none_or(|description| description.trim().is_empty()) {
+ sink.report(¶meter_pointer(name), t!("lint.rules.parameter.useParameterDescription", name = name).to_string());
+ }
+ }
+}
+
+/// Report supplied parameter values that don't match a declared parameter.
+fn undeclared_parameter_value(context: &LintContext, sink: &mut DiagnosticSink) {
+ let Some(supplied) = &context.supplied_parameters else {
+ return;
+ };
+ for name in supplied.keys() {
+ if context.declared_parameter(name).is_none() {
+ sink.report(&JsonPointer::root().push("parameters"), t!("lint.rules.parameter.undeclaredParameterValue", name = name).to_string());
+ }
+ }
+}
+
+/// Report declared parameters without a default that weren't supplied a value.
+fn missing_parameter_value(context: &LintContext, sink: &mut DiagnosticSink) {
+ let Some(supplied) = &context.supplied_parameters else {
+ return;
+ };
+ let Some(parameters) = &context.config.parameters else {
+ return;
+ };
+ for (name, parameter) in parameters {
+ if parameter.default_value.is_none() && !supplied.contains_key(name) {
+ sink.report(¶meter_pointer(name), t!("lint.rules.parameter.missingParameterValue", name = name).to_string());
+ }
+ }
+}
+
+/// Report supplied parameter values that don't satisfy the parameter's type or constraints.
+fn parameter_value_type(context: &LintContext, sink: &mut DiagnosticSink) {
+ let Some(supplied) = &context.supplied_parameters else {
+ return;
+ };
+ for (name, value) in supplied {
+ let Some(parameter) = context.declared_parameter(name) else {
+ continue;
+ };
+ if let Err(reason) = check_value(name, value, parameter) {
+ sink.report(¶meter_pointer(name), t!("lint.rules.parameter.parameterValueType", name = name, reason = reason).to_string());
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+
+ use crate::lint::{options::LintOptions, test_util::{lint_with, lint_yaml, rule_paths}};
+
+ #[test]
+ fn reports_unused_parameters_and_variables() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+parameters:
+ used:
+ type: string
+ defaultValue: a
+ unused:
+ type: string
+ defaultValue: b
+variables:
+ used: 1
+ unused: 2
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "[concat(parameters('used'), string(variables('used')))]"
+"#);
+ assert_eq!(rule_paths(&result, "no-unused-parameters"), vec!["/parameters/unused"]);
+ assert_eq!(rule_paths(&result, "no-unused-variables"), vec!["/variables/unused"]);
+ }
+
+ #[test]
+ fn reports_invalid_default_values() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+parameters:
+ wrongType:
+ type: int
+ defaultValue: ten
+ notAllowed:
+ type: string
+ defaultValue: c
+ allowedValues: [a, b]
+ tooSmall:
+ type: int
+ defaultValue: 1
+ minValue: 5
+ tooShort:
+ type: string
+ defaultValue: ab
+ minLength: 3
+ expression:
+ type: int
+ defaultValue: "[int('1')]"
+ secureOk:
+ type: secureString
+ defaultValue: ""
+ fine:
+ type: array
+ defaultValue: [1]
+resources: []
+"#);
+ assert_eq!(
+ rule_paths(&result, "parameter-default-type"),
+ vec![
+ "/parameters/notAllowed/defaultValue",
+ "/parameters/tooShort/defaultValue",
+ "/parameters/tooSmall/defaultValue",
+ "/parameters/wrongType/defaultValue",
+ ]
+ );
+ }
+
+ #[test]
+ fn reports_invalid_constraints() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+parameters:
+ flipped:
+ type: int
+ minValue: 10
+ maxValue: 1
+ lengthOnInt:
+ type: int
+ minLength: 1
+ valueOnString:
+ type: string
+ maxValue: 1
+ negative:
+ type: string
+ maxLength: -1
+ empty:
+ type: string
+ allowedValues: []
+ mixed:
+ type: int
+ allowedValues: [1, two]
+resources: []
+"#);
+ assert_eq!(
+ rule_paths(&result, "invalid-parameter-constraints"),
+ vec![
+ "/parameters/empty/allowedValues",
+ "/parameters/flipped/minValue",
+ "/parameters/lengthOnInt/minLength",
+ "/parameters/mixed/allowedValues/1",
+ "/parameters/negative/maxLength",
+ "/parameters/valueOnString/maxValue",
+ ]
+ );
+ }
+
+ #[test]
+ fn reports_secure_defaults_and_secret_like_names() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+parameters:
+ adminPassword:
+ type: string
+ defaultValue: ""
+ token:
+ type: secureString
+ defaultValue: hardcoded
+ apiKey:
+ type: secureString
+ config:
+ type: object
+ defaultValue: {}
+resources: []
+"#);
+ assert_eq!(rule_paths(&result, "secure-parameter-default"), vec!["/parameters/token/defaultValue"]);
+ assert_eq!(rule_paths(&result, "prefer-secure-type"), vec!["/parameters/adminPassword"]);
+ }
+
+ #[test]
+ fn reports_secure_values_in_outputs() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+parameters:
+ password:
+ type: secureString
+ plain:
+ type: string
+ defaultValue: x
+outputs:
+ leaked:
+ type: string
+ value: "[parameters('password')]"
+ viaSecret:
+ type: string
+ value: "[secret('vault')]"
+ fine:
+ type: string
+ value: "[parameters('plain')]"
+resources: []
+"#);
+ assert_eq!(rule_paths(&result, "secure-value-in-output"), vec!["/outputs/leaked/value", "/outputs/viaSecret/value"]);
+ }
+
+ #[test]
+ fn checks_supplied_parameter_values() {
+ let options = LintOptions {
+ discovery: false,
+ use_settings: false,
+ parameters: Some(json!({"parameters": {"count": "five", "extra": 1}})),
+ ..Default::default()
+ };
+ let result = lint_with(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+parameters:
+ count:
+ type: int
+ required:
+ type: string
+ optional:
+ type: string
+ defaultValue: x
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "[concat(string(parameters('count')), parameters('required'), parameters('optional'))]"
+"#, options);
+ assert_eq!(rule_paths(&result, "undeclared-parameter-value"), vec!["/parameters"]);
+ assert_eq!(rule_paths(&result, "missing-parameter-value"), vec!["/parameters/required"]);
+ assert_eq!(rule_paths(&result, "parameter-value-type"), vec!["/parameters/count"]);
+ }
+
+ #[test]
+ fn parameter_value_rules_need_supplied_parameters() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+parameters:
+ required:
+ type: string
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "[parameters('required')]"
+"#);
+ assert!(rule_paths(&result, "missing-parameter-value").is_empty());
+ }
+}
diff --git a/lib/dsc-lib/src/lint/rules/resource.rs b/lib/dsc-lib/src/lint/rules/resource.rs
new file mode 100644
index 000000000..6f14563af
--- /dev/null
+++ b/lib/dsc-lib/src/lint/rules/resource.rs
@@ -0,0 +1,427 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Rules that inspect the installed resources a configuration document uses. They only run
+//! when discovery is enabled and never invoke a resource operation.
+
+use rust_i18n::t;
+use serde_json::Value;
+
+use crate::{
+ dscresources::{
+ dscresource::{validate_json_all, Capability, DscResource, ImplementedAs},
+ resource_manifest::{Kind, SchemaKind},
+ },
+ lint::{
+ context::{LintContext, ResolvedResource},
+ diagnostic::LintCategory,
+ expression_index::ExpressionScope,
+ options::RuleLevel,
+ pointer::JsonPointer,
+ rule::{DiagnosticSink, Rule, RuleTier},
+ },
+};
+
+/// The rules in this module.
+#[must_use]
+pub fn rules() -> Vec {
+ vec![
+ Rule {
+ id: "unknown-resource-type",
+ category: LintCategory::Resource,
+ tier: RuleTier::Resource,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.resource.unknownResourceTypeDescription").to_string(),
+ check: unknown_resource_type,
+ },
+ Rule {
+ id: "unsatisfied-require-version",
+ category: LintCategory::Resource,
+ tier: RuleTier::Resource,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.resource.unsatisfiedRequireVersionDescription").to_string(),
+ check: unsatisfied_require_version,
+ },
+ Rule {
+ id: "deprecated-resource",
+ category: LintCategory::Resource,
+ tier: RuleTier::Resource,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.resource.deprecatedResourceDescription").to_string(),
+ check: deprecated_resource,
+ },
+ Rule {
+ id: "property-schema-mismatch",
+ category: LintCategory::Resource,
+ tier: RuleTier::Resource,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.resource.propertySchemaMismatchDescription").to_string(),
+ check: property_schema_mismatch,
+ },
+ Rule {
+ id: "nested-resources-unsupported",
+ category: LintCategory::Resource,
+ tier: RuleTier::Resource,
+ default_level: RuleLevel::Warning,
+ description: || t!("lint.rules.resource.nestedResourcesUnsupportedDescription").to_string(),
+ check: nested_resources_unsupported,
+ },
+ Rule {
+ id: "exist-false-unsupported",
+ category: LintCategory::Resource,
+ tier: RuleTier::Resource,
+ default_level: RuleLevel::Error,
+ description: || t!("lint.rules.resource.existFalseUnsupportedDescription").to_string(),
+ check: exist_false_unsupported,
+ },
+ Rule {
+ id: "canonical-type-name",
+ category: LintCategory::Resource,
+ tier: RuleTier::Resource,
+ default_level: RuleLevel::Info,
+ description: || t!("lint.rules.resource.canonicalTypeNameDescription").to_string(),
+ check: canonical_type_name,
+ },
+ ]
+}
+
+/// The resource declarations paired with what discovery found for them.
+///
+/// # Arguments
+///
+/// * `context` - The lint context.
+///
+/// # Returns
+///
+/// One entry per resource declaration, or an empty list when discovery didn't run.
+fn resolved<'a>(context: &'a LintContext<'_>) -> Vec<(usize, &'a ResolvedResource)> {
+ context.discovered.as_ref().map_or_else(Vec::new, |discovered| discovered.iter().enumerate().collect())
+}
+
+/// The pointer to the version requirement of a resource, whichever key the author used.
+///
+/// # Arguments
+///
+/// * `context` - The lint context.
+/// * `index` - The index of the resource in the context's resource list.
+fn version_pointer(context: &LintContext, index: usize) -> JsonPointer {
+ let site = &context.resources[index];
+ if context.raw_resource(index).is_some_and(|resource| resource.get("apiVersion").is_some()) {
+ site.pointer.push("apiVersion")
+ } else {
+ site.pointer.push("requireVersion")
+ }
+}
+
+/// The schema for a resource's properties without invoking the resource: the schema discovery
+/// loaded, or the schema embedded in the manifest.
+///
+/// # Arguments
+///
+/// * `resource` - The discovered resource.
+fn embedded_schema(resource: &DscResource) -> Option {
+ if let Some(schema) = &resource.schema {
+ return Some(Value::Object(schema.clone()));
+ }
+ if let Some(SchemaKind::Embedded(schema)) = resource.manifest.as_ref().and_then(|manifest| manifest.schema.as_ref()) {
+ return Some(schema.clone());
+ }
+ None
+}
+
+/// Report resource types that aren't installed.
+fn unknown_resource_type(context: &LintContext, sink: &mut DiagnosticSink) {
+ for (index, resolved) in resolved(context) {
+ if resolved.found.is_none() && !resolved.type_exists {
+ let site = &context.resources[index];
+ sink.report(&site.pointer.push("type"), t!("lint.rules.resource.unknownResourceType", type_name = site.resource.resource_type).to_string());
+ }
+ }
+}
+
+/// Report version requirements that no installed version of the type satisfies.
+fn unsatisfied_require_version(context: &LintContext, sink: &mut DiagnosticSink) {
+ for (index, resolved) in resolved(context) {
+ if resolved.found.is_none() && resolved.type_exists
+ && let Some(version) = &context.resources[index].resource.require_version {
+ sink.report(
+ &version_pointer(context, index),
+ t!(
+ "lint.rules.resource.unsatisfiedRequireVersion",
+ type_name = context.resources[index].resource.resource_type,
+ version = version
+ ).to_string(),
+ );
+ }
+ }
+}
+
+/// Report resources whose type is deprecated.
+fn deprecated_resource(context: &LintContext, sink: &mut DiagnosticSink) {
+ for (index, resolved) in resolved(context) {
+ if let Some(message) = resolved.found.as_ref().and_then(|found| found.deprecation_message.as_ref()) {
+ let site = &context.resources[index];
+ sink.report(
+ &site.pointer.push("type"),
+ t!("lint.rules.resource.deprecatedResource", type_name = site.resource.resource_type, message = message).to_string(),
+ );
+ }
+ }
+}
+
+/// Report property values that don't validate against the resource's embedded schema.
+///
+/// Violations at or below a property that holds an expression are dropped, because the value
+/// is only known at runtime.
+fn property_schema_mismatch(context: &LintContext, sink: &mut DiagnosticSink) {
+ for (index, resolved) in resolved(context) {
+ let Some(found) = &resolved.found else {
+ continue;
+ };
+ if found.implemented_as != Some(ImplementedAs::Command) {
+ continue;
+ }
+ let Some(schema) = embedded_schema(found) else {
+ continue;
+ };
+ let site = &context.resources[index];
+ let base = site.pointer.push("properties");
+ let Some(properties) = base.resolve(context.raw) else {
+ continue;
+ };
+ let violations = match validate_json_all(found.type_name.as_ref(), &schema, properties) {
+ Ok(violations) => violations,
+ Err(err) => {
+ sink.report(
+ &base,
+ t!("lint.rules.resource.propertySchemaCompileFailed", type_name = site.resource.resource_type, error = LintContext::error_message(&err)).to_string(),
+ );
+ continue;
+ },
+ };
+ for violation in violations {
+ let pointer = base.join(&violation.instance_path);
+ let in_expression = context.expressions.sites().iter().any(|expression| {
+ expression.scope == ExpressionScope::Resource(index) && pointer.starts_with(&expression.pointer)
+ });
+ if in_expression {
+ continue;
+ }
+ sink.report(
+ &pointer,
+ t!("lint.rules.resource.propertySchemaMismatch", type_name = site.resource.resource_type, message = violation.message).to_string(),
+ );
+ }
+ }
+}
+
+/// Report nested resources under a type that isn't a group or adapter.
+fn nested_resources_unsupported(context: &LintContext, sink: &mut DiagnosticSink) {
+ for (index, resolved) in resolved(context) {
+ let site = &context.resources[index];
+ if site.resource.resources.is_none() {
+ continue;
+ }
+ let Some(found) = &resolved.found else {
+ continue;
+ };
+ if !matches!(found.kind, Kind::Group | Kind::Adapter) {
+ sink.report(
+ &site.pointer.push("resources"),
+ t!(
+ "lint.rules.resource.nestedResourcesUnsupported",
+ name = site.resource.name,
+ type_name = site.resource.resource_type,
+ kind = format!("{:?}", found.kind).to_lowercase()
+ ).to_string(),
+ );
+ }
+ }
+}
+
+/// Report `_exist: false` on a type that can't delete instances.
+fn exist_false_unsupported(context: &LintContext, sink: &mut DiagnosticSink) {
+ for (index, resolved) in resolved(context) {
+ let site = &context.resources[index];
+ let exists = site.resource.properties.as_ref().and_then(|properties| properties.get("_exist"));
+ if exists != Some(&Value::Bool(false)) {
+ continue;
+ }
+ let Some(found) = &resolved.found else {
+ continue;
+ };
+ let can_delete = found.capabilities.contains(&Capability::Delete) || found.capabilities.contains(&Capability::SetHandlesExist);
+ if !can_delete {
+ sink.report(
+ &site.pointer.push("properties").push("_exist"),
+ t!("lint.rules.resource.existFalseUnsupported", type_name = site.resource.resource_type).to_string(),
+ );
+ }
+ }
+}
+
+/// Report resource types written with different casing than their manifest.
+fn canonical_type_name(context: &LintContext, sink: &mut DiagnosticSink) {
+ for (index, resolved) in resolved(context) {
+ let Some(found) = &resolved.found else {
+ continue;
+ };
+ let site = &context.resources[index];
+ let written: &str = site.resource.resource_type.as_ref();
+ let canonical: &str = found.type_name.as_ref();
+ if written != canonical {
+ sink.report(
+ &site.pointer.push("type"),
+ t!("lint.rules.resource.canonicalTypeName", type_name = written, canonical = canonical).to_string(),
+ );
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::{json, Value};
+
+ use crate::{
+ configure::config_doc::Configuration,
+ dscresources::dscresource::{Capability, DscResource, ImplementedAs},
+ dscresources::resource_manifest::Kind,
+ lint::{
+ context::{collect_resource_sites, LintContext, ResolvedResource},
+ diagnostic::{LintDiagnostic, LintLevel},
+ expression_index::ExpressionIndex,
+ options::LintOptions,
+ rule::DiagnosticSink,
+ },
+ parser::Statement,
+ types::FullyQualifiedTypeName,
+ };
+
+ use super::rules;
+
+ fn echo(kind: Kind, capabilities: Vec, schema: Option) -> DscResource {
+ DscResource {
+ type_name: FullyQualifiedTypeName::parse("Microsoft.DSC.Debug/Echo").unwrap(),
+ kind,
+ capabilities,
+ implemented_as: Some(ImplementedAs::Command),
+ schema: schema.and_then(|schema| schema.as_object().cloned()),
+ ..DscResource::new()
+ }
+ }
+
+ fn run(document: &str, discovered: Vec, rule_id: &str) -> Vec {
+ let raw: Value = serde_yaml::from_str(document).unwrap();
+ let config: Configuration = serde_json::from_value(raw.clone()).unwrap();
+ let options = LintOptions::default();
+ let mut parser = Statement::new().unwrap();
+ let sites = collect_resource_sites(&config);
+ let expressions = ExpressionIndex::build(&raw, &sites, &mut parser);
+ let context = LintContext {
+ raw: &raw,
+ config: &config,
+ resources: sites,
+ expressions,
+ options: &options,
+ functions: parser.function_dispatcher(),
+ supplied_parameters: None,
+ discovered: Some(discovered),
+ };
+ let rule = rules().into_iter().find(|rule| rule.id == rule_id).unwrap();
+ let mut diagnostics = Vec::new();
+ let mut sink = DiagnosticSink::new(rule.id, rule.category, LintLevel::Error, &mut diagnostics);
+ (rule.check)(&context, &mut sink);
+ diagnostics
+ }
+
+ fn paths(diagnostics: &[LintDiagnostic]) -> Vec<&str> {
+ diagnostics.iter().map(|diagnostic| diagnostic.path.as_str()).collect()
+ }
+
+ const DOCUMENT: &str = r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: a
+ type: microsoft.dsc.debug/echo
+ requireVersion: "^9.0"
+ properties:
+ output: literal
+ dynamic: "[parameters('x')]"
+ _exist: false
+ resources:
+ - name: nested
+ type: Microsoft.DSC.Debug/Echo
+"#;
+
+ #[test]
+ fn reports_unknown_types_and_unsatisfied_versions() {
+ let discovered = vec![
+ ResolvedResource { found: None, type_exists: true },
+ ResolvedResource { found: None, type_exists: false },
+ ];
+ assert_eq!(paths(&run(DOCUMENT, discovered, "unknown-resource-type")), vec!["/resources/0/resources/0/type"]);
+ let discovered = vec![
+ ResolvedResource { found: None, type_exists: true },
+ ResolvedResource { found: None, type_exists: false },
+ ];
+ assert_eq!(paths(&run(DOCUMENT, discovered, "unsatisfied-require-version")), vec!["/resources/0/requireVersion"]);
+ }
+
+ #[test]
+ fn reports_deprecated_resources_and_casing() {
+ let mut deprecated = echo(Kind::Resource, vec![Capability::Get], None);
+ deprecated.deprecation_message = Some("use something else".to_string());
+ let discovered = vec![
+ ResolvedResource { found: Some(deprecated), type_exists: true },
+ ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], None)), type_exists: true },
+ ];
+ assert_eq!(paths(&run(DOCUMENT, discovered, "deprecated-resource")), vec!["/resources/0/type"]);
+ let discovered = vec![
+ ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], None)), type_exists: true },
+ ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], None)), type_exists: true },
+ ];
+ assert_eq!(paths(&run(DOCUMENT, discovered, "canonical-type-name")), vec!["/resources/0/type"]);
+ }
+
+ #[test]
+ fn reports_schema_violations_outside_expressions() {
+ let schema = json!({
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "output": { "type": "integer" },
+ "dynamic": { "type": "integer" },
+ "_exist": { "type": "boolean" }
+ }
+ });
+ let discovered = vec![
+ ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], Some(schema))), type_exists: true },
+ ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], None)), type_exists: true },
+ ];
+ assert_eq!(paths(&run(DOCUMENT, discovered, "property-schema-mismatch")), vec!["/resources/0/properties/output"]);
+ }
+
+ #[test]
+ fn reports_nested_resources_and_exist_false_on_unsupported_types() {
+ let discovered = vec![
+ ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get, Capability::Set], None)), type_exists: true },
+ ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], None)), type_exists: true },
+ ];
+ assert_eq!(paths(&run(DOCUMENT, discovered, "nested-resources-unsupported")), vec!["/resources/0/resources"]);
+ let discovered = vec![
+ ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get, Capability::Set], None)), type_exists: true },
+ ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], None)), type_exists: true },
+ ];
+ assert_eq!(paths(&run(DOCUMENT, discovered, "exist-false-unsupported")), vec!["/resources/0/properties/_exist"]);
+ let discovered = vec![
+ ResolvedResource { found: Some(echo(Kind::Group, vec![Capability::Get, Capability::Delete], None)), type_exists: true },
+ ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], None)), type_exists: true },
+ ];
+ assert!(run(DOCUMENT, discovered, "nested-resources-unsupported").is_empty());
+ let discovered = vec![
+ ResolvedResource { found: Some(echo(Kind::Group, vec![Capability::Get, Capability::Delete], None)), type_exists: true },
+ ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], None)), type_exists: true },
+ ];
+ assert!(run(DOCUMENT, discovered, "exist-false-unsupported").is_empty());
+ }
+}
diff --git a/lib/dsc-lib/src/parser/expressions.rs b/lib/dsc-lib/src/parser/expressions.rs
index 5071da666..514b8abee 100644
--- a/lib/dsc-lib/src/parser/expressions.rs
+++ b/lib/dsc-lib/src/parser/expressions.rs
@@ -204,4 +204,16 @@ impl Expression {
Ok(value)
}
}
+
+ /// Get the function the expression invokes.
+ #[must_use]
+ pub fn function(&self) -> &Function {
+ &self.function
+ }
+
+ /// Get the accessors applied to the function result, in the order they are applied.
+ #[must_use]
+ pub fn accessors(&self) -> &[Accessor] {
+ &self.accessors
+ }
}
diff --git a/lib/dsc-lib/src/parser/functions.rs b/lib/dsc-lib/src/parser/functions.rs
index 68c68c74b..194cc599b 100644
--- a/lib/dsc-lib/src/parser/functions.rs
+++ b/lib/dsc-lib/src/parser/functions.rs
@@ -147,6 +147,12 @@ impl Function {
pub fn name(&self) -> &str {
&self.name
}
+
+ /// Get the arguments passed to the function, if any were written.
+ #[must_use]
+ pub fn args(&self) -> Option<&[FunctionArg]> {
+ self.args.as_deref()
+ }
}
fn convert_args_node(statement_bytes: &[u8], args: Option<&Node>) -> Result>, DscError> {
diff --git a/lib/dsc-lib/src/parser/mod.rs b/lib/dsc-lib/src/parser/mod.rs
index eb67e3dce..790df9177 100644
--- a/lib/dsc-lib/src/parser/mod.rs
+++ b/lib/dsc-lib/src/parser/mod.rs
@@ -19,6 +19,17 @@ pub struct Statement {
function_dispatcher: FunctionDispatcher,
}
+/// The result of parsing a statement without evaluating it.
+#[derive(Clone, Debug)]
+pub enum ParsedStatement {
+ /// The statement is a plain string that doesn't contain an expression.
+ Literal(String),
+ /// The statement started with `[[`; the value has the escape character removed.
+ Escaped(String),
+ /// The statement is an expression that can be invoked.
+ Expression(Expression),
+}
+
impl Statement {
/// Create a new `StatementParser` instance.
///
@@ -56,6 +67,29 @@ impl Statement {
return Ok(Value::String(statement.to_string()));
}
+ match self.parse(statement)? {
+ ParsedStatement::Literal(value) | ParsedStatement::Escaped(value) => Ok(Value::String(value)),
+ ParsedStatement::Expression(expression) => expression.invoke(&self.function_dispatcher, context),
+ }
+ }
+
+ /// Parse a statement without evaluating it.
+ ///
+ /// Use this to inspect the expression tree of a statement, for example to find which
+ /// functions it calls, without needing a [`Context`] and without side effects.
+ ///
+ /// # Arguments
+ ///
+ /// * `statement` - The statement to parse.
+ ///
+ /// # Returns
+ ///
+ /// The parsed statement: a literal, an escaped literal, or an expression tree.
+ ///
+ /// # Errors
+ ///
+ /// This function will return an error if the statement fails to parse.
+ pub fn parse(&mut self, statement: &str) -> Result {
let Some(tree) = &mut self.parser.parse(statement, None) else {
return Err(DscError::Parser(t!("parser.failedToParse", statement = statement).to_string()));
};
@@ -68,19 +102,19 @@ impl Statement {
}
let statement_bytes = statement.as_bytes();
let mut cursor = root_node.walk();
- let mut return_value = Value::Null;
+ let mut parsed = ParsedStatement::Literal(statement.to_string());
for child_node in root_node.named_children(&mut cursor) {
if child_node.is_error() {
return Err(DscError::Parser(t!("parser.failedToParse", statement = statement).to_string()));
}
- match child_node.kind() {
+ parsed = match child_node.kind() {
"stringLiteral" => {
let Ok(value) = child_node.utf8_text(statement_bytes) else {
return Err(DscError::Parser(t!("parser.failedToParseStringLiteral").to_string()));
};
debug!("{}", t!("parser.parsingStringLiteral", value = value.to_string()));
- return_value = Value::String(value.to_string());
+ ParsedStatement::Literal(value.to_string())
},
"escapedStringLiteral" => {
// need to remove the first character: [[ => [
@@ -88,20 +122,25 @@ impl Statement {
return Err(DscError::Parser(t!("parser.failedToParseEscapedStringLiteral").to_string()));
};
debug!("{}", t!("parser.parsingEscapedStringLiteral", value = value[1..].to_string()));
- return_value = Value::String(value[1..].to_string());
+ ParsedStatement::Escaped(value[1..].to_string())
},
"expression" => {
debug!("{}", t!("parser.parsingExpression"));
- let expression = Expression::new(statement_bytes, &child_node)?;
- return_value = expression.invoke(&self.function_dispatcher, context)?;
+ ParsedStatement::Expression(Expression::new(statement_bytes, &child_node)?)
},
_ => {
return Err(DscError::Parser(t!("parser.unknownExpressionType", kind = child_node.kind()).to_string()));
}
- }
+ };
}
- Ok(return_value)
+ Ok(parsed)
+ }
+
+ /// Get the function dispatcher used to evaluate expressions.
+ #[must_use]
+ pub fn function_dispatcher(&self) -> &FunctionDispatcher {
+ &self.function_dispatcher
}
}
@@ -141,6 +180,39 @@ mod tests {
assert!(result.is_err());
}
+ #[test]
+ fn parse_literal_without_evaluating() {
+ let mut parser = Statement::new().unwrap();
+ let result = parser.parse("plain text").unwrap();
+ assert!(matches!(result, ParsedStatement::Literal(value) if value == "plain text"));
+ }
+
+ #[test]
+ fn parse_escaped_literal_without_evaluating() {
+ let mut parser = Statement::new().unwrap();
+ let result = parser.parse("[[not an expression]").unwrap();
+ assert!(matches!(result, ParsedStatement::Escaped(value) if value == "[not an expression]"));
+ }
+
+ #[test]
+ fn parse_expression_without_evaluating() {
+ let mut parser = Statement::new().unwrap();
+ // `parameters('x')` would fail to evaluate without a context, but parsing must succeed
+ let result = parser.parse("[parameters('x').member]").unwrap();
+ let ParsedStatement::Expression(expression) = result else {
+ panic!("expected an expression");
+ };
+ assert_eq!(expression.function().name(), "parameters");
+ assert_eq!(expression.function().args().map(<[_]>::len), Some(1));
+ assert_eq!(expression.accessors().len(), 1);
+ }
+
+ #[test]
+ fn parse_invalid_expression_fails() {
+ let mut parser = Statement::new().unwrap();
+ assert!(parser.parse("[concat('abc)]").is_err());
+ }
+
#[test]
fn invalid_function() {
let mut parser = Statement::new().unwrap();
diff --git a/lib/dsc-lib/src/util.rs b/lib/dsc-lib/src/util.rs
index a291f8d55..e0e3c1d4b 100644
--- a/lib/dsc-lib/src/util.rs
+++ b/lib/dsc-lib/src/util.rs
@@ -30,6 +30,34 @@ impl Default for DscSettingValue {
}
}
+/// Parse a JSON or YAML string into a JSON value.
+///
+/// JSON is tried first; if that fails the input is parsed as YAML.
+///
+/// # Arguments
+///
+/// * `value` - A string slice that holds the input value
+///
+/// # Returns
+///
+/// The parsed value
+///
+/// # Errors
+///
+/// Returns `DscError::Yaml` with the YAML parser error when the input is neither valid JSON nor
+/// valid YAML.
+pub fn parse_input_to_value(value: &str) -> Result {
+ match serde_json::from_str::(value) {
+ Ok(json) => Ok(json),
+ Err(_) => {
+ match serde_yaml::from_str::(value) {
+ Ok(yaml) => Ok(yaml),
+ Err(err) => Err(DscError::Yaml(err)),
+ }
+ }
+ }
+}
+
/// Return JSON string whether the input is JSON or YAML
///
/// # Arguments
diff --git a/lib/dsc-lib/tests/integration/schemas/schema_for.rs b/lib/dsc-lib/tests/integration/schemas/schema_for.rs
index 4bcff527c..c228eca7e 100644
--- a/lib/dsc-lib/tests/integration/schemas/schema_for.rs
+++ b/lib/dsc-lib/tests/integration/schemas/schema_for.rs
@@ -132,6 +132,17 @@ macro_rules! test_schema_for {
}
}
+ #[cfg(test)] mod lint {
+ #[allow(unused_must_use)]
+ #[cfg(test)] mod diagnostic {
+ test_schema_for!(dsc_lib::lint::diagnostic::LintResult);
+ }
+ #[allow(unused_must_use)]
+ #[cfg(test)] mod rule {
+ test_schema_for!(dsc_lib::lint::rule::LintRuleInfo);
+ }
+ }
+
#[cfg(test)] mod extensions {
#[allow(unused_must_use)]
#[cfg(test)] mod discover {
diff --git a/xtask/src/schemas/export.rs b/xtask/src/schemas/export.rs
index fdfb4a8b8..f90a64608 100644
--- a/xtask/src/schemas/export.rs
+++ b/xtask/src/schemas/export.rs
@@ -149,6 +149,8 @@ pub(crate) fn export_schemas(
dsc_lib::functions::FunctionArgKind,
dsc_lib::functions::FunctionCategory,
dsc_lib::functions::FunctionDefinition,
+ dsc_lib::lint::diagnostic::LintResult,
+ dsc_lib::lint::rule::LintRuleInfo,
dsc_lib::types::DateVersion,
dsc_lib::types::ExitCodesMap,
dsc_lib::types::FullyQualifiedTypeName,
From 5b64b44dfc9ee7db7c4060064795f47fb9d20749 Mon Sep 17 00:00:00 2001
From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com>
Date: Thu, 22 Oct 2026 00:49:57 +0200
Subject: [PATCH 2/6] Resolve Copilot remarks
---
CHANGELOG.md | 6 +-
docs/reference/cli/lint/rules.md | 37 ++++--
dsc/locales/en-us.toml | 5 +
dsc/src/server/lint_dsc_config.rs | 118 +++++++++++++++++
dsc/src/server/mcp_server.rs | 1 +
dsc/src/server/mod.rs | 1 +
dsc/tests/dsc_server.tests.ps1 | 60 +++++++++
lib/dsc-lib/locales/en-us.toml | 6 +-
lib/dsc-lib/src/dscresources/dscresource.rs | 41 ++++--
lib/dsc-lib/src/lint/context.rs | 24 ++++
lib/dsc-lib/src/lint/expression_index.rs | 96 ++++++++++++--
lib/dsc-lib/src/lint/rules/dependency.rs | 115 +++++++++++++++-
lib/dsc-lib/src/lint/rules/expression.rs | 71 ++++++++--
lib/dsc-lib/src/lint/rules/parameter.rs | 97 ++++++++++++--
lib/dsc-lib/src/lint/rules/resource.rs | 137 ++++++++++++++++++--
15 files changed, 750 insertions(+), 65 deletions(-)
create mode 100644 dsc/src/server/lint_dsc_config.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cab2b748d..45f8f17f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -85,14 +85,15 @@ changes since the last release, see the [diff on GitHub][unreleased].
resource types that aren't installed or literal properties that don't match a resource's schema.
You can change the level of a rule for a run with the `--rule` option or for every run in the
new `lint` section of the settings file, and control which level makes the command fail with
- `--fail-on`. The `dsc schema` command exposes the new `lint-result` and `lint-rules` schemas.
+ `--fail-on`. The `dsc schema` command exposes the new `lint-result` and `lint-rules` schemas,
+ and the MCP server exposes the linter as the `lint_dsc_config` tool.
Related work items
- Issues:
- [#591][#591]
- [#842][#842]
- - PRs: _None_
+ - PRs: [#1768][#1768]
@@ -2021,4 +2022,5 @@ Version `3.0.0` is the first generally available release of DSC.
[#1558]: https://github.com/PowerShell/DSC/issues/1558
[#1562]: https://github.com/PowerShell/DSC/issues/1562
[#1729]: https://github.com/PowerShell/DSC/issues/1729
+[#1768]: https://github.com/PowerShell/DSC/issues/1768
diff --git a/docs/reference/cli/lint/rules.md b/docs/reference/cli/lint/rules.md
index 79b0cf21a..ae0a60c75 100644
--- a/docs/reference/cli/lint/rules.md
+++ b/docs/reference/cli/lint/rules.md
@@ -130,10 +130,20 @@ enable it to require reproducible documents.
## Expression rules
+The expression rules check strings in the places DSC evaluates them: parameter default values,
+variables, output values and conditions, user-defined function bodies, and the name, condition,
+`dependsOn` entries, copy settings, and properties of each resource. A parameter default value or
+variable is only an expression when the whole value is a string; DSC uses object and array values
+as written, so strings nested in them aren't checked. The properties of a group resource, such as
+`Microsoft.DSC/Group`, hold an embedded configuration document that DSC evaluates separately, so
+they aren't checked against the outer document.
+
### invalid-expression
Every string that starts with `[` is evaluated as an expression and must parse. To use a literal
-string that starts with a bracket, escape it with `[[`.
+string that starts with a bracket, escape it with `[[`. When the expression is the default value of
+a `secureString` or `secureObject` parameter, the diagnostic omits the expression and the parser
+error, because they can contain a secret.
```yaml
properties:
@@ -184,7 +194,8 @@ Declared variables should be referenced by at least one expression.
### parameter-default-type
A literal default value must satisfy the parameter's type, `allowedValues`, value limits, and
-length limits. Default values that are expressions aren't checked.
+length limits. Default values that are expressions aren't checked. An escaped string such as
+`"[[x]"` is checked as the value DSC uses, `"[x]"`.
### invalid-parameter-constraints
@@ -205,8 +216,11 @@ A `string` or `object` parameter whose name suggests a secret, such as `password
### secure-value-in-output
-Outputs shouldn't expose a secure parameter or a `secret()` value. DSC skips such outputs at
-runtime.
+Outputs shouldn't use a secure parameter or a `secret()` value. DSC only withholds outputs whose
+declared type is `secureString` or `secureObject`; it doesn't track where a value came from. An
+output of any other type that extracts a plain value, such as
+`[parameters('password').secureString]`, returns that value in the result. Outputs declared as
+`secureString` or `secureObject` aren't reported, because DSC never returns them.
### use-parameter-description
@@ -226,6 +240,8 @@ parameters on purpose.
### parameter-value-type
When you pass parameters, every supplied value must satisfy the parameter's type and constraints.
+A `secureString` value must be supplied as a plain string, not in the wrapped
+`{"secureString": "..."}` form that a default value can use.
## Dependency rules
@@ -234,7 +250,9 @@ When you pass parameters, every supplied value must satisfy the parameter's type
Every `dependsOn` entry and every `reference(resourceId(...))` call must point at a resource
declared at the same level. The diagnostic distinguishes a missing resource from a resource whose
type doesn't match. Resources in copy loops aren't checked, because their names are only known at
-runtime.
+runtime. `dependsOn` entries match resource names as written, the way DSC resolves them. A
+`reference()` call matches the name DSC evaluates, so it isn't reported when a resource at the same
+level has a name that is an expression.
```yaml
resources:
@@ -326,9 +344,12 @@ includes the deprecation message from the manifest.
### property-schema-mismatch
-Literal property values must validate against the resource's embedded schema. Properties that hold
-expressions aren't checked, because their value is only known at runtime. The rule never invokes
-the resource to retrieve its schema.
+Literal property values must validate against the resource's embedded schema. Escaped strings such
+as `"[[x]"` are validated as the value DSC passes to the resource, `"[x]"`. Properties that hold
+expressions aren't checked, because their value is only known at runtime. When an expression sits
+inside an `anyOf` or `oneOf`, the rule only reports the failure if every alternative fails for a
+reason the expression can't change, such as a literal sibling with the wrong type. The rule never
+invokes the resource to retrieve its schema.
### nested-resources-unsupported
diff --git a/dsc/locales/en-us.toml b/dsc/locales/en-us.toml
index 0a0c9640f..ad52059ee 100644
--- a/dsc/locales/en-us.toml
+++ b/dsc/locales/en-us.toml
@@ -123,6 +123,11 @@ functionInvocationFailed = "Function '%{function}' invocation failed: %{error}"
resourceNotFound = "Resource type '%{resource}' does not exist"
whatIfNotSupported = "what_if is only supported for the 'set' and 'delete' operations"
+[server.lint_dsc_config]
+invalidParameters = "Invalid parameters"
+failedConvertJson = "Failed to convert to JSON"
+lintFailed = "Failed to lint the configuration"
+
[server.list_dsc_functions]
invalidNamePattern = "Invalid function name pattern '%{pattern}'"
invalidDescriptionPattern = "Invalid function description pattern '%{pattern}'"
diff --git a/dsc/src/server/lint_dsc_config.rs b/dsc/src/server/lint_dsc_config.rs
new file mode 100644
index 000000000..a4e7e28e8
--- /dev/null
+++ b/dsc/src/server/lint_dsc_config.rs
@@ -0,0 +1,118 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use std::collections::HashMap;
+
+use crate::server::mcp_server::McpServer;
+use crate::util::current_dsc_version;
+use dsc_lib::{
+ dscerror::DscError,
+ lint::{LintError, LintOptions, LintResult, Linter, RuleLevel, RuleLevelOverride},
+ progress::ProgressFormat,
+};
+use rmcp::{handler::server::wrapper::Parameters, tool, tool_router, ErrorData as McpError, Json};
+use rust_i18n::t;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use tokio::task;
+
+#[derive(Serialize, JsonSchema)]
+pub struct LintDscConfigResponse {
+ pub result: LintResult,
+}
+
+#[derive(Deserialize, JsonSchema)]
+pub struct LintDscConfigRequest {
+ #[schemars(description = "The DSC configuration document as a JSON or YAML string")]
+ pub configuration: String,
+ #[schemars(
+ description = "Optional parameters to check against the configuration as a JSON or YAML string, with or without a top-level 'parameters' wrapper"
+ )]
+ pub parameters: Option,
+ #[schemars(
+ description = "When false, skip resource discovery and the rules that inspect installed resources. Defaults to true."
+ )]
+ #[serde(default)]
+ pub discovery: Option,
+ #[schemars(description = "Optional rule levels for this run, keyed by rule identifier")]
+ #[serde(default)]
+ pub rules: Option>,
+}
+
+/// Parse the parameters of a lint request into the input format `dsc config` accepts.
+///
+/// # Arguments
+///
+/// * `parameters` - The parameters as JSON or YAML, with or without a top-level `parameters` wrapper.
+///
+/// # Errors
+///
+/// Returns an invalid-request error when the text isn't valid JSON or YAML.
+fn parse_parameters(parameters: &str) -> Result {
+ let yaml = serde_yaml::from_str::(parameters)
+ .map_err(|e| McpError::invalid_request(format!("{}: {e}", t!("server.lint_dsc_config.invalidParameters")), None))?;
+ let json = serde_json::to_value(yaml)
+ .map_err(|e| McpError::invalid_request(format!("{}: {e}", t!("server.lint_dsc_config.failedConvertJson")), None))?;
+ if json.get("parameters").is_some_and(Value::is_object) {
+ Ok(json)
+ } else {
+ Ok(serde_json::json!({ "parameters": json }))
+ }
+}
+
+/// Convert a linter error into the MCP error for it.
+///
+/// # Arguments
+///
+/// * `error` - The error from the linter.
+fn lint_error(error: DscError) -> McpError {
+ let message = format!("{}: {error}", t!("server.lint_dsc_config.lintFailed"));
+ match error {
+ DscError::Lint(LintError::UnknownRule(_)) => McpError::invalid_params(message, None),
+ DscError::Lint(LintError::InvalidParameters(_)) => McpError::invalid_request(message, None),
+ _ => McpError::internal_error(message, None),
+ }
+}
+
+#[tool_router(router = lint_dsc_config_router, vis = "pub")]
+impl McpServer {
+ #[tool(
+ description = "Lint a DSC configuration document without applying it. Returns diagnostics for problems such as invalid expressions, undeclared parameters, unresolved dependencies, and, with discovery, property values that don't match the resource schema.",
+ annotations(
+ title = "Lint a DSC configuration document",
+ read_only_hint = true,
+ destructive_hint = false,
+ idempotent_hint = true,
+ open_world_hint = false,
+ )
+ )]
+ pub async fn lint_dsc_config(
+ &self,
+ Parameters(LintDscConfigRequest {
+ configuration,
+ parameters,
+ discovery,
+ rules,
+ }): Parameters,
+ ) -> Result, McpError> {
+ let result = task::spawn_blocking(move || {
+ let parameters = parameters.as_deref().map(parse_parameters).transpose()?;
+ let rule_levels = rules.unwrap_or_default().into_iter().map(|(id, level)| RuleLevelOverride { id, level }).collect();
+ let options = LintOptions {
+ discovery: discovery.unwrap_or(true),
+ rule_levels,
+ parameters,
+ dsc_version: Some(current_dsc_version()),
+ progress_format: ProgressFormat::None,
+ ..LintOptions::default()
+ };
+ let mut linter = Linter::new(options).map_err(lint_error)?;
+ linter.lint(&configuration).map_err(lint_error)
+ })
+ .await
+ .map_err(|e| McpError::internal_error(e.to_string(), None))??;
+
+ Ok(Json(LintDscConfigResponse { result }))
+ }
+}
diff --git a/dsc/src/server/mcp_server.rs b/dsc/src/server/mcp_server.rs
index 951b5cd14..a76351cf1 100644
--- a/dsc/src/server/mcp_server.rs
+++ b/dsc/src/server/mcp_server.rs
@@ -25,6 +25,7 @@ impl McpServer {
+ Self::invoke_dsc_expression_router()
+ Self::invoke_dsc_function_router()
+ Self::invoke_dsc_resource_router()
+ + Self::lint_dsc_config_router()
+ Self::list_dsc_functions_router()
+ Self::list_dsc_resources_router()
+ Self::show_dsc_resource_router()
diff --git a/dsc/src/server/mod.rs b/dsc/src/server/mod.rs
index b7c844b4d..e8e8509bd 100644
--- a/dsc/src/server/mod.rs
+++ b/dsc/src/server/mod.rs
@@ -13,6 +13,7 @@ pub mod invoke_dsc_config;
pub mod invoke_dsc_expression;
pub mod invoke_dsc_function;
pub mod invoke_dsc_resource;
+pub mod lint_dsc_config;
pub mod list_dsc_functions;
pub mod list_dsc_resources;
pub mod mcp_server;
diff --git a/dsc/tests/dsc_server.tests.ps1 b/dsc/tests/dsc_server.tests.ps1
index 3382a7a2a..bb4052096 100644
--- a/dsc/tests/dsc_server.tests.ps1
+++ b/dsc/tests/dsc_server.tests.ps1
@@ -75,6 +75,7 @@ Describe 'Tests for DSC server' {
'invoke_dsc_expression' = $false
'invoke_dsc_function' = $false
'invoke_dsc_resource' = $false
+ 'lint_dsc_config' = $false
'list_dsc_functions' = $false
'list_dsc_resources' = $false
'show_dsc_resource' = $false
@@ -1108,4 +1109,63 @@ resources:
$response.error.code | Should -Be -32600
$response.error.message | Should -BeExactly "Function 'add' invocation failed: Parser: Function 'add' does not accept string arguments, accepted types are: Number"
}
+
+ It 'Calling lint_dsc_config returns diagnostics' {
+ $configuration = @'
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+parameters:
+ unused:
+ type: string
+ defaultValue: x
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "[parameters('missing')]"
+'@
+ $mcpRequest = @{
+ jsonrpc = "2.0"
+ id = 39
+ method = "tools/call"
+ params = @{
+ name = "lint_dsc_config"
+ arguments = @{
+ configuration = $configuration
+ discovery = $false
+ rules = @{ 'no-unused-parameters' = 'info' }
+ }
+ }
+ }
+
+ $response = Send-McpRequest -request $mcpRequest
+ $response.id | Should -Be 39
+ $result = $response.result.structuredContent.result
+ $result.valid | Should -BeFalse -Because ($response | ConvertTo-Json -Depth 20 | Out-String)
+ $result.discovery | Should -BeFalse
+ $undefined = $result.diagnostics | Where-Object rule -EQ 'undefined-parameter'
+ $undefined.path | Should -BeExactly '/resources/0/properties/output'
+ $unused = $result.diagnostics | Where-Object rule -EQ 'no-unused-parameters'
+ $unused.level | Should -BeExactly 'info'
+ }
+
+ It 'Calling lint_dsc_config with an unknown rule returns error' {
+ $mcpRequest = @{
+ jsonrpc = "2.0"
+ id = 40
+ method = "tools/call"
+ params = @{
+ name = "lint_dsc_config"
+ arguments = @{
+ configuration = '{"$schema": "https://aka.ms/dsc/schemas/v3/bundled/config/document.json", "resources": []}'
+ discovery = $false
+ rules = @{ 'no-such-rule' = 'off' }
+ }
+ }
+ }
+
+ $response = Send-McpRequest -request $mcpRequest
+ $response.id | Should -Be 40
+ $response.error.code | Should -Be -32602
+ $response.error.message | Should -Match 'no-such-rule'
+ }
}
diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml
index 380b729db..bc3d57520 100644
--- a/lib/dsc-lib/locales/en-us.toml
+++ b/lib/dsc-lib/locales/en-us.toml
@@ -1141,6 +1141,7 @@ missingRequireVersion = "Resource '%{name}' doesn't pin the version of '%{type_n
[lint.rules.expression]
invalidExpressionDescription = "Strings that start with '[' must be valid expressions; escape a literal bracket with '[['."
invalidExpression = "Expression '%{expression}' failed to parse: %{error}"
+invalidExpressionInSecureParameter = "The default value of secure parameter '%{name}' isn't a valid expression; the expression isn't shown because it can hold a secret"
unknownFunctionDescription = "Expressions may only call built-in functions or user-defined functions declared in 'functions'."
unknownFunction = "Unknown function '%{name}'"
unknownFunctionSuggestion = "Unknown function '%{name}'; function names are case-sensitive, did you mean '%{suggestion}'?"
@@ -1162,6 +1163,7 @@ noUnusedVariables = "Variable '%{name}' is declared but never used"
parameterDefaultTypeDescription = "A literal default value must satisfy the parameter's type and constraints."
parameterDefaultType = "The default value of parameter '%{name}' is invalid: %{reason}"
valueNotOfType = "the value isn't of type '%{type_name}'"
+secureStringNotPlain = "a 'secureString' value must be supplied as a plain string"
invalidParameterConstraintsDescription = "Parameter constraints must be consistent with each other and with the parameter type."
minValueExceedsMaxValue = "Parameter '%{name}' has a minValue of %{min} that is greater than its maxValue of %{max}"
minLengthExceedsMaxLength = "Parameter '%{name}' has a minLength of %{min} that is greater than its maxLength of %{max}"
@@ -1174,8 +1176,8 @@ secureParameterDefaultDescription = "Secure parameters shouldn't have a hard-cod
secureParameterDefault = "Secure parameter '%{name}' has a hard-coded default value"
preferSecureTypeDescription = "Parameters whose names suggest a secret should use the 'secureString' or 'secureObject' type."
preferSecureType = "Parameter '%{name}' looks like a secret; use type '%{secure_type}' instead of '%{type_name}'"
-secureValueInOutputDescription = "Outputs shouldn't expose secure values; DSC skips such outputs at runtime."
-secureValueInOutput = "Output '%{name}' exposes the secure value from %{source}, so DSC skips it at runtime"
+secureValueInOutputDescription = "Outputs shouldn't use secure values; DSC only withholds outputs declared as 'secureString' or 'secureObject', so a plain value extracted into any other output is returned in the result."
+secureValueInOutput = "Output '%{name}' uses the secure value from %{source}; because the output isn't declared as 'secureString' or 'secureObject', a plain value extracted from it is returned in the result"
useParameterDescriptionDescription = "Parameters should have a description."
useParameterDescription = "Parameter '%{name}' has no description"
undeclaredParameterValueDescription = "Supplied parameter values must match a declared parameter."
diff --git a/lib/dsc-lib/src/dscresources/dscresource.rs b/lib/dsc-lib/src/dscresources/dscresource.rs
index 4b5f8f677..785e1a0f0 100644
--- a/lib/dsc-lib/src/dscresources/dscresource.rs
+++ b/lib/dsc-lib/src/dscresources/dscresource.rs
@@ -6,7 +6,7 @@ use crate::discovery::discovery_trait::DiscoveryFilter;
use crate::dscresources::invoke_result::{ResourceGetResponse, ResourceSetResponse};
use crate::schemas::transforms::idiomaticize_string_enum;
use dscerror::DscError;
-use jsonschema::Validator;
+use jsonschema::{error::ValidationErrorKind, ValidationError, Validator};
use rust_i18n::t;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -945,14 +945,44 @@ pub struct SchemaViolation {
pub instance_path: String,
/// JSON pointer to the keyword in the schema that the instance violated.
pub schema_path: String,
+ /// The keyword that failed, such as `type` or `anyOf`.
+ pub keyword: String,
/// The validation error message.
pub message: String,
+ /// For a failed `anyOf`, or a `oneOf` that no subschema matched, the violations of each
+ /// subschema in order; empty for every other keyword.
+ pub alternatives: Vec>,
+}
+
+impl SchemaViolation {
+ /// Convert a validation error, including the violations of each `anyOf` or `oneOf`
+ /// subschema.
+ ///
+ /// # Arguments
+ ///
+ /// * `error` - The validation error.
+ fn from_error(error: &ValidationError<'_>) -> Self {
+ let alternatives = match error.kind() {
+ ValidationErrorKind::AnyOf { context } | ValidationErrorKind::OneOfNotValid { context } => {
+ context.iter().map(|branch| branch.iter().map(Self::from_error).collect()).collect()
+ },
+ _ => Vec::new(),
+ };
+ Self {
+ instance_path: error.instance_path().to_string(),
+ schema_path: error.schema_path().to_string(),
+ keyword: error.kind().keyword().to_string(),
+ message: error.to_string(),
+ alternatives,
+ }
+ }
}
/// Validate the JSON against the schema and return every violation.
///
/// Unlike [`validate_json`], which stops at the first error, this function collects all
-/// violations so callers can report each one with its location.
+/// violations so callers can report each one with its location. The JSON isn't traced because
+/// it can hold secure values.
///
/// # Arguments
///
@@ -969,7 +999,6 @@ pub struct SchemaViolation {
/// * `DscError::Validation` - The schema failed to compile
pub fn validate_json_all(source: &str, schema: &Value, json: &Value) -> Result, DscError> {
debug!("{}: {source}", t!("dscresources.dscresource.validatingSchema"));
- trace!("{}", t!("dscresources.dscresource.json", json = json));
trace!("{}", t!("dscresources.dscresource.schema", schema = schema));
let compiled_schema = match Validator::new(schema) {
Ok(compiled_schema) => compiled_schema,
@@ -978,11 +1007,7 @@ pub fn validate_json_all(source: &str, schema: &Value, json: &Value) -> Result {
pub parent: Option,
}
+/// The built-in group resources. They receive an embedded configuration document as their
+/// properties and evaluate its expressions themselves.
+const GROUP_RESOURCE_TYPES: [&str; 2] = ["Microsoft.DSC/Group", "Microsoft.DSC/Assertion"];
+
+impl ResourceSite<'_> {
+ /// Whether the resource's properties are an embedded configuration document, which DSC
+ /// passes to the resource without evaluating its expressions.
+ ///
+ /// Without discovery the resource kind isn't known, so this recognizes the built-in group
+ /// resources and any properties that declare a configuration document `$schema`.
+ #[must_use]
+ pub fn has_embedded_configuration(&self) -> bool {
+ let type_name: &str = self.resource.resource_type.as_ref();
+ if GROUP_RESOURCE_TYPES.iter().any(|group| group.eq_ignore_ascii_case(type_name)) {
+ return true;
+ }
+ self.resource.properties.as_ref()
+ .and_then(|properties| properties.get("$schema"))
+ .and_then(Value::as_str)
+ .is_some_and(|uri| Configuration::is_recognized_schema_uri(&uri.to_string()))
+ }
+}
+
/// The outcome of discovering the resource type a declaration uses.
pub struct ResolvedResource {
/// The installed resource that satisfies the declaration, if one was found.
diff --git a/lib/dsc-lib/src/lint/expression_index.rs b/lib/dsc-lib/src/lint/expression_index.rs
index 864c1b3ec..66fcf2715 100644
--- a/lib/dsc-lib/src/lint/expression_index.rs
+++ b/lib/dsc-lib/src/lint/expression_index.rs
@@ -59,7 +59,10 @@ impl ExpressionIndex {
///
/// Strings are walked in the places DSC evaluates expressions: parameter default values,
/// variables, outputs, user-defined function bodies, and for each resource its name,
- /// condition, `dependsOn` entries, copy counts, and properties.
+ /// condition, `dependsOn` entries, copy counts, and properties. Parameter default values and
+ /// variables are only expressions when the whole value is a string; DSC uses object and
+ /// array values as written. The properties of a resource that holds an embedded
+ /// configuration are skipped, because that configuration is evaluated on its own.
///
/// # Arguments
///
@@ -78,7 +81,7 @@ impl ExpressionIndex {
let base = root.push("parameters");
for (name, parameter) in parameters {
if let Some(default_value) = parameter.get("defaultValue") {
- index.walk(default_value, &base.push(name).push("defaultValue"), &ExpressionScope::Parameter(name.clone()), parser);
+ index.add_string(default_value, &base.push(name).push("defaultValue"), &ExpressionScope::Parameter(name.clone()), parser);
}
}
}
@@ -86,7 +89,7 @@ impl ExpressionIndex {
if let Some(variables) = raw.get("variables").and_then(Value::as_object) {
let base = root.push("variables");
for (name, variable) in variables {
- index.walk(variable, &base.push(name), &ExpressionScope::Variable(name.clone()), parser);
+ index.add_string(variable, &base.push(name), &ExpressionScope::Variable(name.clone()), parser);
}
}
@@ -123,6 +126,9 @@ impl ExpressionIndex {
};
let scope = ExpressionScope::Resource(position);
for key in ["name", "condition", "dependsOn", "properties"] {
+ if key == "properties" && site.has_embedded_configuration() {
+ continue;
+ }
if let Some(member) = resource.get(key) {
index.walk(member, &site.pointer.push(key), &scope, parser);
}
@@ -139,6 +145,27 @@ impl ExpressionIndex {
index
}
+ /// Parse a value when it is a string that starts with `[`, without looking inside objects
+ /// or arrays.
+ ///
+ /// # Arguments
+ ///
+ /// * `value` - The value.
+ /// * `pointer` - The pointer to `value`.
+ /// * `scope` - The scope the expression belongs to.
+ /// * `parser` - The parser to parse expressions with.
+ fn add_string(&mut self, value: &Value, pointer: &JsonPointer, scope: &ExpressionScope, parser: &mut Statement) {
+ if let Value::String(text) = value && text.starts_with('[') {
+ self.by_pointer.insert(pointer.to_string(), self.sites.len());
+ self.sites.push(ExpressionSite {
+ pointer: pointer.clone(),
+ text: text.clone(),
+ scope: scope.clone(),
+ parsed: parser.parse(text),
+ });
+ }
+ }
+
/// Walk a value, parsing every string that starts with `[`.
///
/// # Arguments
@@ -149,15 +176,7 @@ impl ExpressionIndex {
/// * `parser` - The parser to parse expressions with.
fn walk(&mut self, value: &Value, pointer: &JsonPointer, scope: &ExpressionScope, parser: &mut Statement) {
match value {
- Value::String(text) if text.starts_with('[') => {
- self.by_pointer.insert(pointer.to_string(), self.sites.len());
- self.sites.push(ExpressionSite {
- pointer: pointer.clone(),
- text: text.clone(),
- scope: scope.clone(),
- parsed: parser.parse(text),
- });
- },
+ Value::String(_) => self.add_string(value, pointer, scope, parser),
Value::Array(items) => {
for (position, item) in items.iter().enumerate() {
self.walk(item, &pointer.push_index(position), scope, parser);
@@ -363,4 +382,57 @@ resources:
assert!(index.sites()[0].parsed.is_err());
assert!(index.calls().is_empty());
}
+
+ #[test]
+ fn indexes_only_string_defaults_and_variables() {
+ let (_, index) = index_for(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+parameters:
+ p:
+ type: object
+ defaultValue:
+ nested: "[parameters('missing')]"
+variables:
+ literal:
+ nested: "[parameters('missing')]"
+ list:
+ - "[parameters('missing')]"
+ evaluated: "[parameters('p')]"
+resources: []
+"#);
+ let pointers: Vec<&str> = index.sites().iter().map(|site| site.pointer.as_str()).collect();
+ assert_eq!(pointers, vec!["/variables/evaluated"]);
+ }
+
+ #[test]
+ fn skips_embedded_configurations() {
+ let (_, index) = index_for(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: "[concat('group', '1')]"
+ type: Microsoft.DSC/Group
+ properties:
+ $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+ resources:
+ - name: inner
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "[parameters('inner')]"
+- name: other
+ type: Third.Party/Group
+ properties:
+ $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+ resources: []
+ parameters:
+ x:
+ type: string
+ defaultValue: "[parameters('inner')]"
+- name: plain
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "[parameters('outer')]"
+"#);
+ let pointers: Vec<&str> = index.sites().iter().map(|site| site.pointer.as_str()).collect();
+ assert_eq!(pointers, vec!["/resources/0/name", "/resources/2/properties/output"]);
+ }
}
diff --git a/lib/dsc-lib/src/lint/rules/dependency.rs b/lib/dsc-lib/src/lint/rules/dependency.rs
index 678a95b77..b5adaa2d1 100644
--- a/lib/dsc-lib/src/lint/rules/dependency.rs
+++ b/lib/dsc-lib/src/lint/rules/dependency.rs
@@ -157,7 +157,8 @@ fn reference_targets(context: &LintContext, index: usize) -> Vec<(JsonPointer, R
.collect()
}
-/// Resolve a dependency key against a group of sibling resources.
+/// Resolve a dependency key against a group of sibling resources by their names as written,
+/// which is how DSC resolves `dependsOn`.
///
/// # Arguments
///
@@ -165,7 +166,64 @@ fn reference_targets(context: &LintContext, index: usize) -> Vec<(JsonPointer, R
/// * `group` - The indexes of the sibling resources.
/// * `key` - The dependency key.
fn resolve(context: &LintContext, group: &[usize], key: &ResourceKey) -> Resolution {
- let by_name: Vec = group.iter().copied().filter(|&index| context.resources[index].resource.name == key.name).collect();
+ resolve_by(context, group, key, |index| Some(context.resources[index].resource.name.clone()))
+}
+
+/// Resolve a `reference()` key against a group of sibling resources by their evaluated names,
+/// which is how DSC resolves references.
+///
+/// # Arguments
+///
+/// * `context` - The lint context.
+/// * `group` - The indexes of the sibling resources.
+/// * `key` - The referenced key.
+///
+/// # Returns
+///
+/// The resolution, or `None` when the key isn't found and a sibling's name is an expression
+/// that could evaluate to it.
+fn resolve_reference(context: &LintContext, group: &[usize], key: &ResourceKey) -> Option {
+ let resolution = resolve_by(context, group, key, |index| evaluated_name(context, index));
+ let has_dynamic_name = group.iter().any(|&index| evaluated_name(context, index).is_none());
+ match resolution {
+ Resolution::Found(_) => Some(resolution),
+ _ if has_dynamic_name => None,
+ _ => Some(resolution),
+ }
+}
+
+/// The name a resource has at runtime, when it is known without evaluating an expression.
+///
+/// # Arguments
+///
+/// * `context` - The lint context.
+/// * `index` - The index of the resource in the context's resource list.
+///
+/// # Returns
+///
+/// The name as written, the name without the escape character when it starts with `[[`, or
+/// `None` when the name is an expression.
+fn evaluated_name(context: &LintContext, index: usize) -> Option {
+ let name = &context.resources[index].resource.name;
+ if LintContext::is_expression(name) {
+ None
+ } else if let Some(unescaped) = name.strip_prefix('[') {
+ Some(unescaped.to_string())
+ } else {
+ Some(name.clone())
+ }
+}
+
+/// Resolve a dependency key against a group of sibling resources.
+///
+/// # Arguments
+///
+/// * `context` - The lint context.
+/// * `group` - The indexes of the sibling resources.
+/// * `key` - The dependency key.
+/// * `name_of` - The name to match for a sibling, or `None` when it isn't known.
+fn resolve_by(context: &LintContext, group: &[usize], key: &ResourceKey, name_of: impl Fn(usize) -> Option) -> Resolution {
+ let by_name: Vec = group.iter().copied().filter(|&index| name_of(index).is_some_and(|name| name == key.name)).collect();
if let Some(&index) = by_name.iter().find(|&&index| context.resources[index].resource.resource_type.as_ref().eq_ignore_ascii_case(&key.type_name)) {
return Resolution::Found(index);
}
@@ -178,7 +236,8 @@ fn resolve(context: &LintContext, group: &[usize], key: &ResourceKey) -> Resolut
/// Report `dependsOn` entries and `reference()` calls whose target isn't a sibling resource.
///
/// Resources in copy loops are skipped, and a missing target isn't reported when a sibling is
-/// a copy loop, because copy loops produce names that are only known at runtime.
+/// a copy loop, because copy loops produce names that are only known at runtime. For the same
+/// reason a `reference()` call isn't reported when a sibling's name is an expression.
fn unresolved_dependency(context: &LintContext, sink: &mut DiagnosticSink) {
for group in context.sibling_groups() {
let has_copy = group.iter().any(|&index| context.resources[index].resource.copy.is_some());
@@ -187,8 +246,19 @@ fn unresolved_dependency(context: &LintContext, sink: &mut DiagnosticSink) {
if site.resource.copy.is_some() {
continue;
}
- for (pointer, key) in literal_dependencies(context, index).into_iter().chain(reference_targets(context, index)) {
- match resolve(context, &group, &key) {
+ let dependencies = literal_dependencies(context, index).into_iter().map(|(pointer, key)| {
+ let resolution = resolve(context, &group, &key);
+ (pointer, key, Some(resolution))
+ });
+ let references = reference_targets(context, index).into_iter().map(|(pointer, key)| {
+ let resolution = resolve_reference(context, &group, &key);
+ (pointer, key, resolution)
+ });
+ for (pointer, key, resolution) in dependencies.chain(references) {
+ let Some(resolution) = resolution else {
+ continue;
+ };
+ match resolution {
Resolution::Found(_) => {},
Resolution::TypeMismatch(actual) => sink.report(
&pointer,
@@ -355,6 +425,41 @@ resources:
assert!(rule_messages(&result, "unresolved-dependency")[1].contains("Microsoft.DSC.Debug/Echo"));
}
+ #[test]
+ fn references_resolve_against_evaluated_names() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+parameters:
+ name:
+ type: string
+ defaultValue: source
+resources:
+- name: "[parameters('name')]"
+ type: Microsoft.DSC.Debug/Echo
+- name: consumer
+ type: Microsoft.DSC.Debug/Echo
+ dependsOn:
+ - "[resourceId('Microsoft.DSC.Debug/Echo', 'source')]"
+ properties:
+ output: "[reference(resourceId('Microsoft.DSC.Debug/Echo', 'source'))]"
+"#);
+ // dependsOn matches names as written, so only it is reported
+ assert_eq!(rule_paths(&result, "unresolved-dependency"), vec!["/resources/1/dependsOn/0"]);
+
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: "[[bracketed]"
+ type: Microsoft.DSC.Debug/Echo
+- name: consumer
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ found: "[reference(resourceId('Microsoft.DSC.Debug/Echo', '[bracketed]'))]"
+ missing: "[reference(resourceId('Microsoft.DSC.Debug/Echo', '[[bracketed]'))]"
+"#);
+ assert_eq!(rule_paths(&result, "unresolved-dependency"), vec!["/resources/1/properties/missing"]);
+ }
+
#[test]
fn copy_loops_suppress_unresolved_dependencies() {
let result = lint_yaml(r#"
diff --git a/lib/dsc-lib/src/lint/rules/expression.rs b/lib/dsc-lib/src/lint/rules/expression.rs
index d3f961484..70ff62bff 100644
--- a/lib/dsc-lib/src/lint/rules/expression.rs
+++ b/lib/dsc-lib/src/lint/rules/expression.rs
@@ -5,6 +5,7 @@
use rust_i18n::t;
+use crate::configure::config_doc::DataType;
use crate::lint::{
context::LintContext,
diagnostic::LintCategory,
@@ -99,19 +100,39 @@ fn is_checkable_builtin(context: &LintContext, call: &FunctionCall) -> bool {
!name.contains('.') && !is_lambda(call) && context.functions.get_metadata(name).is_some()
}
+/// The name of the secure parameter whose default value holds an expression, if any.
+///
+/// # Arguments
+///
+/// * `context` - The lint context.
+/// * `scope` - Where the expression appears.
+fn secure_parameter_scope<'a>(context: &LintContext, scope: &'a ExpressionScope) -> Option<&'a str> {
+ let ExpressionScope::Parameter(name) = scope else {
+ return None;
+ };
+ let secure = context.declared_parameter(name)
+ .is_some_and(|parameter| matches!(parameter.parameter_type, DataType::SecureString | DataType::SecureObject));
+ secure.then_some(name.as_str())
+}
+
/// Report expressions that fail to parse.
+///
+/// The expression and the parser error, which repeats it, are left out of the message when the
+/// expression is the default value of a secure parameter, because it can hold a secret.
fn invalid_expression(context: &LintContext, sink: &mut DiagnosticSink) {
for site in context.expressions.sites() {
- if let Err(err) = &site.parsed {
- sink.report(
- &site.pointer,
- t!(
- "lint.rules.expression.invalidExpression",
- expression = site.text,
- error = LintContext::error_message(err)
- ).to_string(),
- );
- }
+ let Err(err) = &site.parsed else {
+ continue;
+ };
+ let message = match secure_parameter_scope(context, &site.scope) {
+ Some(name) => t!("lint.rules.expression.invalidExpressionInSecureParameter", name = name),
+ None => t!(
+ "lint.rules.expression.invalidExpression",
+ expression = site.text,
+ error = LintContext::error_message(err)
+ ),
+ };
+ sink.report(&site.pointer, message.to_string());
}
}
@@ -237,6 +258,36 @@ resources:
assert_eq!(rule_paths(&result, "invalid-expression"), vec!["/resources/0/properties/output"]);
}
+ #[test]
+ fn redacts_invalid_expressions_in_secure_defaults() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+parameters:
+ password:
+ type: secureString
+ defaultValue: "[concat('hunter2', 'unterminated)]"
+ blob:
+ type: secureObject
+ defaultValue: "[createObject('key', 'hunter2'))]"
+ plain:
+ type: string
+ defaultValue: "[concat('visible', 'unterminated)]"
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "[concat(parameters('password'), parameters('blob'), parameters('plain'))]"
+"#);
+ assert_eq!(
+ rule_paths(&result, "invalid-expression"),
+ vec!["/parameters/blob/defaultValue", "/parameters/password/defaultValue", "/parameters/plain/defaultValue"]
+ );
+ let messages = rule_messages(&result, "invalid-expression");
+ assert!(messages[0].contains("'blob'") && !messages[0].contains("hunter2"), "{}", messages[0]);
+ assert!(messages[1].contains("'password'") && !messages[1].contains("hunter2"), "{}", messages[1]);
+ assert!(messages[2].contains("visible"), "{}", messages[2]);
+ }
+
#[test]
fn reports_unknown_functions_with_case_suggestion() {
let result = lint_yaml(r#"
diff --git a/lib/dsc-lib/src/lint/rules/parameter.rs b/lib/dsc-lib/src/lint/rules/parameter.rs
index 3471a0624..bd81201d6 100644
--- a/lib/dsc-lib/src/lint/rules/parameter.rs
+++ b/lib/dsc-lib/src/lint/rules/parameter.rs
@@ -22,6 +22,7 @@ use crate::{
pointer::JsonPointer,
rule::{DiagnosticSink, Rule, RuleTier},
},
+ parser::ParsedStatement,
};
/// Parameter names that suggest the value is a secret.
@@ -250,6 +251,9 @@ fn no_unused_variables(context: &LintContext, sink: &mut DiagnosticSink) {
}
/// Report literal default values that don't satisfy the parameter's type or constraints.
+///
+/// An escaped string default (`[[...`) is checked with the escape character removed, which is
+/// the value DSC uses.
fn parameter_default_type(context: &LintContext, sink: &mut DiagnosticSink) {
let Some(parameters) = &context.config.parameters else {
return;
@@ -261,11 +265,13 @@ fn parameter_default_type(context: &LintContext, sink: &mut DiagnosticSink) {
if value.as_str().is_some_and(LintContext::is_expression) {
continue;
}
- if let Err(reason) = check_value(name, value, parameter) {
- sink.report(
- ¶meter_pointer(name).push("defaultValue"),
- t!("lint.rules.parameter.parameterDefaultType", name = name, reason = reason).to_string(),
- );
+ let pointer = parameter_pointer(name).push("defaultValue");
+ let unescaped = match context.expressions.site_at(&pointer).map(|site| &site.parsed) {
+ Some(Ok(ParsedStatement::Escaped(text))) => Value::String(text.clone()),
+ _ => value.clone(),
+ };
+ if let Err(reason) = check_value(name, &unescaped, parameter) {
+ sink.report(&pointer, t!("lint.rules.parameter.parameterDefaultType", name = name, reason = reason).to_string());
}
}
}
@@ -359,12 +365,15 @@ fn prefer_secure_type(context: &LintContext, sink: &mut DiagnosticSink) {
}
/// Report outputs whose value references a secure parameter or a secret.
+///
+/// Outputs declared as `secureString` or `secureObject` are skipped, because DSC withholds them
+/// from the result.
fn secure_value_in_output(context: &LintContext, sink: &mut DiagnosticSink) {
let Some(outputs) = &context.config.outputs else {
return;
};
for (name, output) in outputs {
- if !matches!(output.value_or_copy, ValueOrCopy::Value(_)) {
+ if !matches!(output.value_or_copy, ValueOrCopy::Value(_)) || matches!(output.r#type, DataType::SecureString | DataType::SecureObject) {
continue;
}
let pointer = JsonPointer::root().push("outputs").push(name).push("value");
@@ -428,6 +437,27 @@ fn missing_parameter_value(context: &LintContext, sink: &mut DiagnosticSink) {
}
}
+/// Check a supplied value against a parameter's type and constraints.
+///
+/// Unlike a default value, a supplied `secureString` value must be a plain string; DSC wraps it
+/// itself and rejects the wrapped form.
+///
+/// # Arguments
+///
+/// * `name` - The parameter name.
+/// * `value` - The supplied value.
+/// * `parameter` - The parameter declaration.
+///
+/// # Errors
+///
+/// Returns the reason the value is invalid.
+fn check_supplied_value(name: &str, value: &Value, parameter: &Parameter) -> Result<(), String> {
+ if parameter.parameter_type == DataType::SecureString && !value.is_string() {
+ return Err(t!("lint.rules.parameter.secureStringNotPlain").to_string());
+ }
+ check_value(name, value, parameter)
+}
+
/// Report supplied parameter values that don't satisfy the parameter's type or constraints.
fn parameter_value_type(context: &LintContext, sink: &mut DiagnosticSink) {
let Some(supplied) = &context.supplied_parameters else {
@@ -437,7 +467,7 @@ fn parameter_value_type(context: &LintContext, sink: &mut DiagnosticSink) {
let Some(parameter) = context.declared_parameter(name) else {
continue;
};
- if let Err(reason) = check_value(name, value, parameter) {
+ if let Err(reason) = check_supplied_value(name, value, parameter) {
sink.report(¶meter_pointer(name), t!("lint.rules.parameter.parameterValueType", name = name, reason = reason).to_string());
}
}
@@ -515,6 +545,54 @@ resources: []
);
}
+ #[test]
+ fn checks_escaped_defaults_without_the_escape_character() {
+ let result = lint_yaml(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+parameters:
+ fits:
+ type: string
+ defaultValue: "[[x]"
+ maxLength: 3
+ allowedValues: ["[x]"]
+ tooLong:
+ type: string
+ defaultValue: "[[abc]"
+ maxLength: 4
+resources: []
+"#);
+ assert_eq!(rule_paths(&result, "parameter-default-type"), vec!["/parameters/tooLong/defaultValue"]);
+ }
+
+ #[test]
+ fn reports_wrapped_supplied_secure_strings() {
+ let options = LintOptions {
+ discovery: false,
+ use_settings: false,
+ parameters: Some(json!({"parameters": {"wrapped": {"secureString": "x"}, "plain": "x"}})),
+ ..Default::default()
+ };
+ let result = lint_with(r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+parameters:
+ wrapped:
+ type: secureString
+ plain:
+ type: secureString
+ defaulted:
+ type: secureString
+ defaultValue:
+ secureString: "[[not a secret]"
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: "[concat(parameters('wrapped'), parameters('plain'), parameters('defaulted'))]"
+"#, options);
+ assert_eq!(rule_paths(&result, "parameter-value-type"), vec!["/parameters/wrapped"]);
+ assert!(rule_paths(&result, "parameter-default-type").is_empty());
+ }
+
#[test]
fn reports_invalid_constraints() {
let result = lint_yaml(r#"
@@ -589,13 +667,16 @@ parameters:
outputs:
leaked:
type: string
- value: "[parameters('password')]"
+ value: "[parameters('password').secureString]"
viaSecret:
type: string
value: "[secret('vault')]"
fine:
type: string
value: "[parameters('plain')]"
+ withheld:
+ type: secureString
+ value: "[parameters('password')]"
resources: []
"#);
assert_eq!(rule_paths(&result, "secure-value-in-output"), vec!["/outputs/leaked/value", "/outputs/viaSecret/value"]);
diff --git a/lib/dsc-lib/src/lint/rules/resource.rs b/lib/dsc-lib/src/lint/rules/resource.rs
index 6f14563af..ef1653f4f 100644
--- a/lib/dsc-lib/src/lint/rules/resource.rs
+++ b/lib/dsc-lib/src/lint/rules/resource.rs
@@ -9,7 +9,7 @@ use serde_json::Value;
use crate::{
dscresources::{
- dscresource::{validate_json_all, Capability, DscResource, ImplementedAs},
+ dscresource::{validate_json_all, Capability, DscResource, ImplementedAs, SchemaViolation},
resource_manifest::{Kind, SchemaKind},
},
lint::{
@@ -20,6 +20,7 @@ use crate::{
pointer::JsonPointer,
rule::{DiagnosticSink, Rule, RuleTier},
},
+ parser::ParsedStatement,
};
/// The rules in this module.
@@ -169,10 +170,85 @@ fn deprecated_resource(context: &LintContext, sink: &mut DiagnosticSink) {
}
}
+/// Keywords whose outcome depends only on the shape of a value (its type, member names, or
+/// length) and not on the values nested in it, so an expression nested in the value can't
+/// change it.
+const STRUCTURAL_KEYWORDS: [&str; 9] = [
+ "type",
+ "required",
+ "additionalProperties",
+ "propertyNames",
+ "minProperties",
+ "maxProperties",
+ "dependentRequired",
+ "minItems",
+ "maxItems",
+];
+
+/// The properties of a resource as DSC passes them to the resource, as far as that is known
+/// statically.
+///
+/// # Arguments
+///
+/// * `context` - The lint context.
+/// * `index` - The index of the resource in the context's resource list.
+/// * `properties` - The properties as written.
+///
+/// # Returns
+///
+/// The properties with escaped strings (`[[...`) replaced by their value, and the pointers of
+/// the expressions, whose values are only known at runtime.
+fn runtime_properties(context: &LintContext, index: usize, properties: &Value) -> (Value, Vec) {
+ let base = context.resources[index].pointer.push("properties");
+ let mut value = properties.clone();
+ let mut unknown = Vec::new();
+ for site in context.expressions.sites() {
+ if site.scope != ExpressionScope::Resource(index) || !site.pointer.starts_with(&base) {
+ continue;
+ }
+ match &site.parsed {
+ Ok(ParsedStatement::Escaped(text)) => {
+ let relative = &site.pointer.as_str()[base.as_str().len()..];
+ if let Some(slot) = value.pointer_mut(relative) {
+ *slot = Value::String(text.clone());
+ }
+ },
+ Ok(ParsedStatement::Literal(_)) => {},
+ Ok(ParsedStatement::Expression(_)) | Err(_) => unknown.push(site.pointer.clone()),
+ }
+ }
+ (value, unknown)
+}
+
+/// Whether a violation holds whatever the expressions in the properties evaluate to.
+///
+/// A violation at or below an expression is unknown until runtime. Above an expression, only
+/// violations of a structural keyword are definite. A failed `anyOf` or `oneOf` is definite only
+/// when every subschema has a definite violation, because otherwise the runtime values could
+/// satisfy one of them.
+///
+/// # Arguments
+///
+/// * `violation` - The violation.
+/// * `base` - The pointer to the properties the violation paths are relative to.
+/// * `unknown` - The pointers of the expressions in the properties.
+fn is_definite(violation: &SchemaViolation, base: &JsonPointer, unknown: &[JsonPointer]) -> bool {
+ if !violation.alternatives.is_empty() {
+ return violation.alternatives.iter().all(|alternative| alternative.iter().any(|nested| is_definite(nested, base, unknown)));
+ }
+ let pointer = base.join(&violation.instance_path);
+ if unknown.iter().any(|expression| pointer.starts_with(expression)) {
+ return false;
+ }
+ let contains_expression = unknown.iter().any(|expression| expression.starts_with(&pointer));
+ !contains_expression || STRUCTURAL_KEYWORDS.contains(&violation.keyword.as_str())
+}
+
/// Report property values that don't validate against the resource's embedded schema.
///
-/// Violations at or below a property that holds an expression are dropped, because the value
-/// is only known at runtime.
+/// The properties are validated as DSC passes them to the resource, with escaped strings
+/// unescaped. Violations that an expression's runtime value could resolve are dropped; see
+/// [`is_definite`].
fn property_schema_mismatch(context: &LintContext, sink: &mut DiagnosticSink) {
for (index, resolved) in resolved(context) {
let Some(found) = &resolved.found else {
@@ -189,7 +265,8 @@ fn property_schema_mismatch(context: &LintContext, sink: &mut DiagnosticSink) {
let Some(properties) = base.resolve(context.raw) else {
continue;
};
- let violations = match validate_json_all(found.type_name.as_ref(), &schema, properties) {
+ let (properties, unknown) = runtime_properties(context, index, properties);
+ let violations = match validate_json_all(found.type_name.as_ref(), &schema, &properties) {
Ok(violations) => violations,
Err(err) => {
sink.report(
@@ -200,15 +277,11 @@ fn property_schema_mismatch(context: &LintContext, sink: &mut DiagnosticSink) {
},
};
for violation in violations {
- let pointer = base.join(&violation.instance_path);
- let in_expression = context.expressions.sites().iter().any(|expression| {
- expression.scope == ExpressionScope::Resource(index) && pointer.starts_with(&expression.pointer)
- });
- if in_expression {
+ if !is_definite(&violation, &base, &unknown) {
continue;
}
sink.report(
- &pointer,
+ &base.join(&violation.instance_path),
t!("lint.rules.resource.propertySchemaMismatch", type_name = site.resource.resource_type, message = violation.message).to_string(),
);
}
@@ -401,6 +474,50 @@ resources:
assert_eq!(paths(&run(DOCUMENT, discovered, "property-schema-mismatch")), vec!["/resources/0/properties/output"]);
}
+ #[test]
+ fn combinators_allow_expressions_but_keep_definite_errors() {
+ let document = r#"
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: a
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ items:
+ - name: dynamic
+ scope: "[parameters('scope')]"
+ - name: literal
+ scope: nowhere
+ - name: brokenSibling
+ scope: "[parameters('scope')]"
+ count: notAnInt
+ count: "[[5]"
+ mode: "[[x]"
+"#;
+ let branch = |scope: &str| json!({
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" },
+ "scope": { "const": scope },
+ "count": { "type": "integer" }
+ }
+ });
+ let schema = json!({
+ "type": "object",
+ "properties": {
+ "items": { "type": "array", "items": { "anyOf": [branch("currentUser"), branch("machine")] } },
+ "count": { "type": "integer" },
+ "mode": { "enum": ["[x]"] }
+ }
+ });
+ let discovered = vec![
+ ResolvedResource { found: Some(echo(Kind::Resource, vec![Capability::Get], Some(schema))), type_exists: true },
+ ];
+ let diagnostics = run(document, discovered, "property-schema-mismatch");
+ let mut reported = paths(&diagnostics);
+ reported.sort_unstable();
+ assert_eq!(reported, vec!["/resources/0/properties/count", "/resources/0/properties/items/1", "/resources/0/properties/items/2"]);
+ }
+
#[test]
fn reports_nested_resources_and_exist_false_on_unsupported_types() {
let discovered = vec![
From 15b8005a2704e5ad4aadb6499ac4dd4c06eb6497 Mon Sep 17 00:00:00 2001
From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com>
Date: Fri, 23 Oct 2026 08:48:06 +0200
Subject: [PATCH 3/6] Add miette
---
CHANGELOG.md | 21 +-
Cargo.lock | 187 +++++-
Cargo.toml | 4 +-
docs/reference/cli/lint/index.md | 46 +-
docs/reference/schemas/outputs/lint/result.md | 5 +-
dsc/Cargo.toml | 1 +
dsc/locales/en-us.toml | 4 +
dsc/src/main.rs | 1 +
dsc/src/report.rs | 165 ++++++
dsc/src/resource_command.rs | 21 +-
dsc/src/subcommand.rs | 90 ++-
dsc/src/util.rs | 39 +-
dsc/tests/dsc_diagnostics.tests.ps1 | 84 +++
dsc/tests/dsc_lint.tests.ps1 | 11 +
lib/dsc-lib/Cargo.toml | 2 +-
lib/dsc-lib/locales/en-us.toml | 14 +
lib/dsc-lib/src/configure/mod.rs | 63 +-
lib/dsc-lib/src/diagnostics/mod.rs | 43 ++
lib/dsc-lib/src/diagnostics/schema.rs | 387 +++++++++++++
lib/dsc-lib/src/diagnostics/source_map.rs | 537 ++++++++++++++++++
lib/dsc-lib/src/dscerror.rs | 4 +
.../src/dscresources/command_resource.rs | 104 ++--
lib/dsc-lib/src/dscresources/dscresource.rs | 47 +-
lib/dsc-lib/src/lib.rs | 1 +
lib/dsc-lib/src/lint/mod.rs | 32 +-
lib/dsc-lib/src/lint/report.rs | 139 +++++
rfc.md | 462 +++++++++++++++
27 files changed, 2368 insertions(+), 146 deletions(-)
create mode 100644 dsc/src/report.rs
create mode 100644 dsc/tests/dsc_diagnostics.tests.ps1
create mode 100644 lib/dsc-lib/src/diagnostics/mod.rs
create mode 100644 lib/dsc-lib/src/diagnostics/schema.rs
create mode 100644 lib/dsc-lib/src/diagnostics/source_map.rs
create mode 100644 lib/dsc-lib/src/lint/report.rs
create mode 100644 rfc.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 45f8f17f3..8ae61a517 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -86,7 +86,10 @@ changes since the last release, see the [diff on GitHub][unreleased].
You can change the level of a rule for a run with the `--rule` option or for every run in the
new `lint` section of the settings file, and control which level makes the command fail with
`--fail-on`. The `dsc schema` command exposes the new `lint-result` and `lint-rules` schemas,
- and the MCP server exposes the linter as the `lint_dsc_config` tool.
+ and the MCP server exposes the linter as the `lint_dsc_config` tool. Every diagnostic records
+ the line and column of the problem, and when the output isn't redirected the command renders
+ each diagnostic as a report that shows the problem in the document text with the rule's
+ identifier and what the rule requires.
Related work items
@@ -106,6 +109,22 @@ changes since the last release, see the [diff on GitHub][unreleased].
### Changed
+- Schema validation errors now show the problem in place. When the input to a resource or a
+ resource's properties in a configuration document don't match the resource's schema, DSC reports
+ every violation at once with its JSON pointer, renders a snippet of the input or the document
+ that highlights each offending property or value, and suggests the closest known property name
+ for a property it doesn't recognize. For a configuration document the snippet points at the
+ resource's declaration in your document rather than at its evaluated properties. The snippet
+ isn't rendered for the `json` trace format, which keeps one line per error, and is never
+ rendered for values the schema identifies as secure.
+
+ Related work items
+
+ - Issues: _None_
+ - PRs: [#1768][#1768]
+
+
+
- Extensions that implement the `secret` capability must now define the `args` property for the
`secret` command with exactly one secret name input argument (`nameArg`) and at most one vault
input argument (`vaultArg`). Previously, an extension could omit the secret name input argument,
diff --git a/Cargo.lock b/Cargo.lock
index 46df2b052..ddf924905 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -808,6 +808,7 @@ dependencies = [
"dsc-lib-telemetry",
"indicatif",
"jsonschema",
+ "miette",
"path-absolutize",
"regex",
"rmcp",
@@ -874,6 +875,7 @@ dependencies = [
"registry",
"rt-format",
"rust-i18n",
+ "saphyr-parser",
"schemars",
"semver",
"serde",
@@ -1647,9 +1649,9 @@ dependencies = [
[[package]]
name = "icu_collections"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
dependencies = [
"displaydoc",
"potential_utf",
@@ -1661,22 +1663,43 @@ dependencies = [
[[package]]
name = "icu_locale_core"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
dependencies = [
"displaydoc",
"litemap",
+ "serde",
"tinystr",
"writeable",
"zerovec",
]
+[[package]]
+name = "icu_locale_fallback"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c"
+dependencies = [
+ "icu_locale_core",
+ "icu_locale_fallback_data",
+ "icu_provider",
+ "potential_utf",
+ "tinystr",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_fallback_data"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c"
+
[[package]]
name = "icu_normalizer"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
dependencies = [
"icu_collections",
"icu_normalizer_data",
@@ -1688,15 +1711,15 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
[[package]]
name = "icu_properties"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
dependencies = [
"displaydoc",
"icu_collections",
@@ -1709,18 +1732,20 @@ dependencies = [
[[package]]
name = "icu_properties_data"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
[[package]]
name = "icu_provider"
-version = "2.3.1"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
dependencies = [
"displaydoc",
"icu_locale_core",
+ "serde",
+ "stable_deref_trait",
"writeable",
"yoke",
"zerofrom",
@@ -1728,6 +1753,29 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "icu_segmenter"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61b195dc605f81aa181bec9b3bd14e26cc71b903e4644f6af578ca9772c7a710"
+dependencies = [
+ "icu_collections",
+ "icu_locale_fallback",
+ "icu_provider",
+ "icu_segmenter_data",
+ "potential_utf",
+ "smallvec",
+ "utf8_iter",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_segmenter_data"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "913a7d294ad056474749152107309fdee3ec17d2482d1ab70fdc61a81c64b9a3"
+
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -1809,6 +1857,12 @@ version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763"
+[[package]]
+name = "is_ci"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
+
[[package]]
name = "is_elevated"
version = "0.1.2"
@@ -1998,6 +2052,12 @@ checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
dependencies = [
"cfg-if",
"miette-derive",
+ "owo-colors",
+ "supports-color",
+ "supports-hyperlinks",
+ "supports-unicode",
+ "terminal_size",
+ "textwrap",
"unicode-width 0.1.14",
]
@@ -2445,6 +2505,12 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
+[[package]]
+name = "owo-colors"
+version = "4.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8"
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2557,6 +2623,8 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
+ "serde_core",
+ "writeable",
"zerovec",
]
@@ -3056,6 +3124,16 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "saphyr-parser"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c6220586c3dacac54bfddec87af86ddb31862d29666e7e35cf4baf70a25d387"
+dependencies = [
+ "arraydeque",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "schemars"
version = "1.2.2"
@@ -3375,6 +3453,27 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "supports-color"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
+dependencies = [
+ "is_ci",
+]
+
+[[package]]
+name = "supports-hyperlinks"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91"
+
+[[package]]
+name = "supports-unicode"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2"
+
[[package]]
name = "syn"
version = "2.0.119"
@@ -3417,6 +3516,17 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "synstructure"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
[[package]]
name = "syntect"
version = "5.3.0"
@@ -3472,6 +3582,16 @@ dependencies = [
"windows-sys",
]
+[[package]]
+name = "terminal_size"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
+dependencies = [
+ "rustix",
+ "windows-sys",
+]
+
[[package]]
name = "test-case"
version = "3.4.0"
@@ -3516,6 +3636,16 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "textwrap"
+version = "0.16.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49"
+dependencies = [
+ "icu_segmenter",
+ "unicode-width 0.2.2",
+]
+
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -3597,11 +3727,12 @@ dependencies = [
[[package]]
name = "tinystr"
-version = "0.8.4"
+version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
dependencies = [
"displaydoc",
+ "serde_core",
"zerovec",
]
@@ -4629,9 +4760,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yoke"
-version = "0.8.3"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3"
dependencies = [
"stable_deref_trait",
"yoke-derive",
@@ -4640,14 +4771,14 @@ dependencies = [
[[package]]
name = "yoke-derive"
-version = "0.8.2"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.119",
- "synstructure",
+ "syn 3.0.6",
+ "synstructure 0.14.0",
]
[[package]]
@@ -4688,7 +4819,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
- "synstructure",
+ "synstructure 0.13.2",
]
[[package]]
@@ -4700,14 +4831,16 @@ dependencies = [
"displaydoc",
"yoke",
"zerofrom",
+ "zerovec",
]
[[package]]
name = "zerovec"
-version = "0.11.8"
+version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
dependencies = [
+ "serde",
"yoke",
"zerofrom",
"zerovec-derive",
@@ -4715,9 +4848,9 @@ dependencies = [
[[package]]
name = "zerovec-derive"
-version = "0.11.6"
+version = "0.11.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a"
dependencies = [
"proc-macro2",
"quote",
diff --git a/Cargo.toml b/Cargo.toml
index ea14b0632..d7c730833 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -196,7 +196,7 @@ jsonschema = { version = "0.58.6", default-features = false }
referencing = { version = "0.58.6" }
# dsc-lib
linked-hash-map = { version = "0.5" }
-# dsc-lib
+# dsc, dsc-lib
miette = { version = "7.6.0" }
# dsc-lib
murmurhash64 = { version = "0.3" }
@@ -230,6 +230,8 @@ roxmltree = { version = "0.21.1" }
rt-format = { version = "0.3" }
# dsc, dsc-lib, dsc-bicep-ext, dscecho, registry, dsc-lib-registry, runcommandonset, sshdconfig
rust-i18n = { version = "4.2.4" }
+# dsc-lib
+saphyr-parser = { version = "0.2.2" }
# dsc, dsc-lib, dscecho, registry, dsc-lib-registry, sshdconfig, dsctest, test_group_resource
schemars = { version = "1.2.2", features = ["preserve_order"] }
# dsc, dsc-lib
diff --git a/docs/reference/cli/lint/index.md b/docs/reference/cli/lint/index.md
index 487cc412c..957ab2076 100644
--- a/docs/reference/cli/lint/index.md
+++ b/docs/reference/cli/lint/index.md
@@ -76,7 +76,8 @@ For the list of rules and what each one checks, see [dsc lint rules][01].
-The command reports a duplicate resource name and exits with code `5`.
+The command reports a duplicate resource name and exits with code `5`. In the console, each
+diagnostic is rendered as a report that shows the problem in the document.
```yaml
# example.dsc.config.yaml
@@ -96,6 +97,30 @@ resources:
dsc lint --file ./example.dsc.config.yaml
```
+```text
+duplicate-resource
+
+ × Resource 'EnsureTest1Value' of type 'Microsoft.DSC.Debug/Echo' is declared more than once
+ â•─[./example.dsc.config.yaml:7:3]
+ 5 │ properties:
+ 6 │ output: one
+ 7 │ - name: EnsureTest1Value
+ · ──────────────────────
+ 8 │ type: Microsoft.DSC.Debug/Echo
+ 9 │ properties:
+ ╰────
+ help: Resource names must be unique among resources of the same type at the same level.
+
+errors: 1, warnings: 0, info: 0
+```
+
+When the output is redirected, or when you specify [--output-format](#--output-format), the
+command emits the result as data instead:
+
+```sh
+dsc lint --file ./example.dsc.config.yaml --output-format yaml
+```
+
```yaml
diagnostics:
- level: error
@@ -104,6 +129,9 @@ diagnostics:
message: Resource 'EnsureTest1Value' of type 'Microsoft.DSC.Debug/Echo' is declared more than once
path: /resources/1
file: ./example.dsc.config.yaml
+ location:
+ line: 7
+ column: 3
summary:
errors: 1
warnings: 0
@@ -315,14 +343,17 @@ available formats are:
- `pretty-json` to emit the data as JSON with newlines, indentation, and spaces for readability.
- `yaml` to emit the data as YAML.
-The default output format depends on whether DSC detects that the output is being redirected or
-captured as a variable:
+The default output depends on whether DSC detects that the output is being redirected or captured
+as a variable:
-- If the command isn't being redirected or captured, DSC displays the output as the `yaml` format
- in the console.
+- If the command isn't being redirected or captured, DSC renders each diagnostic in the console as
+ a report that shows the problem in the document text, with the rule identifier as the code and
+ what the rule requires as the help, followed by the counts of diagnostics by level.
- If the command output is redirected or captured, DSC emits the data as the `json` format to
stdout.
+Specify a format to get the data in the console too.
+
```yaml
Type : string
Mandatory : false
@@ -350,8 +381,9 @@ ShortSyntax : -h
This command returns the diagnostics for the document, counts of the diagnostics by level, whether
the document has no errors, and whether resource discovery ran. The command always writes the
-result to stdout, including when the document doesn't parse. For more information, see
-[dsc lint result schema][04].
+result to stdout, including when the document doesn't parse. In the console it renders the
+diagnostics as reports; otherwise, or when you specify [--output-format](#--output-format), it
+emits the result as data. For more information, see [dsc lint result schema][04].
## Exit codes
diff --git a/docs/reference/schemas/outputs/lint/result.md b/docs/reference/schemas/outputs/lint/result.md
index 736dae6ea..5f10ddda1 100644
--- a/docs/reference/schemas/outputs/lint/result.md
+++ b/docs/reference/schemas/outputs/lint/result.md
@@ -52,8 +52,9 @@ following properties:
- `file` is the file the document was read from. The property is omitted for documents passed
with `--input` or from stdin.
- `location` is the position of the problem in the source text as an object with `line` and
- `column` properties, both starting at `1`. The property is only present for problems that
- prevented the document from parsing.
+ `column` properties, both starting at `1`. For a `path` the document doesn't spell out, like a
+ property that is missing, the location is that of the closest enclosing value. The property is
+ omitted when the linter is given an already parsed document, as the MCP server does.
```yaml
Type: array
diff --git a/dsc/Cargo.toml b/dsc/Cargo.toml
index 010d1c420..8b8de73b1 100644
--- a/dsc/Cargo.toml
+++ b/dsc/Cargo.toml
@@ -11,6 +11,7 @@ crossterm = { workspace = true }
ctrlc = { workspace = true }
indicatif = { workspace = true }
jsonschema = { workspace = true }
+miette = { workspace = true, features = ["fancy-no-backtrace"] }
path-absolutize = { workspace = true }
regex = { workspace = true }
rmcp = { workspace = true, features = [
diff --git a/dsc/locales/en-us.toml b/dsc/locales/en-us.toml
index ad52059ee..72f20ba3d 100644
--- a/dsc/locales/en-us.toml
+++ b/dsc/locales/en-us.toml
@@ -139,6 +139,9 @@ adapterNotFound = "Adapter '%{adapter}' does not exist"
[server.show_dsc_resource]
resourceNotFound = "Resource type '%{type_name}' does not exist"
+[report]
+lintSummary = "errors: %{errors}, warnings: %{warnings}, info: %{info}"
+
[subcommand]
actualStateNotObject = "actual_state is not an object"
unexpectedTestResult = "Unexpected Group TestResult"
@@ -197,6 +200,7 @@ failedToReadFile = "Failed to read input file"
noInput = "No input provided"
emptyInput = "Empty input provided"
failedToParseInput = "Invalid JSON or YAML"
+inputSourceName = "input"
failedToAbsolutizePath = "Error making config path absolute"
failedToGetParentPath = "Error reading config path parent"
dscConfigRootAlreadySet = "The current value of DSC_CONFIG_ROOT env var will be overridden"
diff --git a/dsc/src/main.rs b/dsc/src/main.rs
index ef65bef25..9972b69f9 100644
--- a/dsc/src/main.rs
+++ b/dsc/src/main.rs
@@ -19,6 +19,7 @@ use crossterm::event;
use std::env;
pub mod args;
+pub mod report;
pub mod resolve;
pub mod resource_command;
pub mod server;
diff --git a/dsc/src/report.rs b/dsc/src/report.rs
new file mode 100644
index 000000000..8d8e015f5
--- /dev/null
+++ b/dsc/src/report.rs
@@ -0,0 +1,165 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Render errors for the terminal.
+//!
+//! Errors from `dsc-lib` are [`miette::Diagnostic`]s. Most carry only a message, but the ones
+//! found while validating a document or resource input also carry the text they apply to, with a
+//! label on each problem and sometimes a suggestion. [`render`] turns such an error into a report
+//! that shows the problem in place, in the style the trace format asks for.
+
+use std::collections::HashMap;
+use std::io::{IsTerminal, stderr};
+use std::sync::OnceLock;
+
+use dsc_lib::diagnostics::SourceMap;
+use dsc_lib::lint::{LintDiagnosticReport, LintResult};
+use dsc_lib_telemetry::StderrFormat;
+use miette::{Diagnostic, GraphicalReportHandler, GraphicalTheme};
+use rust_i18n::t;
+
+/// The lines shown around each labeled line of a snippet.
+const CONTEXT_LINES: usize = 2;
+
+static STDERR_FORMAT: OnceLock = OnceLock::new();
+
+/// Record the trace format so that reports match the rest of the trace output.
+///
+/// Only the first call takes effect.
+///
+/// # Arguments
+///
+/// * `format` - The format trace output is written in.
+pub fn set_stderr_format(format: StderrFormat) {
+ let _ = STDERR_FORMAT.set(format);
+}
+
+/// Render an error for the error log.
+///
+/// An error that carries source text or help is rendered as a report with the text, the labels
+/// on it, and the help; any other error is rendered as its message. When the trace format is
+/// JSON the message alone is used, so that consumers get one line per error.
+///
+/// # Arguments
+///
+/// * `error` - The error to render.
+#[must_use]
+pub fn render(error: &dyn Diagnostic) -> String {
+ let format = STDERR_FORMAT.get().copied().unwrap_or(StderrFormat::Default);
+ let has_detail = error.source_code().is_some() || error.help().is_some();
+ if !has_detail || format == StderrFormat::Json {
+ return error.to_string();
+ }
+
+ let handler = handler(format == StderrFormat::Default && stderr().is_terminal());
+ let mut report = String::new();
+ if handler.render_report(&mut report, error).is_err() {
+ return error.to_string();
+ }
+ report.trim_end().to_string()
+}
+
+/// Render the diagnostics of a lint run as reports that show each problem in its document,
+/// followed by the counts by level.
+///
+/// # Arguments
+///
+/// * `result` - The lint result.
+/// * `documents` - The text of each linted document, keyed by the file name recorded on its
+/// diagnostics; `None` for inline input.
+/// * `descriptions` - What each rule requires, by rule identifier, shown as the help text.
+/// * `colored` - Whether to style the reports with ANSI colors.
+#[must_use]
+pub fn render_lint_result(result: &LintResult, documents: &[(Option, String)], descriptions: &HashMap, colored: bool) -> String {
+ let maps: HashMap<&Option, SourceMap> = documents.iter()
+ .filter_map(|(file, text)| SourceMap::parse(text).map(|map| (file, map)))
+ .collect();
+ let handler = handler(colored);
+ let mut output = String::new();
+ for diagnostic in &result.diagnostics {
+ let report = LintDiagnosticReport::new(diagnostic, maps.get(&diagnostic.file), descriptions.get(&diagnostic.rule).cloned());
+ if handler.render_report(&mut output, &report).is_err() {
+ output.push_str(&format!("{} {}: {}\n", diagnostic.level, diagnostic.rule, diagnostic.message));
+ }
+ output.push('\n');
+ }
+ output.push_str(&t!(
+ "report.lintSummary",
+ errors = result.summary.errors,
+ warnings = result.summary.warnings,
+ info = result.summary.info
+ ));
+ output.push('\n');
+ output
+}
+
+/// A report handler for the terminal, drawing with unicode and, unless `NO_COLOR` asks
+/// otherwise, ANSI colors.
+///
+/// # Arguments
+///
+/// * `colored` - Whether colors are wanted.
+fn handler(colored: bool) -> GraphicalReportHandler {
+ let no_color = std::env::var("NO_COLOR").is_ok_and(|value| value != "0");
+ let theme = if colored && !no_color {
+ GraphicalTheme::unicode()
+ } else {
+ GraphicalTheme::unicode_nocolor()
+ };
+ GraphicalReportHandler::new_themed(theme).with_context_lines(CONTEXT_LINES)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use dsc_lib::{diagnostics::SchemaValidationError, dscerror::DscError, dscresources::dscresource::validate_json_all, lint::{LintCategory, LintDiagnostic, LintLevel}};
+ use serde_json::json;
+
+ fn schema_error() -> DscError {
+ let schema = json!({"type": "object", "additionalProperties": false, "properties": {"output": {"type": "string"}}});
+ let text = "{\n \"otput\": \"hi\"\n}";
+ let violations = validate_json_all("test", &schema, &serde_json::from_str(text).unwrap()).unwrap();
+ let error = SchemaValidationError::new("input for 'Test/Echo'", violations)
+ .with_schema(&schema)
+ .with_source(text, "input", "");
+ DscError::SchemaValidation(Box::new(error))
+ }
+
+ #[test]
+ fn plain_errors_render_as_their_message() {
+ let error = DscError::Operation("failed".to_string());
+ assert_eq!(render(&error), error.to_string());
+ }
+
+ #[test]
+ fn errors_with_source_render_a_snippet_and_help() {
+ let report = render(&schema_error());
+ assert!(report.contains("input for 'Test/Echo'"), "{report}");
+ assert!(report.contains("\"otput\""), "{report}");
+ assert!(report.contains("'otput' isn't a known property"), "{report}");
+ assert!(report.contains("Did you mean 'output'"), "{report}");
+ assert!(report.contains("[input:2:3]"), "{report}");
+ }
+
+ #[test]
+ fn lint_results_render_each_diagnostic_in_its_document() {
+ let text = "resources:\n- name: a\n type: T/E\n- name: a\n type: T/E\n".to_string();
+ let diagnostic = LintDiagnostic {
+ level: LintLevel::Warning,
+ rule: "duplicate-resource".to_string(),
+ category: LintCategory::Document,
+ message: "declared twice".to_string(),
+ path: "/resources/1".to_string(),
+ file: Some("demo.dsc.yaml".to_string()),
+ location: None,
+ };
+ let result = LintResult::from_diagnostics(vec![diagnostic], false);
+ let descriptions = HashMap::from([("duplicate-resource".to_string(), "Names must be unique.".to_string())]);
+ let output = render_lint_result(&result, &[(Some("demo.dsc.yaml".to_string()), text)], &descriptions, false);
+ assert!(output.contains("duplicate-resource"), "{output}");
+ assert!(output.contains("declared twice"), "{output}");
+ assert!(output.contains("[demo.dsc.yaml:4:3]"), "{output}");
+ assert!(output.contains("Names must be unique."), "{output}");
+ assert!(output.contains("errors: 0, warnings: 1, info: 0"), "{output}");
+ }
+}
diff --git a/dsc/src/resource_command.rs b/dsc/src/resource_command.rs
index 322057799..84500efa4 100644
--- a/dsc/src/resource_command.rs
+++ b/dsc/src/resource_command.rs
@@ -2,6 +2,7 @@
// Licensed under the MIT License.
use crate::args::{GetOutputFormat, OutputFormat};
+use crate::report::render;
use crate::util::{EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_JSON_ERROR, EXIT_DSC_RESOURCE_NOT_FOUND, write_object};
use dsc_lib::configure::config_doc::{Configuration, ExecutionKind};
use dsc_lib::configure::add_resource_export_results_to_configuration;
@@ -64,7 +65,7 @@ pub fn get(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, version
write_object(&json, format, false)?;
}
Err(err) => {
- error!("{err}");
+ error!("{}", render(&err));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}
}
@@ -87,7 +88,7 @@ pub fn get_all(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, ver
let export_result = match resource.export(&input) {
Ok(export) => { export }
Err(err) => {
- error!("{err}");
+ error!("{}", render(&err));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}
};
@@ -167,13 +168,13 @@ pub fn set(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, version
Ok(GetResult::Resource(response)) => response.actual_state,
Ok(_) => unreachable!(),
Err(err) => {
- error!("{err}");
+ error!("{}", render(&err));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}
};
if let Err(err) = resource.delete(input, &ExecutionKind::Actual) {
- error!("{err}");
+ error!("{}", render(&err));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}
@@ -181,7 +182,7 @@ pub fn set(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, version
Ok(GetResult::Resource(response)) => response.actual_state,
Ok(_) => unreachable!(),
Err(err) => {
- error!("{err}");
+ error!("{}", render(&err));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}
};
@@ -218,7 +219,7 @@ pub fn set(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, version
write_object(&json, format, false)?;
}
Err(err) => {
- error!("{err}");
+ error!("{}", render(&err));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}
}
@@ -255,7 +256,7 @@ pub fn test(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, versio
write_object(&json, format, false)?;
}
Err(err) => {
- error!("{err}");
+ error!("{}", render(&err));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}
}
@@ -296,7 +297,7 @@ pub fn delete(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, vers
}
},
Err(err) => {
- error!("{err}");
+ error!("{}", render(&err));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}
}
@@ -326,7 +327,7 @@ pub fn schema(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, vers
write_object(&json, format, false)?;
}
Err(err) => {
- error!("{err}");
+ error!("{}", render(&err));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}
}
@@ -346,7 +347,7 @@ pub fn export(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, vers
let mut conf = Configuration::new();
if let Err(err) = add_resource_export_results_to_configuration(dsc_resource, &mut conf, input) {
- error!("{err}");
+ error!("{}", render(&err));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}
diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs
index 7e09ecdba..9af8fdb28 100644
--- a/dsc/src/subcommand.rs
+++ b/dsc/src/subcommand.rs
@@ -2,10 +2,12 @@
// Licensed under the MIT License.
use crate::args::{ConfigSubCommand, SchemaType, ExtensionSubCommand, FunctionSubCommand, GetOutputFormat, ListOutputFormat, OutputFormat, ResourceSubCommand};
+use crate::report::{render, render_lint_result};
use crate::resolve::{get_contents, Include};
use crate::resource_command::{get_resource, self};
use crate::tablewriter::Table;
-use crate::util::{current_dsc_version, get_input, get_schema, in_desired_state, read_input, set_dscconfigroot, write_object, DSC_CONFIG_ROOT, EXIT_DSC_ASSERTION_FAILED, EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_INVALID_INPUT, EXIT_JSON_ERROR, EXIT_VALIDATION_FAILED};
+use crate::util::{current_dsc_version, get_input, get_input_with_source, get_schema, in_desired_state, read_input, set_dscconfigroot, write_object, DSC_CONFIG_ROOT, EXIT_DSC_ASSERTION_FAILED, EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_INVALID_INPUT, EXIT_JSON_ERROR, EXIT_VALIDATION_FAILED};
+use dsc_lib::diagnostics::DocumentSource;
use dsc_lib::lint::{LintError, LintLevel, LintOptions, LintResult, Linter, RuleLevelOverride};
use dsc_lib::types::{FullyQualifiedTypeName, ResourceVersionReq, TypeNameFilter};
use dsc_lib::{
@@ -71,7 +73,7 @@ pub fn config_get(configurator: &mut Configurator, format: Option<&OutputFormat>
}
},
Err(err) => {
- error!("{err}");
+ error!("{}", render(&err));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}
}
@@ -106,7 +108,7 @@ pub fn config_set(configurator: &mut Configurator, format: Option<&OutputFormat>
}
},
Err(err) => {
- error!("Error: {err}");
+ error!("Error: {}", render(&err));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}
}
@@ -207,7 +209,7 @@ pub fn config_test(configurator: &mut Configurator, format: Option<&OutputFormat
}
},
Err(err) => {
- error!("{err}");
+ error!("{}", render(&err));
let exit_code = config_test_exit_code(&err);
return Err(ExitCode::from(exit_code));
}
@@ -217,7 +219,7 @@ pub fn config_test(configurator: &mut Configurator, format: Option<&OutputFormat
fn config_test_exit_code(error: &DscError) -> u8 {
match error {
- DscError::Schema(_) => EXIT_VALIDATION_FAILED,
+ DscError::Schema(_) | DscError::SchemaValidation(_) => EXIT_VALIDATION_FAILED,
_ => EXIT_DSC_ERROR,
}
}
@@ -225,10 +227,13 @@ fn config_test_exit_code(error: &DscError) -> u8 {
#[cfg(test)]
mod tests {
use super::{config_test_exit_code, DscError, EXIT_DSC_ERROR, EXIT_VALIDATION_FAILED};
+ use dsc_lib::diagnostics::SchemaValidationError;
#[test]
fn schema_errors_use_validation_failed_exit_code() {
assert_eq!(config_test_exit_code(&DscError::Schema("invalid value".to_string())), EXIT_VALIDATION_FAILED);
+ let violations = DscError::SchemaValidation(Box::new(SchemaValidationError::new("input for 'Test/Echo'", Vec::new())));
+ assert_eq!(config_test_exit_code(&violations), EXIT_VALIDATION_FAILED);
}
#[test]
@@ -259,7 +264,7 @@ pub fn config_export(configurator: &mut Configurator, format: Option<&OutputForm
}
},
Err(err) => {
- error!("{err}");
+ error!("{}", render(&err));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}
}
@@ -299,14 +304,14 @@ fn initialize_config_root(path: Option<&String>) -> Result, ExitC
#[allow(clippy::too_many_lines)]
#[allow(clippy::too_many_arguments)]
pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, mounted_path: Option<&String>, as_group: &bool, as_assert: &bool, as_include: &bool, progress_format: ProgressFormat) -> Result<(), ExitCode> {
- let (new_parameters, json_string) = match subcommand {
+ let (new_parameters, json_string, source) = match subcommand {
ConfigSubCommand::Get { input, file, .. } |
ConfigSubCommand::Set { input, file, .. } |
ConfigSubCommand::Test { input, file, .. } |
ConfigSubCommand::Validate { input, file, .. } |
ConfigSubCommand::Export { input, file, .. } => {
let new_path = initialize_config_root(file.as_ref())?;
- let document = get_input(input.as_ref(), new_path.as_ref())?;
+ let (document, source) = get_input_with_source(input.as_ref(), new_path.as_ref())?;
if *as_include {
let (new_parameters, config_json) = match get_contents(&document) {
Ok((parameters, config_json)) => (parameters, config_json),
@@ -315,9 +320,9 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, mounte
return Err(ExitCode::from(EXIT_DSC_ERROR));
}
};
- (new_parameters, config_json)
+ (new_parameters, config_json, None)
} else {
- (None, document)
+ (None, document, source)
}
},
ConfigSubCommand::Resolve { input, file, .. } => {
@@ -330,17 +335,20 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, mounte
return Err(ExitCode::from(EXIT_DSC_ERROR));
}
};
- (new_parameters, config_json)
+ (new_parameters, config_json, None)
}
};
let mut configurator = match Configurator::new_with_dsc_version(&json_string, progress_format, current_dsc_version()) {
Ok(configurator) => configurator,
Err(err) => {
- error!("Error: {err}");
+ error!("Error: {}", render(&err));
return Err(ExitCode::from(EXIT_DSC_ERROR));
}
};
+ if let Some(source) = source {
+ configurator.set_source(source);
+ }
if let ConfigSubCommand::Set { what_if , .. } = subcommand && *what_if {
configurator.context.execution_type = ExecutionKind::WhatIf;
@@ -408,12 +416,12 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, mounte
}
}
} else {
- match validate_config(configurator.get_config(), progress_format) {
+ match validate_config(configurator.get_config(), configurator.source(), progress_format) {
Ok(()) => {
// valid, so do nothing
},
Err(err) => {
- error!("{err}");
+ error!("{}", render(&err));
result.valid = false;
}
}
@@ -577,9 +585,9 @@ pub fn lint(args: &LintArgs, progress_format: ProgressFormat) -> Result<(), Exit
}
let mut result = LintResult::from_diagnostics(Vec::new(), true);
- for (file, document) in documents {
- linter.set_file(file);
- let document_result = match linter.lint(&document) {
+ for (file, document) in &documents {
+ linter.set_file(file.clone());
+ let document_result = match linter.lint(document) {
Ok(document_result) => document_result,
Err(DscError::Lint(err @ LintError::InvalidParameters(_))) => {
error!("{}: {err}", t!("subcommand.lintFailed"));
@@ -593,14 +601,19 @@ pub fn lint(args: &LintArgs, progress_format: ProgressFormat) -> Result<(), Exit
result.merge(document_result);
}
- let json = match serde_json::to_string(&result) {
- Ok(json) => json,
- Err(err) => {
- error!("JSON: {err}");
- return Err(ExitCode::from(EXIT_JSON_ERROR));
- }
- };
- write_object(&json, args.output_format, false)?;
+ if args.output_format.is_none() && io::stdout().is_terminal() {
+ let descriptions = linter.rules().into_iter().map(|rule| (rule.id, rule.description)).collect();
+ print!("{}", render_lint_result(&result, &documents, &descriptions, true));
+ } else {
+ let json = match serde_json::to_string(&result) {
+ Ok(json) => json,
+ Err(err) => {
+ error!("JSON: {err}");
+ return Err(ExitCode::from(EXIT_JSON_ERROR));
+ }
+ };
+ write_object(&json, args.output_format, false)?;
+ }
let threshold = args.fail_on.unwrap_or(LintLevel::Error);
if result.has_diagnostic_at_least(threshold) {
@@ -657,6 +670,8 @@ fn list_lint_rules(linter: &Linter, format: Option<&OutputFormat>) -> Result<(),
/// # Arguments
///
/// * `config` - The configuration to validate.
+/// * `source` - The text the configuration was read from, so that errors point into it.
+/// * `progress_format` - How to report progress during resource discovery.
///
/// # Returns
///
@@ -665,12 +680,12 @@ fn list_lint_rules(linter: &Linter, format: Option<&OutputFormat>) -> Result<(),
/// # Errors
///
/// * `DscError` - The error that occurred.
-pub fn validate_config(config: &Configuration, progress_format: ProgressFormat) -> Result<(), DscError> {
+pub fn validate_config(config: &Configuration, source: Option<&DocumentSource>, progress_format: ProgressFormat) -> Result<(), DscError> {
// first validate against the config schema
debug!("{}", t!("subcommand.validatingConfiguration"));
let schema = serde_json::to_value(get_schema(SchemaType::Configuration))?;
let config_value = serde_json::to_value(config)?;
- validate_json("Configuration", &schema, &config_value)?;
+ validate_json("Configuration", &schema, &config_value).map_err(|err| anchor_error(err, source, ""))?;
let mut dsc = DscManager::new();
// then validate each resource
@@ -693,7 +708,7 @@ pub fn validate_config(config: &Configuration, progress_format: ProgressFormat)
}
dsc.find_resources(&resource_types, progress_format)?;
- for resource_block in resources {
+ for (index, resource_block) in resources.iter().enumerate() {
let Some(type_name) = resource_block["type"].as_str() else {
return Err(DscError::Validation(t!("subcommand.resourceTypeNotSpecified").to_string()));
};
@@ -712,13 +727,30 @@ pub fn validate_config(config: &Configuration, progress_format: ProgressFormat)
// see if the resource is command based
if resource.implemented_as == Some(ImplementedAs::Command) {
- validate_properties(resource, &resource_block["properties"])?;
+ validate_properties(resource, &resource_block["properties"])
+ .map_err(|err| anchor_error(err, source, &format!("/resources/{index}/properties")))?;
}
}
Ok(())
}
+/// Point a schema validation error at the document it was found in.
+///
+/// # Arguments
+///
+/// * `error` - The error.
+/// * `source` - The document text, when known.
+/// * `base` - The pointer in the document that the error's paths are relative to.
+fn anchor_error(error: DscError, source: Option<&DocumentSource>, base: &str) -> DscError {
+ match (error, source) {
+ (DscError::SchemaValidation(schema_error), Some(source)) => {
+ DscError::SchemaValidation(Box::new(schema_error.with_source(&source.text, &source.name, base)))
+ },
+ (error, _) => error,
+ }
+}
+
pub fn extension(subcommand: &ExtensionSubCommand, progress_format: ProgressFormat) -> Result<(), ExitCode> {
let mut dsc = DscManager::new();
diff --git a/dsc/src/util.rs b/dsc/src/util.rs
index c10b101a3..498cca203 100644
--- a/dsc/src/util.rs
+++ b/dsc/src/util.rs
@@ -19,6 +19,7 @@ use dsc_lib::{
ResourceTestResult,
},
},
+ diagnostics::DocumentSource,
discovery::{
command_discovery::ManifestList,
Discovery,
@@ -449,6 +450,7 @@ pub fn enable_tracing(trace_level_arg: Option<&TraceLevel>, trace_format_arg: Op
tracing_level,
stderr_format
}.init_subscriber();
+ crate::report::set_stderr_format(stderr_format);
drop(default_guard);
if tracing::subscriber::set_global_default(subscriber).is_err() {
@@ -478,13 +480,40 @@ pub fn enable_tracing(trace_level_arg: Option<&TraceLevel>, trace_format_arg: Op
/// Returns `EXIT_INVALID_INPUT` when the document can't be read (see [`read_input`]) or isn't
/// valid JSON or YAML.
pub fn get_input(input: Option<&String>, file: Option<&String>) -> Result {
- let value = read_input(input, file)?;
- if value.is_empty() {
- return Ok(value);
+ get_input_with_source(input, file).map(|(json, _)| json)
+}
+
+/// Read an input document, converting it to a JSON string and keeping the text it was read from.
+///
+/// # Arguments
+///
+/// * `input` - The inline document, if provided.
+/// * `file` - The path to the document, or `-` to read it from STDIN.
+///
+/// # Returns
+///
+/// The document as JSON and, when there is a document, its text named after the file it was
+/// read from, or `input` for inline text and STDIN. Both are empty when neither argument is
+/// provided.
+///
+/// # Errors
+///
+/// Returns `EXIT_INVALID_INPUT` when the document can't be read (see [`read_input`]) or isn't
+/// valid JSON or YAML.
+pub fn get_input_with_source(input: Option<&String>, file: Option<&String>) -> Result<(String, Option), ExitCode> {
+ let text = read_input(input, file)?;
+ if text.is_empty() {
+ return Ok((text, None));
}
- match parse_input_to_json(&value) {
- Ok(json) => Ok(json),
+ match parse_input_to_json(&text) {
+ Ok(json) => {
+ let name = match file {
+ Some(path) if path != "-" => path.clone(),
+ _ => t!("util.inputSourceName").to_string(),
+ };
+ Ok((json, Some(DocumentSource::new(name, text))))
+ },
Err(err) => {
error!("{}: {err}", t!("util.failedToParseInput"));
Err(ExitCode::from(EXIT_INVALID_INPUT))
diff --git a/dsc/tests/dsc_diagnostics.tests.ps1 b/dsc/tests/dsc_diagnostics.tests.ps1
new file mode 100644
index 000000000..d1f73fb36
--- /dev/null
+++ b/dsc/tests/dsc_diagnostics.tests.ps1
@@ -0,0 +1,84 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+Describe 'Schema validation diagnostics' {
+ BeforeAll {
+ $schemaUri = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json'
+ $configYaml = @"
+`$schema: $schemaUri
+resources:
+- name: first
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ output: fine
+- name: second
+ type: Microsoft.DSC.Debug/Echo
+ properties:
+ otput: hi
+"@
+ $configPath = Join-Path $TestDrive 'typo.dsc.yaml'
+ Set-Content -Path $configPath -Value $configYaml
+ }
+
+ It 'shows the invalid property of resource input in a snippet with a suggestion' {
+ $resourceInput = @'
+{
+ "otput": "hi"
+}
+'@
+ $null = dsc resource get --resource Microsoft.DSC.Debug/Echo --input $resourceInput 2> $TestDrive/error.log
+ $LASTEXITCODE | Should -Be 2
+ $log = Get-Content -Path $TestDrive/error.log -Raw
+ $log | Should -Match "Schema: input for 'Microsoft.DSC.Debug/Echo'" -Because $log
+ $log | Should -Match "'otput' was unexpected" -Because $log
+ # the snippet opens at the first label, which marks the object missing its required property
+ $log | Should -Match '\[input:1:1\]' -Because $log
+ $log | Should -Match "'otput' isn't a known property" -Because $log
+ $log | Should -Match "missing required property 'output'" -Because $log
+ $log | Should -Match "Did you mean 'output' instead of 'otput'" -Because $log
+ }
+
+ It 'points at the resource declaration in the configuration file for: ' -TestCases @(
+ @{ operation = 'get'; exitCode = 2 }
+ @{ operation = 'test'; exitCode = 5 }
+ ) {
+ param($operation, $exitCode)
+
+ $null = dsc config $operation --file $configPath 2> $TestDrive/error.log
+ $LASTEXITCODE | Should -Be $exitCode
+ $log = Get-Content -Path $TestDrive/error.log -Raw
+ $log | Should -Match "Schema: input for 'Microsoft.DSC.Debug/Echo'" -Because $log
+ # the snippet is the author's YAML, not the evaluated properties
+ $log | Should -Match ([regex]::Escape($configPath) + ':9:3') -Because $log
+ $log | Should -Match '- name: second' -Because $log
+ $log | Should -Match 'otput: hi' -Because $log
+ $log | Should -Match "'otput' isn't a known property" -Because $log
+ $log | Should -Match "Did you mean 'output' instead of 'otput'" -Because $log
+ $log | Should -Not -Match 'name: first' -Because 'only the failing resource is shown'
+ }
+
+ It 'names inline input as input in the snippet' {
+ $null = dsc config get --input $configYaml 2> $TestDrive/error.log
+ $LASTEXITCODE | Should -Be 2
+ $log = Get-Content -Path $TestDrive/error.log -Raw
+ $log | Should -Match '\[input:9:3\]' -Because $log
+ }
+
+ It 'keeps the message on one line for the json trace format' {
+ $null = dsc --trace-format json config get --file $configPath 2> $TestDrive/error.log
+ $LASTEXITCODE | Should -Be 2
+ $errors = @(Get-Content -Path $TestDrive/error.log | Where-Object { $_ -like '*"level":"ERROR"*' })
+ $errors.Count | Should -Be 1 -Because (Get-Content -Path $TestDrive/error.log -Raw)
+ $entry = $errors[0] | ConvertFrom-Json
+ $entry.fields.message | Should -Match "^Schema: input for 'Microsoft.DSC.Debug/Echo': Additional properties are not allowed \('otput' was unexpected\)" -Because $entry.fields.message
+ $entry.fields.message | Should -Not -Match 'otput: hi' -Because 'the snippet is only rendered for the default and plaintext formats'
+ }
+
+ It 'renders the snippet without colors for the plaintext trace format' {
+ $null = dsc --trace-format plaintext config get --file $configPath 2> $TestDrive/error.log
+ $LASTEXITCODE | Should -Be 2
+ $log = Get-Content -Path $TestDrive/error.log -Raw
+ $log | Should -Match "'otput' isn't a known property" -Because $log
+ $log | Should -Not -Match "`e\[" -Because 'plaintext output has no ANSI escape sequences'
+ }
+}
diff --git a/dsc/tests/dsc_lint.tests.ps1 b/dsc/tests/dsc_lint.tests.ps1
index 9f1c3989c..f17c53d4f 100644
--- a/dsc/tests/dsc_lint.tests.ps1
+++ b/dsc/tests/dsc_lint.tests.ps1
@@ -69,6 +69,17 @@ resources:
$result.diagnostics[0].category | Should -BeExactly 'document'
$result.diagnostics[0].path | Should -BeExactly '/resources/2'
$result.diagnostics[0].message | Should -BeLike "*EnsureTest1Value*"
+ # the third resource starts on line 11 of the document, after the leading `- `
+ $result.diagnostics[0].location.line | Should -Be 11
+ $result.diagnostics[0].location.column | Should -Be 3
+ }
+
+ It 'records the line and column of a diagnostic inside a value' {
+ $result = $unusedParameterConfig | dsc lint --file - --no-discovery 2>$TestDrive/error.log | ConvertFrom-Json
+ $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw)
+ $result.diagnostics[0].path | Should -BeExactly '/parameters/unused'
+ $result.diagnostics[0].location.line | Should -Be 3
+ $result.diagnostics[0].location.column | Should -Be 3
}
It 'accepts the document with --input' {
diff --git a/lib/dsc-lib/Cargo.toml b/lib/dsc-lib/Cargo.toml
index ea1de3f57..c47986b35 100644
--- a/lib/dsc-lib/Cargo.toml
+++ b/lib/dsc-lib/Cargo.toml
@@ -24,7 +24,7 @@ path-absolutize = { workspace = true }
regex = { workspace = true }
rt-format = { workspace = true }
rust-i18n = { workspace = true }
-# reqwest = { version = "0.12.8", features = ["native-tls"], default-features = false }
+saphyr-parser = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml
index bc3d57520..cfb6e5b3b 100644
--- a/lib/dsc-lib/locales/en-us.toml
+++ b/lib/dsc-lib/locales/en-us.toml
@@ -116,6 +116,13 @@ importingParametersFromComplexInput = "Importing parameters from complex input"
importingParametersFromInput = "Importing parameters from simple input"
invalidParamsFormat = "Invalid parameters format: %{error}"
+[diagnostics.schema]
+unexpectedProperty = "'%{property}' isn't a known property"
+missingProperty = "missing required property '%{property}'"
+didYouMean = "Did you mean '%{suggestion}' instead of '%{property}'?"
+knownProperties = "Known properties: %{properties}"
+at = "at '%{path}'"
+
[discovery.commandDiscovery]
couldNotReadSetting = "Could not read 'resourcePath' setting"
appendingEnvPath = "Appending PATH to resourcePath"
@@ -233,6 +240,10 @@ writingStdin = "Writing to command STDIN: %{input}"
processTrace = "PID %{process_id}: %{message}"
processTraceWithTarget = "PID %{process_id}: %{target}: %{line_number}: %{message}"
redactedSchemaErrorMessage = "'secureString' and 'secureObject' values are not compatible with the schema, use '.secureString' or '.secureObject' in an expression to convert to string or object"
+schemaSubjectInput = "input for '%{resource}'"
+schemaSubjectOutput = "output from '%{resource}'"
+schemaSourceInput = "input"
+schemaSourceOutput = "output"
[dscresources.dscresource]
invokeGet = "Invoking get for '%{resource}'"
@@ -1122,6 +1133,9 @@ documentKindConfiguration = "configuration document"
documentKindResourceManifest = "resource manifest"
documentKindExtensionManifest = "extension manifest"
+[lint.report]
+inputSourceName = "input"
+
[lint.rules.document]
duplicateResourceDescription = "Resource names must be unique among resources of the same type at the same level."
duplicateResource = "Resource '%{name}' of type '%{type_name}' is declared more than once"
diff --git a/lib/dsc-lib/src/configure/mod.rs b/lib/dsc-lib/src/configure/mod.rs
index a612970fc..0e87dd9d3 100644
--- a/lib/dsc-lib/src/configure/mod.rs
+++ b/lib/dsc-lib/src/configure/mod.rs
@@ -6,6 +6,7 @@ use crate::configure::context::{Context, ProcessMode};
use crate::configure::parameters::{SecureObject, SecureString, import_parameters, is_secure_value};
use crate::configure::{config_doc::{ExecutionKind, IntOrExpression, Metadata, Parameter, Resource, ResourceDiscoveryMode, RestartRequired, ValueOrCopy}};
use crate::discovery::discovery_trait::DiscoveryFilter;
+use crate::diagnostics::DocumentSource;
use crate::dscerror::DscError;
use crate::dscresources::{
{dscresource::{Capability, Invoke, get_diff, validate_properties, get_adapter_input_kind},
@@ -45,6 +46,7 @@ pub struct Configurator {
discovery: Discovery,
statement_parser: Statement,
progress_format: ProgressFormat,
+ source: Option,
}
/// Invokes the [`Discovery::find_resource`] method to retrieve a specific resource or raise a
@@ -557,6 +559,7 @@ impl Configurator {
discovery: discovery.clone(),
statement_parser: Statement::new()?,
progress_format,
+ source: None,
};
config.validate_config()?;
for extension in discovery.extensions.values() {
@@ -575,6 +578,52 @@ impl Configurator {
&self.config
}
+ /// Record the text the configuration was read from, so that errors found in a resource's
+ /// properties point at the author's document rather than at the evaluated properties.
+ ///
+ /// # Arguments
+ ///
+ /// * `source` - The document text and how to name it.
+ pub fn set_source(&mut self, source: DocumentSource) {
+ self.source = Some(source);
+ }
+
+ /// The text the configuration was read from, when the host recorded it.
+ #[must_use]
+ pub fn source(&self) -> Option<&DocumentSource> {
+ self.source.as_ref()
+ }
+
+ /// Point a schema validation error from a resource at the resource's declaration in the
+ /// document.
+ ///
+ /// Resources are validated after their expressions are evaluated, so the error arrives
+ /// pointing at the evaluated properties. When the document text is known and the resource
+ /// is declared in it directly, the error is re-anchored to the resource's `properties` in the
+ /// document. Resources that a copy loop expands keep the evaluated view. Other errors pass
+ /// through unchanged.
+ ///
+ /// # Arguments
+ ///
+ /// * `error` - The error the resource operation returned.
+ /// * `resource` - The resource that was being invoked.
+ fn anchor_error(&self, error: DscError, resource: &Resource) -> DscError {
+ let DscError::SchemaValidation(schema_error) = error else {
+ return error;
+ };
+ let Some(source) = &self.source else {
+ return DscError::SchemaValidation(schema_error);
+ };
+ let declared = self.config.resources.iter().position(|declared| {
+ declared.name == resource.name && declared.resource_type == resource.resource_type
+ });
+ let Some(index) = declared else {
+ return DscError::SchemaValidation(schema_error);
+ };
+ let base = format!("/resources/{index}/properties");
+ DscError::SchemaValidation(Box::new(schema_error.with_source(&source.text, &source.name, &base)))
+ }
+
/// Get the discovery.
///
/// # Returns
@@ -650,7 +699,7 @@ impl Configurator {
Err(e) => {
progress.set_failure(get_failure_from_error(&e));
progress.write_increment(1);
- return Err(e);
+ return Err(self.anchor_error(e, &resource));
},
};
let end_datetime = chrono::Local::now();
@@ -764,7 +813,7 @@ impl Configurator {
Err(e) => {
progress.set_failure(get_failure_from_error(&e));
progress.write_increment(1);
- return Err(e);
+ return Err(self.anchor_error(e, &resource));
},
};
end_datetime = chrono::Local::now();
@@ -776,7 +825,7 @@ impl Configurator {
Err(e) => {
progress.set_failure(get_failure_from_error(&e));
progress.write_increment(1);
- return Err(e);
+ return Err(self.anchor_error(e, &resource));
},
};
@@ -786,7 +835,7 @@ impl Configurator {
Err(e) => {
progress.set_failure(get_failure_from_error(&e));
progress.write_increment(1);
- return Err(e);
+ return Err(self.anchor_error(e, &resource));
},
};
@@ -805,7 +854,7 @@ impl Configurator {
Err(e) => {
progress.set_failure(get_failure_from_error(&e));
progress.write_increment(1);
- return Err(e);
+ return Err(self.anchor_error(e, &resource));
},
};
end_datetime = chrono::Local::now();
@@ -932,7 +981,7 @@ impl Configurator {
Err(e) => {
progress.set_failure(get_failure_from_error(&e));
progress.write_increment(1);
- return Err(e);
+ return Err(self.anchor_error(e, &resource));
},
};
let end_datetime = chrono::Local::now();
@@ -1025,7 +1074,7 @@ impl Configurator {
Err(e) => {
progress.set_failure(get_failure_from_error(&e));
progress.write_increment(1);
- return Err(e);
+ return Err(self.anchor_error(e, resource));
},
};
self.context.references.insert(resource_id(&resource.resource_type, &evaluated_name), serde_json::to_value(&export_result.actual_state)?);
diff --git a/lib/dsc-lib/src/diagnostics/mod.rs b/lib/dsc-lib/src/diagnostics/mod.rs
new file mode 100644
index 000000000..a78e3cb2b
--- /dev/null
+++ b/lib/dsc-lib/src/diagnostics/mod.rs
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Diagnostics that point at the text they apply to.
+//!
+//! DSC reads documents and resource input as JSON or YAML text and validates the parsed value.
+//! The types here keep the connection between a problem found in the value and the text it came
+//! from, so a host can show the problem in place rather than only describe it:
+//!
+//! - [`SourceMap`] finds the byte span and line/column of any JSON pointer in JSON or YAML text.
+//! - [`SchemaValidationError`] is a [`miette::Diagnostic`] for JSON schema violations, with a
+//! label on each violation when its source text is attached.
+//! - [`DocumentSource`] carries the text and name of a document a host read, so library code
+//! can anchor the problems it finds to the author's text.
+
+pub mod schema;
+pub mod source_map;
+
+pub use schema::SchemaValidationError;
+pub use source_map::SourceMap;
+
+/// The text a document was read from, for diagnostics that point into it.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct DocumentSource {
+ /// How to name the text in a report: the file it was read from when known, otherwise a
+ /// description like `input`.
+ pub name: String,
+ /// The document text as the author wrote it, as JSON or YAML.
+ pub text: String,
+}
+
+impl DocumentSource {
+ /// Create a document source.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - How to name the text in a report.
+ /// * `text` - The document text.
+ #[must_use]
+ pub fn new(name: impl Into, text: impl Into) -> Self {
+ Self { name: name.into(), text: text.into() }
+ }
+}
diff --git a/lib/dsc-lib/src/diagnostics/schema.rs b/lib/dsc-lib/src/diagnostics/schema.rs
new file mode 100644
index 000000000..137ed452f
--- /dev/null
+++ b/lib/dsc-lib/src/diagnostics/schema.rs
@@ -0,0 +1,387 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Schema validation failures that point at the text they were found in.
+
+// TODO: Remove when updating to rustc 1.94; false positive from thiserror + rust-i18n t!() macro
+#![allow(unused_assignments)]
+
+use std::fmt::{self, Display};
+
+use miette::{Diagnostic, LabeledSpan, NamedSource, SourceCode};
+use rust_i18n::t;
+use serde_json::Value;
+
+use crate::dscresources::dscresource::{SchemaViolation, SchemaViolationDetail};
+
+use super::source_map::SourceMap;
+
+/// Property names within this edit distance of an unknown property are suggested as corrections.
+const MAX_SUGGESTION_DISTANCE: usize = 2;
+/// The most property names to list when there is no close match.
+const MAX_LISTED_PROPERTIES: usize = 12;
+
+/// Every violation found when validating a value against a JSON schema.
+///
+/// The error is a [`miette::Diagnostic`]: when source text is attached with
+/// [`with_source`](Self::with_source), each violation becomes a label on the span of the text it
+/// applies to, and unknown properties get a suggestion in the help text when the schema is
+/// attached with [`with_schema`](Self::with_schema). Without source text the error still lists
+/// every violation and its JSON pointer in its message.
+#[derive(Debug)]
+pub struct SchemaValidationError {
+ subject: String,
+ violations: Vec,
+ source: Option>,
+ labels: Vec,
+ help: Option,
+}
+
+impl SchemaValidationError {
+ /// Create an error for a set of violations.
+ ///
+ /// # Arguments
+ ///
+ /// * `subject` - What was validated, as it should read in the message, like
+ /// `input for 'Microsoft.DSC.Debug/Echo'` or `Configuration`.
+ /// * `violations` - The violations found; must not be empty.
+ #[must_use]
+ pub fn new(subject: impl Into, violations: Vec) -> Self {
+ Self {
+ subject: subject.into(),
+ violations,
+ source: None,
+ labels: Vec::new(),
+ help: None,
+ }
+ }
+
+ /// Use the schema to suggest corrections for unknown properties in the help text.
+ ///
+ /// # Arguments
+ ///
+ /// * `schema` - The schema the value was validated against.
+ #[must_use]
+ pub fn with_schema(mut self, schema: &Value) -> Self {
+ let mut suggestions = Vec::new();
+ let mut listed = Vec::new();
+ for violation in &self.violations {
+ let SchemaViolationDetail::UnexpectedProperties(unexpected) = &violation.detail else {
+ continue;
+ };
+ let Some(known) = known_properties(schema, &violation.schema_path) else {
+ continue;
+ };
+ for property in unexpected {
+ match closest(property, &known) {
+ Some(suggestion) => suggestions.push(t!("diagnostics.schema.didYouMean", property = property, suggestion = suggestion).to_string()),
+ None if listed.is_empty() && !known.is_empty() && known.len() <= MAX_LISTED_PROPERTIES => {
+ listed.push(t!("diagnostics.schema.knownProperties", properties = known.join(", ")).to_string());
+ },
+ None => {},
+ }
+ }
+ }
+ suggestions.extend(listed);
+ if !suggestions.is_empty() {
+ self.help = Some(suggestions.join("\n"));
+ }
+ self
+ }
+
+ /// Attach the text the validated value came from, so the report can show each violation in
+ /// place.
+ ///
+ /// Replaces any text attached before, so a host that knows the document a value was taken
+ /// from can point at the document instead of the value.
+ ///
+ /// # Arguments
+ ///
+ /// * `text` - The document text, as JSON or YAML.
+ /// * `name` - How to name the text in the report, like `input` or a file path.
+ /// * `base` - The JSON pointer in `text` that the violations' instance paths are relative
+ /// to; empty when the text is the validated value itself.
+ ///
+ /// # Returns
+ ///
+ /// The error with the text attached, or unchanged when the text can't be parsed.
+ #[must_use]
+ pub fn with_source(mut self, text: &str, name: &str, base: &str) -> Self {
+ let Some(map) = SourceMap::parse(text) else {
+ return self;
+ };
+ let mut labels = Vec::new();
+ for violation in &self.violations {
+ labels.extend(labels_for(violation, &map, base));
+ }
+ if labels.is_empty() {
+ return self;
+ }
+ self.labels = labels;
+ self.source = Some(NamedSource::new(name, map.text().to_string()));
+ self
+ }
+
+ /// Replace every violation message and drop the source text, for a value that holds secure
+ /// data the report must not echo.
+ ///
+ /// # Arguments
+ ///
+ /// * `message` - The message to show in place of each violation's.
+ #[must_use]
+ pub fn redacted(mut self, message: &str) -> Self {
+ for violation in &mut self.violations {
+ violation.message = message.to_string();
+ violation.alternatives.clear();
+ }
+ self.source = None;
+ self.labels.clear();
+ self.help = None;
+ self
+ }
+
+ /// What was validated.
+ #[must_use]
+ pub fn subject(&self) -> &str {
+ &self.subject
+ }
+
+ /// The violations found.
+ #[must_use]
+ pub fn violations(&self) -> &[SchemaViolation] {
+ &self.violations
+ }
+
+ /// Whether the report can show the violations in their source text.
+ #[must_use]
+ pub fn has_source(&self) -> bool {
+ self.source.is_some()
+ }
+}
+
+impl Display for SchemaValidationError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}: {}", t!("dscerror.schema"), self.subject)?;
+ for (index, violation) in self.violations.iter().enumerate() {
+ write!(f, "{} {}", if index == 0 { ":" } else { ";" }, violation.message)?;
+ if !violation.instance_path.is_empty() {
+ write!(f, " {}", t!("diagnostics.schema.at", path = violation.instance_path))?;
+ }
+ }
+ Ok(())
+ }
+}
+
+impl std::error::Error for SchemaValidationError {}
+
+impl Diagnostic for SchemaValidationError {
+ fn help<'a>(&'a self) -> Option> {
+ self.help.as_ref().map(|help| Box::new(help) as Box)
+ }
+
+ fn source_code(&self) -> Option<&dyn SourceCode> {
+ self.source.as_ref().map(|source| source as &dyn SourceCode)
+ }
+
+ fn labels(&self) -> Option + '_>> {
+ if self.labels.is_empty() {
+ None
+ } else {
+ Some(Box::new(self.labels.iter().cloned()))
+ }
+ }
+}
+
+/// The labels that show a violation in the source text.
+///
+/// # Arguments
+///
+/// * `violation` - The violation.
+/// * `map` - The source map of the text.
+/// * `base` - The pointer the violation's instance path is relative to.
+fn labels_for(violation: &SchemaViolation, map: &SourceMap, base: &str) -> Vec {
+ let pointer = format!("{base}{}", violation.instance_path);
+ match &violation.detail {
+ SchemaViolationDetail::UnexpectedProperties(unexpected) => {
+ unexpected.iter().filter_map(|property| {
+ let member = format!("{pointer}/{}", escape(property));
+ let span = map.key_span(&member).or_else(|| map.span(&member))?;
+ Some(label(map, span, t!("diagnostics.schema.unexpectedProperty", property = property).to_string()))
+ }).collect()
+ },
+ SchemaViolationDetail::MissingProperty(property) => {
+ let Some((_, span)) = map.nearest_span(&pointer) else {
+ return Vec::new();
+ };
+ vec![label(map, span, t!("diagnostics.schema.missingProperty", property = property).to_string())]
+ },
+ SchemaViolationDetail::None => {
+ if let Some(span) = map.value_span(&pointer) {
+ return vec![label(map, span, violation.message.clone())];
+ }
+ // The text doesn't spell the value out, so point at the closest enclosing value.
+ let Some((_, span)) = map.nearest_span(&pointer) else {
+ return Vec::new();
+ };
+ let text = if violation.instance_path.is_empty() {
+ violation.message.clone()
+ } else {
+ format!("{} {}", violation.message, t!("diagnostics.schema.at", path = violation.instance_path))
+ };
+ vec![label(map, span, text)]
+ },
+ }
+}
+
+/// A label over the first line of a span.
+fn label(map: &SourceMap, span: std::ops::Range, text: String) -> LabeledSpan {
+ let first_line_end = map.text()[span.clone()].find('\n').map_or(span.end, |offset| span.start + offset);
+ let span = span.start..first_line_end.max(span.start);
+ LabeledSpan::new(Some(text), span.start, span.len())
+}
+
+/// Escape a member name as a JSON pointer reference token.
+fn escape(key: &str) -> String {
+ key.replace('~', "~0").replace('/', "~1")
+}
+
+/// The property names the schema declares at the object schema a keyword belongs to.
+///
+/// # Arguments
+///
+/// * `schema` - The root schema.
+/// * `schema_path` - The pointer to the keyword that failed, like `/additionalProperties`.
+fn known_properties(schema: &Value, schema_path: &str) -> Option> {
+ let parent = &schema_path[..schema_path.rfind('/')?];
+ let properties = schema.pointer(&format!("{parent}/properties"))?.as_object()?;
+ Some(properties.keys().cloned().collect())
+}
+
+/// The known property that most resembles an unknown one, if any does.
+///
+/// # Arguments
+///
+/// * `property` - The unknown property.
+/// * `known` - The known property names.
+fn closest<'a>(property: &str, known: &'a [String]) -> Option<&'a str> {
+ if let Some(same) = known.iter().find(|candidate| candidate.eq_ignore_ascii_case(property)) {
+ return Some(same);
+ }
+ known.iter()
+ .map(|candidate| (edit_distance(&property.to_lowercase(), &candidate.to_lowercase()), candidate))
+ .filter(|(distance, _)| *distance <= MAX_SUGGESTION_DISTANCE)
+ .min_by_key(|(distance, _)| *distance)
+ .map(|(_, candidate)| candidate.as_str())
+}
+
+/// The Levenshtein distance between two strings.
+fn edit_distance(a: &str, b: &str) -> usize {
+ let a: Vec = a.chars().collect();
+ let b: Vec = b.chars().collect();
+ let mut previous: Vec = (0..=b.len()).collect();
+ let mut current = vec![0; b.len() + 1];
+ for (i, &ca) in a.iter().enumerate() {
+ current[0] = i + 1;
+ for (j, &cb) in b.iter().enumerate() {
+ let cost = usize::from(ca != cb);
+ current[j + 1] = (previous[j + 1] + 1).min(current[j] + 1).min(previous[j] + cost);
+ }
+ std::mem::swap(&mut previous, &mut current);
+ }
+ previous[b.len()]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::dscresources::dscresource::validate_json_all;
+ use serde_json::json;
+
+ fn echo_schema() -> Value {
+ json!({
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["output"],
+ "properties": {
+ "output": { "type": ["string", "object"] },
+ "verbose": { "type": "boolean" }
+ }
+ })
+ }
+
+ fn error_for(text: &str) -> SchemaValidationError {
+ let schema = echo_schema();
+ let value: Value = serde_yaml::from_str(text).unwrap();
+ let violations = validate_json_all("test", &schema, &value).unwrap();
+ SchemaValidationError::new("input for 'Test/Echo'", violations)
+ .with_schema(&schema)
+ .with_source(text, "input", "")
+ }
+
+ #[test]
+ fn unknown_property_is_labeled_at_its_key_with_a_suggestion() {
+ let text = "{\n \"otput\": \"hi\",\n \"output\": \"x\"\n}";
+ let error = error_for(text);
+ assert!(error.has_source());
+ let labels: Vec = error.labels().unwrap().collect();
+ assert_eq!(labels.len(), 1);
+ assert_eq!(&text[labels[0].offset()..labels[0].offset() + labels[0].len()], "\"otput\"");
+ assert_eq!(labels[0].label().unwrap(), "'otput' isn't a known property");
+ assert_eq!(error.help().unwrap().to_string(), "Did you mean 'output' instead of 'otput'?");
+ assert_eq!(error.to_string(), "Schema: input for 'Test/Echo': Additional properties are not allowed ('otput' was unexpected)");
+ }
+
+ #[test]
+ fn missing_property_is_labeled_at_the_enclosing_object() {
+ let text = "verbose: true\n";
+ let error = error_for(text);
+ let labels: Vec = error.labels().unwrap().collect();
+ assert_eq!(labels[0].label().unwrap(), "missing required property 'output'");
+ assert_eq!(labels[0].offset(), 0);
+ assert!(error.help().is_none());
+ }
+
+ #[test]
+ fn wrong_type_is_labeled_at_the_value_and_named_in_the_message() {
+ let text = "output: hi\nverbose: nope\n";
+ let error = error_for(text);
+ let labels: Vec = error.labels().unwrap().collect();
+ assert_eq!(&text[labels[0].offset()..labels[0].offset() + labels[0].len()], "nope");
+ assert!(error.to_string().ends_with("is not of type \"boolean\" at '/verbose'"));
+ }
+
+ #[test]
+ fn base_pointer_anchors_violations_inside_a_document() {
+ let document = "resources:\n- name: a\n type: Test/Echo\n properties:\n otput: hi\n";
+ let schema = echo_schema();
+ let properties = json!({"otput": "hi"});
+ let violations = validate_json_all("test", &schema, &properties).unwrap();
+ let error = SchemaValidationError::new("input for 'Test/Echo'", violations)
+ .with_source(document, "demo.dsc.yaml", "/resources/0/properties");
+ let labels: Vec = error.labels().unwrap().collect();
+ let unknown = labels.iter().find(|label| label.label() == Some("'otput' isn't a known property")).unwrap();
+ assert_eq!(&document[unknown.offset()..unknown.offset() + unknown.len()], "otput");
+ }
+
+ #[test]
+ fn redaction_drops_source_and_messages() {
+ let error = error_for("output: hi\nverbose: secret\n").redacted("redacted");
+ assert!(!error.has_source());
+ assert!(error.labels().is_none());
+ assert_eq!(error.to_string(), "Schema: input for 'Test/Echo': redacted at '/verbose'");
+ }
+
+ #[test]
+ fn lists_known_properties_without_a_close_match() {
+ let error = error_for("output: hi\ncompletelyDifferent: 1\n");
+ assert_eq!(error.help().unwrap().to_string(), "Known properties: output, verbose");
+ }
+
+ #[test]
+ fn edit_distance_counts_edits() {
+ assert_eq!(edit_distance("output", "otput"), 1);
+ assert_eq!(edit_distance("keyPath", "keypath"), 1);
+ assert_eq!(edit_distance("", "abc"), 3);
+ assert_eq!(edit_distance("same", "same"), 0);
+ }
+}
diff --git a/lib/dsc-lib/src/diagnostics/source_map.rs b/lib/dsc-lib/src/diagnostics/source_map.rs
new file mode 100644
index 000000000..7b1c6baca
--- /dev/null
+++ b/lib/dsc-lib/src/diagnostics/source_map.rs
@@ -0,0 +1,537 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Locate JSON pointers in the text of a JSON or YAML document.
+
+use std::{collections::HashMap, ops::Range};
+
+/// The spans of one location in a document.
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct Entry {
+ /// The span of the member name, when the value is a member of an object.
+ key: Option>,
+ /// The span of the value.
+ value: Range,
+}
+
+/// The text of a document together with the byte span of every location in it, indexed by JSON
+/// pointer (RFC 6901).
+///
+/// A source map lets a diagnostic point at the exact place in a document that a pointer
+/// identifies, both as a byte range for rendering a snippet and as a line and column.
+#[derive(Clone, Debug)]
+pub struct SourceMap {
+ text: String,
+ entries: HashMap,
+ line_starts: Vec,
+}
+
+impl SourceMap {
+ /// Build a source map from document text.
+ ///
+ /// Text that is valid JSON is mapped with the JSON grammar; anything else is mapped as YAML.
+ ///
+ /// # Arguments
+ ///
+ /// * `text` - The document text.
+ ///
+ /// # Returns
+ ///
+ /// The source map, or `None` when the text is neither valid JSON nor valid YAML.
+ #[must_use]
+ pub fn parse(text: &str) -> Option {
+ let entries = if serde_json::from_str::(text).is_ok() {
+ json::scan(text)?
+ } else {
+ yaml::scan(text)?
+ };
+ Some(Self {
+ text: text.to_string(),
+ entries,
+ line_starts: line_starts(text),
+ })
+ }
+
+ /// The document text.
+ #[must_use]
+ pub fn text(&self) -> &str {
+ &self.text
+ }
+
+ /// Whether the document has a value at the pointer.
+ ///
+ /// # Arguments
+ ///
+ /// * `pointer` - The JSON pointer; the empty pointer is the document root.
+ #[must_use]
+ pub fn contains(&self, pointer: &str) -> bool {
+ self.entries.contains_key(pointer)
+ }
+
+ /// The span of the value at the pointer.
+ ///
+ /// # Arguments
+ ///
+ /// * `pointer` - The JSON pointer; the empty pointer is the document root.
+ #[must_use]
+ pub fn value_span(&self, pointer: &str) -> Option> {
+ self.entries.get(pointer).map(|entry| entry.value.clone())
+ }
+
+ /// The span of the member name of the value at the pointer, when it is a member of an object.
+ ///
+ /// # Arguments
+ ///
+ /// * `pointer` - The JSON pointer.
+ #[must_use]
+ pub fn key_span(&self, pointer: &str) -> Option> {
+ self.entries.get(pointer).and_then(|entry| entry.key.clone())
+ }
+
+ /// The span that best identifies the pointer: the member name when the value is a member of
+ /// an object, otherwise the value.
+ ///
+ /// # Arguments
+ ///
+ /// * `pointer` - The JSON pointer.
+ #[must_use]
+ pub fn span(&self, pointer: &str) -> Option> {
+ self.entries.get(pointer).map(|entry| entry.key.clone().unwrap_or_else(|| entry.value.clone()))
+ }
+
+ /// The span of the nearest location that exists in the document, walking up from the pointer
+ /// to its ancestors.
+ ///
+ /// Useful for a pointer into a value the document doesn't spell out, like a property that is
+ /// missing or a character offset inside a string.
+ ///
+ /// # Arguments
+ ///
+ /// * `pointer` - The JSON pointer.
+ ///
+ /// # Returns
+ ///
+ /// The pointer that was found and its span.
+ #[must_use]
+ pub fn nearest_span(&self, pointer: &str) -> Option<(String, Range)> {
+ let mut current = pointer;
+ loop {
+ if let Some(span) = self.span(current) {
+ return Some((current.to_string(), span));
+ }
+ let parent_end = current.rfind('/')?;
+ current = ¤t[..parent_end];
+ }
+ }
+
+ /// The line and column of a byte offset, both starting at 1. Columns count characters.
+ ///
+ /// # Arguments
+ ///
+ /// * `offset` - The byte offset into the text.
+ #[must_use]
+ pub fn location(&self, offset: usize) -> (usize, usize) {
+ let offset = offset.min(self.text.len());
+ let line_index = match self.line_starts.binary_search(&offset) {
+ Ok(index) => index,
+ Err(index) => index.saturating_sub(1),
+ };
+ let line_start = self.line_starts[line_index];
+ let column = self.text[line_start..offset].chars().count() + 1;
+ (line_index + 1, column)
+ }
+}
+
+/// The byte offset where each line starts.
+fn line_starts(text: &str) -> Vec {
+ let mut starts = vec![0];
+ for (index, byte) in text.bytes().enumerate() {
+ if byte == b'\n' {
+ starts.push(index + 1);
+ }
+ }
+ starts
+}
+
+/// Escape a member name as a JSON pointer reference token.
+fn escape(key: &str) -> String {
+ key.replace('~', "~0").replace('/', "~1")
+}
+
+mod json {
+ use super::{Entry, escape};
+ use std::{collections::HashMap, ops::Range};
+
+ /// Record the span of every value in valid JSON text.
+ pub(super) fn scan(text: &str) -> Option> {
+ let mut scanner = Scanner { text, bytes: text.as_bytes(), pos: 0, entries: HashMap::new() };
+ scanner.value("", None)?;
+ Some(scanner.entries)
+ }
+
+ struct Scanner<'a> {
+ text: &'a str,
+ bytes: &'a [u8],
+ pos: usize,
+ entries: HashMap,
+ }
+
+ impl Scanner<'_> {
+ fn skip_whitespace(&mut self) {
+ while self.pos < self.bytes.len() && matches!(self.bytes[self.pos], b' ' | b'\t' | b'\n' | b'\r') {
+ self.pos += 1;
+ }
+ }
+
+ fn expect(&mut self, byte: u8) -> Option<()> {
+ self.skip_whitespace();
+ if self.bytes.get(self.pos) == Some(&byte) {
+ self.pos += 1;
+ Some(())
+ } else {
+ None
+ }
+ }
+
+ fn value(&mut self, pointer: &str, key: Option>) -> Option> {
+ self.skip_whitespace();
+ let start = self.pos;
+ let span = match self.bytes.get(self.pos)? {
+ b'{' => self.object(pointer)?,
+ b'[' => self.array(pointer)?,
+ b'"' => self.string()?,
+ _ => {
+ while self.pos < self.bytes.len() && !matches!(self.bytes[self.pos], b',' | b'}' | b']' | b' ' | b'\t' | b'\n' | b'\r') {
+ self.pos += 1;
+ }
+ start..self.pos
+ },
+ };
+ self.entries.insert(pointer.to_string(), Entry { key, value: span.clone() });
+ Some(span)
+ }
+
+ fn object(&mut self, pointer: &str) -> Option> {
+ let start = self.pos;
+ self.pos += 1;
+ loop {
+ self.skip_whitespace();
+ match self.bytes.get(self.pos)? {
+ b'}' => {
+ self.pos += 1;
+ return Some(start..self.pos);
+ },
+ b',' => {
+ self.pos += 1;
+ },
+ b'"' => {
+ let key_span = self.string()?;
+ let key: String = serde_json::from_str(&self.text[key_span.clone()]).ok()?;
+ self.expect(b':')?;
+ self.value(&format!("{pointer}/{}", escape(&key)), Some(key_span))?;
+ },
+ _ => return None,
+ }
+ }
+ }
+
+ fn array(&mut self, pointer: &str) -> Option> {
+ let start = self.pos;
+ self.pos += 1;
+ let mut index = 0;
+ loop {
+ self.skip_whitespace();
+ match self.bytes.get(self.pos)? {
+ b']' => {
+ self.pos += 1;
+ return Some(start..self.pos);
+ },
+ b',' => {
+ self.pos += 1;
+ },
+ _ => {
+ self.value(&format!("{pointer}/{index}"), None)?;
+ index += 1;
+ },
+ }
+ }
+ }
+
+ fn string(&mut self) -> Option> {
+ let start = self.pos;
+ self.pos += 1;
+ while let Some(byte) = self.bytes.get(self.pos) {
+ match byte {
+ b'\\' => self.pos += 2,
+ b'"' => {
+ self.pos += 1;
+ return Some(start..self.pos);
+ },
+ _ => self.pos += 1,
+ }
+ }
+ None
+ }
+ }
+}
+
+mod yaml {
+ use super::{Entry, escape, line_starts};
+ use saphyr_parser::{Event, Marker, Parser, Span};
+ use std::{collections::HashMap, ops::Range};
+
+ /// A container whose members are being read.
+ enum Frame {
+ Mapping {
+ pointer: String,
+ key: Option>,
+ /// The member name the next value belongs to: `None` while expecting a key, and
+ /// `Some(None)` for a key that isn't a scalar and so can't be addressed by a pointer.
+ pending: Option)>>,
+ start: usize,
+ end: usize,
+ },
+ Sequence {
+ pointer: String,
+ key: Option>,
+ index: usize,
+ start: usize,
+ end: usize,
+ },
+ }
+
+ /// Record the span of every node in the first document of YAML text.
+ pub(super) fn scan(text: &str) -> Option> {
+ let offsets = Offsets::new(text);
+ let mut entries = HashMap::new();
+ let mut frames: Vec = Vec::new();
+ // Nesting depth inside a key that is a collection, whose events are skipped.
+ let mut skipping = 0usize;
+ let mut root_done = false;
+
+ for item in Parser::new_from_str(text) {
+ let (event, span) = item.ok()?;
+ let range = offsets.range(&span);
+ match event {
+ Event::StreamStart | Event::StreamEnd | Event::Nothing => {},
+ Event::DocumentStart(_) => {
+ if root_done {
+ break;
+ }
+ },
+ Event::DocumentEnd => root_done = true,
+ Event::Scalar(..) | Event::Alias(..) => {
+ if skipping > 0 {
+ continue;
+ }
+ let key_text = match &event {
+ Event::Scalar(value, ..) => Some(value.to_string()),
+ _ => None,
+ };
+ match frames.last_mut() {
+ Some(Frame::Mapping { pending: pending @ None, .. }) => {
+ *pending = Some(key_text.map(|value| (value, range)));
+ },
+ Some(frame) => {
+ let (pointer, key) = child(frame, range.end);
+ if let Some(pointer) = pointer {
+ entries.insert(pointer, Entry { key, value: range });
+ }
+ },
+ None => {
+ entries.insert(String::new(), Entry { key: None, value: range });
+ },
+ }
+ },
+ Event::SequenceStart(..) | Event::MappingStart(..) => {
+ if skipping > 0 {
+ skipping += 1;
+ continue;
+ }
+ if let Some(Frame::Mapping { pending: pending @ None, .. }) = frames.last_mut() {
+ *pending = Some(None);
+ skipping = 1;
+ continue;
+ }
+ let (pointer, key) = match frames.last_mut() {
+ Some(frame) => child(frame, range.end),
+ None => (Some(String::new()), None),
+ };
+ let Some(pointer) = pointer else {
+ skipping = 1;
+ continue;
+ };
+ frames.push(if matches!(event, Event::SequenceStart(..)) {
+ Frame::Sequence { pointer, key, index: 0, start: range.start, end: range.end }
+ } else {
+ Frame::Mapping { pointer, key, pending: None, start: range.start, end: range.end }
+ });
+ },
+ Event::SequenceEnd | Event::MappingEnd => {
+ if skipping > 0 {
+ skipping -= 1;
+ continue;
+ }
+ let frame = frames.pop()?;
+ let (pointer, key, start, end) = match frame {
+ Frame::Mapping { pointer, key, start, end, .. } | Frame::Sequence { pointer, key, start, end, .. } => (pointer, key, start, end),
+ };
+ // A flow collection's closing bracket has a span of its own; a block
+ // collection ends with its last member.
+ let end = if range.end > range.start { range.end.max(end) } else { end.max(start) };
+ if let Some(parent) = frames.last_mut() {
+ extend(parent, end);
+ }
+ entries.insert(pointer, Entry { key, value: start..end });
+ },
+ }
+ }
+ Some(entries)
+ }
+
+ /// Resolve the pointer and member-name span of the next value in a container, and advance
+ /// the container past it.
+ fn child(frame: &mut Frame, end: usize) -> (Option, Option>) {
+ match frame {
+ Frame::Mapping { pointer, pending, end: frame_end, .. } => {
+ *frame_end = (*frame_end).max(end);
+ match pending.take().flatten() {
+ Some((key, span)) => (Some(format!("{pointer}/{}", escape(&key))), Some(span)),
+ None => (None, None),
+ }
+ },
+ Frame::Sequence { pointer, index, end: frame_end, .. } => {
+ *frame_end = (*frame_end).max(end);
+ let child = format!("{pointer}/{index}");
+ *index += 1;
+ (Some(child), None)
+ },
+ }
+ }
+
+ fn extend(frame: &mut Frame, end: usize) {
+ match frame {
+ Frame::Mapping { end: frame_end, .. } | Frame::Sequence { end: frame_end, .. } => {
+ *frame_end = (*frame_end).max(end);
+ },
+ }
+ }
+
+ /// Converts the parser's line and column markers into byte offsets.
+ struct Offsets<'a> {
+ text: &'a str,
+ line_starts: Vec,
+ }
+
+ impl<'a> Offsets<'a> {
+ fn new(text: &'a str) -> Self {
+ Self { text, line_starts: line_starts(text) }
+ }
+
+ fn range(&self, span: &Span) -> Range {
+ let start = self.offset(&span.start);
+ let end = self.offset(&span.end).max(start);
+ start..end
+ }
+
+ /// The byte offset of a marker, whose line starts at 1 and whose column starts at 0 and
+ /// counts characters.
+ fn offset(&self, marker: &Marker) -> usize {
+ let Some(&line_start) = self.line_starts.get(marker.line().saturating_sub(1)) else {
+ return self.text.len();
+ };
+ let line = &self.text[line_start..];
+ let line = &line[..line.find('\n').unwrap_or(line.len())];
+ let column_offset = line.char_indices().nth(marker.col()).map_or(line.len(), |(offset, _)| offset);
+ line_start + column_offset
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn text_at(map: &SourceMap, span: Range) -> &str {
+ &map.text()[span]
+ }
+
+ #[test]
+ fn json_locates_keys_values_and_nested_items() {
+ let map = SourceMap::parse(r#"{
+ "a": 1,
+ "b": [true, {"c~/d": "x"}],
+ "e": {"f": null}
+}"#).unwrap();
+ assert_eq!(text_at(&map, map.key_span("/a").unwrap()), "\"a\"");
+ assert_eq!(text_at(&map, map.value_span("/a").unwrap()), "1");
+ assert_eq!(text_at(&map, map.value_span("/b/0").unwrap()), "true");
+ assert_eq!(text_at(&map, map.key_span("/b/1/c~0~1d").unwrap()), "\"c~/d\"");
+ assert_eq!(text_at(&map, map.value_span("/b/1/c~0~1d").unwrap()), "\"x\"");
+ assert_eq!(text_at(&map, map.value_span("/e").unwrap()), "{\"f\": null}");
+ assert_eq!(text_at(&map, map.value_span("/b").unwrap()), "[true, {\"c~/d\": \"x\"}]");
+ assert_eq!(map.value_span("").unwrap().start, 0);
+ assert!(map.value_span("/missing").is_none());
+ assert_eq!(map.location(map.key_span("/b").unwrap().start), (3, 3));
+ }
+
+ #[test]
+ fn json_handles_escaped_strings() {
+ let map = SourceMap::parse(r#"{"k\"ey": "va\\lue", "n": -1.5e3}"#).unwrap();
+ assert_eq!(text_at(&map, map.value_span("/k\"ey").unwrap()), r#""va\\lue""#);
+ assert_eq!(text_at(&map, map.value_span("/n").unwrap()), "-1.5e3");
+ }
+
+ #[test]
+ fn yaml_locates_block_mappings_and_sequences() {
+ let text = "$schema: https://example\nresources:\n- name: first\n type: Test/Echo\n properties:\n output: hi\n- name: second\n type: Test/Echo\n";
+ let map = SourceMap::parse(text).unwrap();
+ assert_eq!(text_at(&map, map.key_span("/resources/0/properties/output").unwrap()), "output");
+ assert_eq!(text_at(&map, map.value_span("/resources/0/properties/output").unwrap()), "hi");
+ assert_eq!(text_at(&map, map.value_span("/resources/1/name").unwrap()), "second");
+ assert_eq!(text_at(&map, map.key_span("/resources").unwrap()), "resources");
+ let item = map.value_span("/resources/0").unwrap();
+ assert!(text_at(&map, item.clone()).starts_with("name: first"));
+ assert!(text_at(&map, item).ends_with("output: hi"));
+ assert_eq!(map.location(map.key_span("/resources/1/type").unwrap().start), (8, 3));
+ }
+
+ #[test]
+ fn yaml_locates_flow_collections_and_quoted_scalars() {
+ let text = "a: {b: [1, 2], 'c d': \"q\"}\n";
+ let map = SourceMap::parse(text).unwrap();
+ assert_eq!(text_at(&map, map.value_span("/a/b/1").unwrap()), "2");
+ assert_eq!(text_at(&map, map.key_span("/a/c d").unwrap()), "'c d'");
+ assert_eq!(text_at(&map, map.value_span("/a/c d").unwrap()), "\"q\"");
+ assert_eq!(text_at(&map, map.value_span("/a").unwrap()), "{b: [1, 2], 'c d': \"q\"}");
+ }
+
+ #[test]
+ fn yaml_counts_columns_in_characters() {
+ let text = "héllo: wörld\nnext: 1\n";
+ let map = SourceMap::parse(text).unwrap();
+ assert_eq!(text_at(&map, map.value_span("/héllo").unwrap()), "wörld");
+ assert_eq!(text_at(&map, map.value_span("/next").unwrap()), "1");
+ }
+
+ #[test]
+ fn yaml_skips_complex_keys() {
+ let text = "? [a, b]\n: value\nplain: 1\n";
+ let map = SourceMap::parse(text).unwrap();
+ assert_eq!(text_at(&map, map.value_span("/plain").unwrap()), "1");
+ }
+
+ #[test]
+ fn nearest_span_walks_up_to_an_ancestor() {
+ let map = SourceMap::parse("resources:\n- name: a\n").unwrap();
+ let (pointer, span) = map.nearest_span("/resources/0/properties/output").unwrap();
+ assert_eq!(pointer, "/resources/0");
+ assert_eq!(text_at(&map, span), "name: a");
+ assert!(map.nearest_span("/resources/5").is_some());
+ }
+
+ #[test]
+ fn invalid_text_has_no_map() {
+ assert!(SourceMap::parse("a: [b").is_none());
+ }
+}
diff --git a/lib/dsc-lib/src/dscerror.rs b/lib/dsc-lib/src/dscerror.rs
index 9a430b8ff..57dc04f05 100644
--- a/lib/dsc-lib/src/dscerror.rs
+++ b/lib/dsc-lib/src/dscerror.rs
@@ -152,6 +152,10 @@ pub enum DscError {
#[error("{t}: {0}", t = t!("dscerror.schema"))]
Schema(String),
+ #[error(transparent)]
+ #[diagnostic(transparent)]
+ SchemaValidation(Box),
+
#[error("{t}: {0}", t = t!("dscerror.schemaNotAvailable"))]
SchemaNotAvailable(String),
diff --git a/lib/dsc-lib/src/dscresources/command_resource.rs b/lib/dsc-lib/src/dscresources/command_resource.rs
index c024ee6ae..29f86affe 100644
--- a/lib/dsc-lib/src/dscresources/command_resource.rs
+++ b/lib/dsc-lib/src/dscresources/command_resource.rs
@@ -3,16 +3,15 @@
use clap::ValueEnum;
use dsc_lib_security_context::{SecurityContext, get_security_context};
-use jsonschema::Validator;
use rust_i18n::t;
use serde::Deserialize;
use serde_json::{Map, Value};
use std::{collections::HashMap, env, path::Path, process::Stdio};
-use crate::{configure::{config_doc::{ExecutionKind, SecurityContextKind}, config_result::{ResourceGetResult, ResourceTestResult}, schema_cache::{RESOURCE_SCHEMAS, get_resource_schema}}, dscresources::{dscresource::Operation, resource_manifest::{ExportSchemaKind, ExportSchemaOrFiltering, SchemaArgKind}}, types::ExitCodesMap, util::canonicalize_which};
+use crate::{configure::{config_doc::{ExecutionKind, SecurityContextKind}, config_result::{ResourceGetResult, ResourceTestResult}, schema_cache::{RESOURCE_SCHEMAS, get_resource_schema}}, diagnostics::SchemaValidationError, dscresources::{dscresource::Operation, resource_manifest::{ExportSchemaKind, ExportSchemaOrFiltering, SchemaArgKind}}, types::ExitCodesMap, util::canonicalize_which};
use crate::dscerror::DscError;
use crate::locked_insert;
use super::{
- dscresource::{get_diff, get_diff_with_schema, redact, DscResource},
+ dscresource::{get_diff, get_diff_with_schema, redact, validate_json_all, DscResource},
invoke_result::{
DeleteResult, DeleteResultKind, ExportResult,
GetResult, ResolveResult, SetResult, TestResult, ValidateResult,
@@ -54,7 +53,7 @@ pub fn invoke_get(resource: &DscResource, filter: &str, target_resource: Option<
validate_security_context(target_resource, &get.require_security_context, &command_resource.type_name, &Operation::Get)?;
let args = process_get_args(get.args.as_ref(), filter, command_resource);
if !filter.is_empty() {
- verify_json_from_manifest(resource, filter, target_resource)?;
+ verify_json_from_manifest(resource, filter, target_resource, JsonOrigin::Input)?;
command_input = get_command_input(get.input.as_ref(), filter)?;
}
@@ -62,7 +61,7 @@ pub fn invoke_get(resource: &DscResource, filter: &str, target_resource: Option<
let (_exit_code, stdout, stderr) = invoke_command(&get.executable, args, command_input.stdin.as_deref(), Some(&resource.directory), command_input.env, manifest.exit_codes.as_ref())?;
if resource.kind == Kind::Resource {
debug!("{}", t!("dscresources.commandResource.verifyOutputUsing", resource = &resource.type_name, executable = &get.executable));
- verify_json_from_manifest(resource, &stdout, target_resource)?;
+ verify_json_from_manifest(resource, &stdout, target_resource, JsonOrigin::Output)?;
}
let result: GetResult = if let Ok(group_response) = serde_json::from_str::>(&stdout) {
@@ -141,7 +140,7 @@ pub fn invoke_set(resource: &DscResource, desired: &str, skip_test: bool, execut
return Err(DscError::NotImplemented("set".to_string()));
};
validate_security_context(target_resource, &set.require_security_context, &command_resource.type_name, &Operation::Set)?;
- verify_json_from_manifest(resource, desired, target_resource)?;
+ verify_json_from_manifest(resource, desired, target_resource, JsonOrigin::Input)?;
// if resource doesn't implement a pre-test, we execute test first to see if a set is needed
if !skip_test && set.pre_test != Some(true) {
@@ -194,7 +193,7 @@ pub fn invoke_set(resource: &DscResource, desired: &str, skip_test: bool, execut
if resource.kind == Kind::Resource {
debug!("{}", t!("dscresources.commandResource.setVerifyGet", resource = &resource.type_name, executable = &get.executable));
- verify_json_from_manifest(resource, &stdout, target_resource)?;
+ verify_json_from_manifest(resource, &stdout, target_resource, JsonOrigin::Output)?;
}
let pre_state_value: Value = if exit_code == 0 {
@@ -244,7 +243,7 @@ pub fn invoke_set(resource: &DscResource, desired: &str, skip_test: bool, execut
if resource.kind == Kind::Resource {
debug!("{}", t!("dscresources.commandResource.setVerifyOutput", operation = operation_type, resource = &command_resource.type_name, executable = &set.executable));
- verify_json_from_manifest(resource, &stdout, target_resource)?;
+ verify_json_from_manifest(resource, &stdout, target_resource, JsonOrigin::Output)?;
}
let actual_value: Value = match serde_json::from_str(&stdout){
@@ -272,7 +271,7 @@ pub fn invoke_set(resource: &DscResource, desired: &str, skip_test: bool, execut
if resource.kind == Kind::Resource {
debug!("{}", t!("dscresources.commandResource.setVerifyOutput", operation = operation_type, resource = &command_resource.type_name, executable = &set.executable));
- verify_json_from_manifest(resource, actual_line, target_resource)?;
+ verify_json_from_manifest(resource, actual_line, target_resource, JsonOrigin::Output)?;
}
let actual_value: Value = serde_json::from_str(actual_line)?;
@@ -335,7 +334,7 @@ pub fn invoke_test(resource: &DscResource, expected: &str, target_resource: Opti
return invoke_synthetic_test(resource, expected, target_resource);
};
- verify_json_from_manifest(resource, expected, target_resource)?;
+ verify_json_from_manifest(resource, expected, target_resource, JsonOrigin::Input)?;
let command_resource = match target_resource {
Some(r) => r,
@@ -359,7 +358,7 @@ pub fn invoke_test(resource: &DscResource, expected: &str, target_resource: Opti
Some(ReturnKind::State) => {
if command_resource.kind == Kind::Resource {
debug!("{}", t!("dscresources.commandResource.testVerifyOutput", resource = &command_resource.type_name, executable = &test.executable));
- verify_json_from_manifest(resource, &stdout, target_resource)?;
+ verify_json_from_manifest(resource, &stdout, target_resource, JsonOrigin::Output)?;
}
let actual_value: Value = match serde_json::from_str(&stdout){
@@ -387,7 +386,7 @@ pub fn invoke_test(resource: &DscResource, expected: &str, target_resource: Opti
if resource.kind == Kind::Resource {
debug!("{}", t!("dscresources.commandResource.testVerifyOutput", resource = &resource.type_name, executable = &test.executable));
- verify_json_from_manifest(resource, actual_value, target_resource)?;
+ verify_json_from_manifest(resource, actual_value, target_resource, JsonOrigin::Output)?;
}
let actual_value: Value = serde_json::from_str(actual_value)?;
@@ -505,7 +504,7 @@ pub fn invoke_delete(resource: &DscResource, filter: &str, target_resource: Opti
return Err(DscError::NotImplemented("delete".to_string()));
};
- verify_json_from_manifest(resource, filter, target_resource)?;
+ verify_json_from_manifest(resource, filter, target_resource, JsonOrigin::Input)?;
let command_resource = match target_resource {
Some(r) => r,
@@ -666,17 +665,8 @@ fn verify_with_export_schema(input: &str, resource: &DscResource, target_resourc
}
};
let schema = serde_json::from_str(&schema)?;
- let compiled_schema = match Validator::new(&schema) {
- Ok(schema) => schema,
- Err(e) => {
- return Err(DscError::Schema(e.to_string()));
- },
- };
let json: Value = serde_json::from_str(input)?;
- if let Err(err) = compiled_schema.validate(&json) {
- return Err(DscError::Schema(err.to_string()));
- }
- Ok(())
+ validate_against_schema(resource, &schema, &json, JsonOrigin::Input)
}
/// Invoke the export operation on a resource
@@ -759,7 +749,7 @@ pub fn invoke_export(resource: &DscResource, input: Option<&str>, target_resourc
};
if resource.kind == Kind::Resource {
debug!("{}", t!("dscresources.commandResource.exportVerifyOutput", resource = &resource.type_name, executable = &export.executable));
- verify_json_from_manifest(resource, line, target_resource)?;
+ verify_json_from_manifest(resource, line, target_resource, JsonOrigin::Output)?;
}
instances.push(instance);
}
@@ -1180,7 +1170,16 @@ fn get_command_input(input_kind: Option<&InputKind>, input: &str) -> Result) -> Result<(), DscError> {
+/// Where the JSON that is validated against a resource's schema came from.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum JsonOrigin {
+ /// The desired state or filter a caller passed to the resource.
+ Input,
+ /// The state the resource returned.
+ Output,
+}
+
+fn verify_json_from_manifest(resource: &DscResource, json: &str, target_resource: Option<&DscResource>, origin: JsonOrigin) -> Result<(), DscError> {
trace!("{}", t!("dscresources.commandResource.verifyJson", resource = resource.type_name, json = json));
let Some(manifest) = &resource.manifest else {
return Err(DscError::MissingManifest(resource.type_name.to_string()));
@@ -1205,23 +1204,50 @@ fn verify_json_from_manifest(resource: &DscResource, json: &str, target_resource
// otherwise, use schema validation
let schema = get_schema(resource, target_resource)?;
let schema: Value = serde_json::from_str(&schema)?;
- let compiled_schema = match Validator::new(&schema) {
- Ok(schema) => schema,
- Err(e) => {
- return Err(DscError::Schema(e.to_string()));
- },
- };
let json: Value = serde_json::from_str(json)?;
- if let Err(err) = compiled_schema.validate(&json) {
- let mut error_message = err.to_string();
- // check if message contains a "secureString" or "secureObject" and redact it
- if error_message.contains("\"secureString\"") || error_message.contains("\"secureObject\"") {
- error_message = t!("dscresources.commandResource.redactedSchemaErrorMessage").to_string();
- }
- return Err(DscError::Schema(error_message));
+ validate_against_schema(resource, &schema, &json, origin)
+}
+
+/// Validate JSON against a resource's schema, reporting every violation where it occurs.
+///
+/// # Arguments
+///
+/// * `resource` - The resource whose schema it is
+/// * `schema` - The schema
+/// * `json` - The JSON to validate
+/// * `origin` - Where the JSON came from, which the report names
+///
+/// # Errors
+///
+/// * `DscError::Validation` - The schema failed to compile
+/// * `DscError::SchemaValidation` - The JSON violates the schema
+fn validate_against_schema(resource: &DscResource, schema: &Value, json: &Value, origin: JsonOrigin) -> Result<(), DscError> {
+ let violations = validate_json_all(resource.type_name.as_ref(), schema, json)?;
+ if violations.is_empty() {
+ return Ok(());
}
- Ok(())
+ let (subject, name) = match origin {
+ JsonOrigin::Input => (
+ t!("dscresources.commandResource.schemaSubjectInput", resource = resource.type_name).to_string(),
+ t!("dscresources.commandResource.schemaSourceInput").to_string(),
+ ),
+ JsonOrigin::Output => (
+ t!("dscresources.commandResource.schemaSubjectOutput", resource = resource.type_name).to_string(),
+ t!("dscresources.commandResource.schemaSourceOutput").to_string(),
+ ),
+ };
+ let mut error = SchemaValidationError::new(subject, violations).with_schema(schema);
+ // a message that quotes a "secureString" or "secureObject" value would expose it, as would the text
+ let holds_secure_value = error.violations().iter().any(|violation| {
+ violation.message.contains("\"secureString\"") || violation.message.contains("\"secureObject\"")
+ });
+ if holds_secure_value {
+ error = error.redacted(&t!("dscresources.commandResource.redactedSchemaErrorMessage"));
+ } else if let Ok(text) = serde_json::to_string_pretty(json) {
+ error = error.with_source(&text, &name, "");
+ }
+ Err(DscError::SchemaValidation(Box::new(error)))
}
fn json_to_hashmap(json: &str) -> Result, DscError> {
diff --git a/lib/dsc-lib/src/dscresources/dscresource.rs b/lib/dsc-lib/src/dscresources/dscresource.rs
index 785e1a0f0..e3c3357fe 100644
--- a/lib/dsc-lib/src/dscresources/dscresource.rs
+++ b/lib/dsc-lib/src/dscresources/dscresource.rs
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-use crate::{configure::{Configurator, config_doc::{Configuration, ExecutionKind, Resource}, context::ProcessMode, parameters::{SECURE_VALUE_REDACTED, is_secure_value}, schema_cache::get_resource_schema}, dscresources::{adapted_resource_manifest::AdaptedDscResourceManifest, resource_manifest::{AdapterInputKind, Kind}}, types::{FullyQualifiedTypeName, ResourceVersion}};
+use crate::{configure::{Configurator, config_doc::{Configuration, ExecutionKind, Resource}, context::ProcessMode, parameters::{SECURE_VALUE_REDACTED, is_secure_value}, schema_cache::get_resource_schema}, diagnostics::SchemaValidationError, dscresources::{adapted_resource_manifest::AdaptedDscResourceManifest, resource_manifest::{AdapterInputKind, Kind}}, types::{FullyQualifiedTypeName, ResourceVersion}};
use crate::discovery::discovery_trait::DiscoveryFilter;
use crate::dscresources::invoke_result::{ResourceGetResponse, ResourceSetResponse};
use crate::schemas::transforms::idiomaticize_string_enum;
@@ -909,7 +909,7 @@ pub fn validate_properties(resource: &DscResource, properties: &Value) -> Result
///
/// # Arguments
///
-/// * `source` - The source of the JSON
+/// * `source` - The source of the JSON, as it should read in the error message
/// * `schema` - The schema to validate against
/// * `json` - The JSON to validate
///
@@ -919,23 +919,28 @@ pub fn validate_properties(resource: &DscResource, properties: &Value) -> Result
///
/// # Errors
///
-/// * `DscError` - The JSON is invalid
+/// * `DscError::Validation` - The schema failed to compile
+/// * `DscError::SchemaValidation` - The JSON is invalid; the error lists every violation
pub fn validate_json(source: &str, schema: &Value, json: &Value) -> Result<(), DscError> {
- debug!("{}: {source}", t!("dscresources.dscresource.validatingSchema"));
trace!("{}", t!("dscresources.dscresource.json", json = json));
- trace!("{}", t!("dscresources.dscresource.schema", schema = schema));
- let compiled_schema = match Validator::new(schema) {
- Ok(compiled_schema) => compiled_schema,
- Err(err) => {
- return Err(DscError::Validation(format!("{}: {err}", t!("dscresources.dscresource.failedToCompileSchema"))));
- }
- };
-
- if let Err(err) = compiled_schema.validate(json) {
- return Err(DscError::Validation(format!("{}: '{source}' {err}", t!("dscresources.dscresource.validationFailed"))));
+ let violations = validate_json_all(source, schema, json)?;
+ if violations.is_empty() {
+ return Ok(());
}
+ Err(DscError::SchemaValidation(Box::new(SchemaValidationError::new(source, violations).with_schema(schema))))
+}
- Ok(())
+/// Extra information for the keywords whose message alone doesn't locate the problem.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub enum SchemaViolationDetail {
+ /// The message describes the problem fully.
+ #[default]
+ None,
+ /// For `additionalProperties` and `unevaluatedProperties`, the property names that aren't
+ /// allowed.
+ UnexpectedProperties(Vec),
+ /// For `required`, the property that is missing.
+ MissingProperty(String),
}
/// A single violation found when validating JSON against a schema.
@@ -949,6 +954,8 @@ pub struct SchemaViolation {
pub keyword: String,
/// The validation error message.
pub message: String,
+ /// What the keyword found, when the message alone doesn't locate the problem.
+ pub detail: SchemaViolationDetail,
/// For a failed `anyOf`, or a `oneOf` that no subschema matched, the violations of each
/// subschema in order; empty for every other keyword.
pub alternatives: Vec>,
@@ -968,11 +975,21 @@ impl SchemaViolation {
},
_ => Vec::new(),
};
+ let detail = match error.kind() {
+ ValidationErrorKind::AdditionalProperties { unexpected } | ValidationErrorKind::UnevaluatedProperties { unexpected } => {
+ SchemaViolationDetail::UnexpectedProperties(unexpected.clone())
+ },
+ ValidationErrorKind::Required { property } => {
+ SchemaViolationDetail::MissingProperty(property.as_str().map_or_else(|| property.to_string(), str::to_string))
+ },
+ _ => SchemaViolationDetail::None,
+ };
Self {
instance_path: error.instance_path().to_string(),
schema_path: error.schema_path().to_string(),
keyword: error.kind().keyword().to_string(),
message: error.to_string(),
+ detail,
alternatives,
}
}
diff --git a/lib/dsc-lib/src/lib.rs b/lib/dsc-lib/src/lib.rs
index b4c88957f..621380793 100644
--- a/lib/dsc-lib/src/lib.rs
+++ b/lib/dsc-lib/src/lib.rs
@@ -12,6 +12,7 @@ use dscresources::{dscresource::{DscResource, Invoke}, invoke_result::{GetResult
use rust_i18n::i18n;
pub mod configure;
+pub mod diagnostics;
pub mod discovery;
pub mod dscerror;
pub mod dscresources;
diff --git a/lib/dsc-lib/src/lint/mod.rs b/lib/dsc-lib/src/lint/mod.rs
index ca48fcc32..a7d74a412 100644
--- a/lib/dsc-lib/src/lint/mod.rs
+++ b/lib/dsc-lib/src/lint/mod.rs
@@ -33,6 +33,7 @@ use crate::{
get_require_adapter_from_directive,
parameters::import_parameters,
},
+ diagnostics::SourceMap,
discovery::discovery_trait::DiscoveryFilter,
dscerror::DscError,
dscresources::{dscresource::validate_json_all, resource_manifest::ResourceManifest},
@@ -48,12 +49,14 @@ pub mod diagnostic;
pub mod expression_index;
pub mod options;
pub mod pointer;
+pub mod report;
pub mod rule;
pub mod rules;
pub use diagnostic::{LintCategory, LintDiagnostic, LintLevel, LintResult, LintSummary, SourceLocation};
pub use options::{DocumentKind, LintOptions, LintSettings, RuleLevel, RuleLevelOverride, RuleSetting};
pub use pointer::JsonPointer;
+pub use report::LintDiagnosticReport;
pub use rule::{CheckFn, DiagnosticSink, LintRuleInfo, Rule, RuleTier};
use context::{collect_resource_sites, LintContext, ResolvedResource};
@@ -171,7 +174,7 @@ impl Linter {
/// Lint a document given as JSON or YAML text.
///
/// Problems in the document are reported as diagnostics, including a document that doesn't
- /// parse.
+ /// parse. Every diagnostic gets the line and column of its location in the text.
///
/// # Arguments
///
@@ -185,7 +188,13 @@ impl Linter {
/// diagnostic.
pub fn lint(&mut self, document: &str) -> Result {
match parse_document(document) {
- Ok(raw) => self.lint_parsed(&raw, Vec::new()),
+ Ok(raw) => {
+ let mut result = self.lint_parsed(&raw, Vec::new())?;
+ if let Some(map) = SourceMap::parse(document) {
+ fill_locations(&mut result.diagnostics, &map);
+ }
+ Ok(result)
+ },
Err(diagnostic) => Ok(LintResult::from_diagnostics(stamp_file(vec![*diagnostic], &self.options.file), false)),
}
}
@@ -480,6 +489,25 @@ fn stamp_file(mut diagnostics: Vec, file: &Option) -> Ve
diagnostics
}
+/// Record the line and column of each diagnostic's path on diagnostics that don't have a
+/// location yet.
+///
+/// A path the document doesn't spell out, like a property that is missing, gets the location of
+/// its closest enclosing value.
+///
+/// # Arguments
+///
+/// * `diagnostics` - The diagnostics.
+/// * `map` - The source map of the document text.
+fn fill_locations(diagnostics: &mut [LintDiagnostic], map: &SourceMap) {
+ for diagnostic in diagnostics.iter_mut().filter(|diagnostic| diagnostic.location.is_none()) {
+ if let Some((_, span)) = map.nearest_span(&diagnostic.path) {
+ let (line, column) = map.location(span.start);
+ diagnostic.location = Some(SourceLocation { line, column });
+ }
+ }
+}
+
/// Whether a rule's level is fixed.
///
/// # Arguments
diff --git a/lib/dsc-lib/src/lint/report.rs b/lib/dsc-lib/src/lint/report.rs
new file mode 100644
index 000000000..968c137d7
--- /dev/null
+++ b/lib/dsc-lib/src/lint/report.rs
@@ -0,0 +1,139 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Present lint diagnostics as [`miette::Diagnostic`]s, so a host can show each problem in the
+//! document text it was found in.
+
+use std::fmt::{self, Display};
+
+use miette::{Diagnostic, LabeledSpan, NamedSource, Severity, SourceCode};
+use rust_i18n::t;
+
+use crate::diagnostics::SourceMap;
+
+use super::diagnostic::{LintDiagnostic, LintLevel};
+
+/// A lint diagnostic together with the text it points into.
+///
+/// The rule identifier is the diagnostic code, the level is the severity, and the description of
+/// the rule is the help text. When the document text is available, the problem's location is
+/// labeled in it.
+#[derive(Debug)]
+pub struct LintDiagnosticReport {
+ message: String,
+ rule: String,
+ severity: Severity,
+ help: Option,
+ source: Option>,
+ label: Option,
+}
+
+impl LintDiagnosticReport {
+ /// Create a report for a diagnostic.
+ ///
+ /// # Arguments
+ ///
+ /// * `diagnostic` - The diagnostic.
+ /// * `source` - The source map of the document the diagnostic was found in, when the text is
+ /// available.
+ /// * `help` - What the rule requires, shown as the help text.
+ #[must_use]
+ pub fn new(diagnostic: &LintDiagnostic, source: Option<&SourceMap>, help: Option) -> Self {
+ let severity = match diagnostic.level {
+ LintLevel::Error => Severity::Error,
+ LintLevel::Warning => Severity::Warning,
+ LintLevel::Info => Severity::Advice,
+ };
+ let located = source.and_then(|map| {
+ let (_, span) = map.nearest_span(&diagnostic.path)?;
+ let first_line_end = map.text()[span.clone()].find('\n').map_or(span.end, |offset| span.start + offset);
+ let span = span.start..first_line_end.max(span.start);
+ let name = diagnostic.file.clone().unwrap_or_else(|| t!("lint.report.inputSourceName").to_string());
+ Some((NamedSource::new(name, map.text().to_string()), LabeledSpan::new(None, span.start, span.len())))
+ });
+ let (source, label) = match located {
+ Some((source, label)) => (Some(source), Some(label)),
+ None => (None, None),
+ };
+ Self {
+ message: diagnostic.message.clone(),
+ rule: diagnostic.rule.clone(),
+ severity,
+ help,
+ source,
+ label,
+ }
+ }
+}
+
+impl Display for LintDiagnosticReport {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.message)
+ }
+}
+
+impl std::error::Error for LintDiagnosticReport {}
+
+impl Diagnostic for LintDiagnosticReport {
+ fn code<'a>(&'a self) -> Option> {
+ Some(Box::new(&self.rule))
+ }
+
+ fn severity(&self) -> Option {
+ Some(self.severity)
+ }
+
+ fn help<'a>(&'a self) -> Option> {
+ self.help.as_ref().map(|help| Box::new(help) as Box)
+ }
+
+ fn source_code(&self) -> Option<&dyn SourceCode> {
+ self.source.as_ref().map(|source| source as &dyn SourceCode)
+ }
+
+ fn labels(&self) -> Option + '_>> {
+ self.label.as_ref().map(|label| Box::new(std::iter::once(label.clone())) as Box + '_>)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::lint::diagnostic::LintCategory;
+
+ fn diagnostic(path: &str, level: LintLevel) -> LintDiagnostic {
+ LintDiagnostic {
+ level,
+ rule: "duplicate-resource".to_string(),
+ category: LintCategory::Document,
+ message: "declared twice".to_string(),
+ path: path.to_string(),
+ file: Some("demo.dsc.yaml".to_string()),
+ location: None,
+ }
+ }
+
+ #[test]
+ fn report_labels_the_path_in_the_document() {
+ let text = "resources:\n- name: a\n type: T/E\n- name: a\n type: T/E\n";
+ let map = SourceMap::parse(text).unwrap();
+ let report = LintDiagnosticReport::new(&diagnostic("/resources/1", LintLevel::Error), Some(&map), Some("Names must be unique.".to_string()));
+ assert_eq!(report.code().unwrap().to_string(), "duplicate-resource");
+ assert_eq!(report.severity(), Some(Severity::Error));
+ assert_eq!(report.help().unwrap().to_string(), "Names must be unique.");
+ assert_eq!(report.to_string(), "declared twice");
+ let label = report.labels().unwrap().next().unwrap();
+ assert_eq!(&text[label.offset()..label.offset() + label.len()], "name: a");
+ assert_eq!(label.offset(), text.rfind("name: a").unwrap());
+ assert!(report.source_code().is_some());
+ }
+
+ #[test]
+ fn report_without_text_has_no_snippet() {
+ let report = LintDiagnosticReport::new(&diagnostic("/resources/0", LintLevel::Info), None, None);
+ assert_eq!(report.severity(), Some(Severity::Advice));
+ assert!(report.labels().is_none());
+ assert!(report.source_code().is_none());
+ assert!(report.help().is_none());
+ }
+}
diff --git a/rfc.md b/rfc.md
new file mode 100644
index 000000000..d5d4ff473
--- /dev/null
+++ b/rfc.md
@@ -0,0 +1,462 @@
+---
+RFC: RFCNNNN
+Author: @Gijsreyn
+Sponsor: @Gijsreyn
+Status: Draft
+SupercededBy: null
+Version: 1.0
+Area: CLI
+CommentsDue: 2026-11-20
+---
+
+# Static linting for configuration documents with `dsc lint`
+
+This RFC proposes a `dsc lint` command that checks DSC configuration documents written in YAML or
+JSON for problems without applying them. The command parses the document, validates it against
+the configuration document schema, and runs a catalog of rules that find mistakes DSC only reports
+at runtime today, or doesn't report at all. It reports each problem as a diagnostic with a level,
+a rule identifier, a message, and a JSON pointer to the location in the document, so authors,
+editors, and CI pipelines can act on the result.
+
+The linting engine lives in `dsc-lib` so other hosts can reuse it. The first phase covers
+configuration documents. A second phase extends the same command to resource and extension
+manifests, as suggested in the discussion of [#591][01].
+
+## Motivation
+
+> As a configuration author,
+> I want DSC to tell me about mistakes in my document before I apply it,
+> so that I don't discover a typo in a function name or a missing dependency halfway through a
+> `dsc config set` run.
+
+> As a platform engineer who keeps configuration documents in source control,
+> I want a command that fails a pull request on errors and emits machine-readable results,
+> so that broken documents never reach the machines they target.
+
+> As a resource author,
+> I want the same command to check my resource manifest,
+> so that I find an inconsistent manifest before publishing it.
+
+Today DSC only validates a document when it runs it. `dsc config validate` exists but is hidden,
+is only used by the Group, Assertion, and Include resources, reports a single `valid` boolean
+without a reason, exits with code `0` even when the document is invalid, and always discovers
+resources before it validates anything. Problems such as a `dependsOn` entry that names a
+resource that doesn't exist, a `reference()` to a resource that hasn't run yet, a parameter that
+is declared but never used, a secure parameter with a hard-coded default, or a condition written
+as a plain string are either reported late, with no location, or not at all.
+
+> [!NOTE]
+> I think earlier discussions revealed that eventually `dsc config validate` will be deprecated (question mark).
+
+[#591][01] proposed a `dsc lint` command with one rule: when the operating system root is
+overridden (`--system-root`, formerly `--target-path`), the document should use the `systemRoot()`
+function. The discussion asked whether linting belongs in an extension or in the core `dsc`
+executable, noted that a linter for both configurations and manifests would be valuable, and
+observed that a YAML and JSON authoring community needs good linting regardless of whether Bicep
+gains DSC-specific rules. [#842][02] asked `dsc config validate` to reject duplicate resource names
+and suggested growing that command into static analysis.
+
+This proposal answers those points as follows:
+
+- Linting is part of the core `dsc` executable and of `dsc-lib`. The rules need the document
+ schema, the expression parser, the function registry, resource discovery, and the dependency
+ resolution logic, all of which live in `dsc-lib`. Putting the engine in the library lets the
+ VS Code extension, the MCP server, and other hosts reuse it without spawning a process.
+ Extensions can't contribute rules in the first version; a `lint` extension capability is a
+ possible later addition.
+- One command covers every document kind. `dsc lint` detects the kind of document from its
+ `$schema` property. The first phase implements configuration documents and rejects manifests
+ with a clear diagnostic; the second phase adds manifests.
+- Bicep users benefit too. Bicep compiles to the same configuration document JSON, so `dsc lint`
+ can check the compiled output, and the rule identifiers follow the Bicep linter's naming where
+ the concept is shared.
+
+## Proposed experience
+
+Linting the document from [#842][02] reports the duplicate resource and exits with code `5`:
+
+```yaml
+# demo.dsc.yaml
+$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
+resources:
+- name: EnsureTest1Value
+ type: Microsoft.Windows/Registry
+ properties:
+ keyPath: HKEY_LOCAL_MACHINE\SOFTWARE\TEST
+ valueName: Test1
+ valueData: { DWord: 1 }
+- name: EnsureTest2Value
+ type: Microsoft.Windows/Registry
+ properties:
+ keyPath: HKEY_LOCAL_MACHINE\SOFTWARE\TEST
+ valueName: Test2
+ valueData: { String: Hello World! }
+- name: EnsureTest1Value
+ type: Microsoft.Windows/Registry
+ properties:
+ keyPath: HKEY_LOCAL_MACHINE\SOFTWARE\TEST
+ valueName: Test
+ _exist: false
+```
+
+```sh
+dsc lint --file ./demo.dsc.yaml
+```
+
+```yaml
+diagnostics:
+- level: error
+ rule: duplicate-resource
+ category: document
+ message: Resource 'EnsureTest1Value' of type 'Microsoft.Windows/Registry' is declared more than once
+ path: /resources/2
+ file: ./demo.dsc.yaml
+summary:
+ errors: 1
+ warnings: 0
+ info: 0
+valid: false
+discovery: true
+```
+
+A pipeline that lints on a build agent without the resources installed skips discovery and fails
+on warnings as well as errors:
+
+```sh
+dsc lint --file ./demo.dsc.yaml --no-discovery --fail-on warning
+```
+
+The rule from [#591][01] fires when `--system-root` is given and no expression calls
+`systemRoot()`:
+
+```sh
+dsc lint --file ./demo.dsc.yaml --system-root D:\
+```
+
+```yaml
+diagnostics:
+- level: warning
+ rule: system-root-unused
+ category: environment
+ message: A system root of 'D:\' was specified, but no expression calls systemRoot(), so the document doesn't target it
+ path: ''
+ file: ./demo.dsc.yaml
+```
+
+Rule levels can be changed per run or in the settings file, and the rules can be listed:
+
+```sh
+dsc lint --file ./demo.dsc.yaml --rule no-unused-parameters=off --rule missing-require-version=warning
+dsc lint --list-rules
+```
+
+```json
+{
+ "lint": {
+ "rules": {
+ "no-unused-parameters": { "level": "off" }
+ }
+ }
+}
+```
+
+## Specification
+
+### Command
+
+```text
+dsc lint [OPTIONS] (--file ... | --input )
+dsc lint --list-rules [--output-format ]
+```
+
+| Option | Description |
+| :---------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `-f, --file ` | A document to lint. `-` reads stdin. The option can be repeated; the result then carries the file name on each diagnostic. |
+| `-i, --input ` | The document as a string. Mutually exclusive with `--file`. |
+| `-p, --parameters ` | Parameter values as JSON or YAML, in either format `dsc config` accepts. Enables the rules that check supplied values. |
+| `--parameters-file ` | Parameter values from a file; `--parameters` takes precedence over it. |
+| `-r, --system-root