diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6e8fc5d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..5f8c17d --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,94 @@ +name: Check + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + build: + name: Build and check the schema + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.13" + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + - name: Install Bikeshed and Ruff + run: pip install -r requirements.txt && bikeshed update + - run: npm ci + - name: Lint the tools + run: ruff check tools/ + - name: Test the tools + run: python3 -m unittest discover -s tools/tests + - name: Build every version + run: tools/build.py + - name: Check every version's fixtures against its schema + run: | + shopt -s nullglob + for dir in v*/ releases/*/; do + node tools/schema-check.cjs "$(basename "$dir")" + done + - name: Keep the site for publishing + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@v7 + with: + name: site + path: site + retention-days: 1 + + openlint: + name: Conformance, OpenLint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Check out OpenLint at the commit the conformance tests were last run against + uses: actions/checkout@v7 + with: + repository: openlint/openlint + ref: 344dc7ea11d52a40d97361aa1e931743596b29bc + path: .linter/openlint + - uses: actions/setup-python@v7 + with: + python-version: "3.13" + - uses: actions/setup-node@v7 + with: + node-version-file: .linter/openlint/.nvmrc + - name: Build OpenLint + working-directory: .linter/openlint + run: | + npm install -g corepack --force + corepack enable + yarn install --immutable + yarn build + - name: Run the conformance tests of every draft + run: | + for dir in v*/; do + tools/conformance.py "${dir%/}" --linter "node $GITHUB_WORKSPACE/.linter/openlint/packages/cli/dist/index.js lint -r {ruleset} -f json {document}" + done + + spectral: + name: Conformance, Spectral 6.17.0 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.13" + - uses: actions/setup-node@v7 + with: + node-version: 24 + - name: Install Spectral + run: npm install --prefix "$RUNNER_TEMP/spectral" @stoplight/spectral-cli@6.17.0 + - name: Run the conformance tests of every draft + run: | + for dir in v*/; do + tools/conformance.py "${dir%/}" --linter "node $RUNNER_TEMP/spectral/node_modules/@stoplight/spectral-cli/dist/index.js lint -r {ruleset} -f json {document}" + done diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..287ba58 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,39 @@ +name: Publish + +on: + workflow_run: + workflows: [Check] + types: [completed] + branches: [main] + +permissions: + contents: read + +jobs: + publish: + name: Publish to GitHub Pages + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' + runs-on: ubuntu-latest + permissions: + actions: read + pages: write + id-token: write + concurrency: + group: pages + cancel-in-progress: false + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Fetch the site the Check run built + uses: actions/download-artifact@v8 + with: + name: site + path: site + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/upload-pages-artifact@v5 + with: + path: site + - id: deployment + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..857550b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +site/ +.build/ +node_modules/ +.venv/ +__pycache__/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4bdbe3f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,330 @@ +# Contributing to the specification + +This guide is for anyone who edits the OpenLint ruleset specification. It covers the files you edit, how the build checks them, and the conventions the text follows. To report a problem or suggest a change without editing, see [Where things go](README.md#where-things-go). + +## Set up + +### Layout + +| Path | What | +| ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `v1/` | The working draft of 1.x: `index.bs`, `ruleset.schema.json`, the format identifiers and their detection conditions in `formats.json`, the conformance tests, and the boilerplate includes. | +| `releases//` | A release: a snapshot of a draft, never edited again. There is none yet. | +| `assets/` | The logo, used by every published page. | +| `schema/registry.schema.json` | The schema of `requirements.json`, the list of classes, requirements, and tests that the build publishes next to each version. | +| `tools/build.py` | Checks every version and builds `site/`. The code is the package `tools/specbuild/`, one module per concern, with its unit tests in `tools/tests/`. | +| `tools/release.py ` | Cuts a release from the draft of its major version. | +| `tools/conformance.py --linter ''` | Runs the conformance tests against a linter. | +| `tools/schema-check.cjs ` | Checks every ruleset fixture against the schema. | +| `package.json`, `package-lock.json`, `requirements.txt`, `pyproject.toml` | The pinned Node and Python dependencies of the tools, and Ruff's settings. | +| `.github/workflows/check.yml`, `publish.yml` | `check.yml` lints and tests the tools, builds every version, checks the schema, and runs each draft's conformance tests against OpenLint and Spectral. `publish.yml` deploys the site that a passing Check run on `main` built to GitHub Pages. | +| `.github/dependabot.yml` | Weekly update pull requests for the npm and Python dependencies and the workflow actions. | +| `site/` | Build output, the shape of `spec.openlint.org`. Never edited, never committed. | + +Each major version has a working draft in a folder of its own, such as `v1/` for 1.x. So far there is only `v1/`. You edit these files in it: + +- `index.bs` is the working draft, in Bikeshed's Markdown-flavored source. +- `conformance///` holds one conformance test per directory. +- `formats.json` lists the format identifiers and how each is detected. +- `ruleset.schema.json` is the ruleset schema of that major version. It is normative, and the property tables in the document are generated from its descriptions. +- `header.include` and `copyright.include` hold the boilerplate. You rarely need to change them. + +The build generates the rest: the headers of requirement blocks, the property tables, the formats table, Appendix A, Appendix B, the list of requirements the schema cannot check in Appendix D, each version's `requirements.json`, and all of `site/`. Never edit `site/` or anything under `releases/`. + +### Install the tools + +Once, from the repository root, with Python 3.10 to 3.13 (CI uses 3.13; Bikeshed's `lxml` has no wheel for newer versions yet, so pip would try to compile it) and Node 24: + +```sh +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt && .venv/bin/bikeshed update +npm ci +``` + +`requirements.txt` pins the Bikeshed and Ruff versions, and `package.json` pins the JSON Schema validator that the schema check uses. The build finds Bikeshed in `.venv/`, on your `PATH`, or in `~/.local/bin`, where pipx installs it. + +### Build and check + +Run these from the repository root. Name a draft by its folder, such as `v1`, and a release by its version, such as `1.0.0`. + +| Command | What it does | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `tools/build.py` | Checks every version and builds `site/`. | +| `tools/build.py --check` | Runs every check, Bikeshed's included, and writes nothing. | +| `tools/build.py v1` | Builds the 1.x draft only. | +| `node tools/schema-check.cjs v1` | Validates every ruleset fixture of the 1.x draft against its schema. Run it after a build. | +| `tools/conformance.py v1 --linter ''` | Runs the conformance tests of the 1.x draft against a linter. See [Run the tests against a linter](#run-the-tests-against-a-linter). | + +When a check fails, the build prints the problem with its file and line and exits with a nonzero status. It also fails on a link that Bikeshed cannot resolve. To read the result, open `site/index.html`, or run `python3 -m http.server -d site 8000` to see the pages at the paths they would have when published. + +After changing a tool, run `.venv/bin/ruff check tools/` and `python3 -m unittest discover -s tools/tests`. + +### What CI runs + +On every pull request, `.github/workflows/check.yml` lints and tests the tools, builds every version, checks the schema, and runs each draft's conformance tests against both linters. It builds OpenLint at the commit pinned in that workflow, so update the commit when the draft follows a newer OpenLint. On `main`, `.github/workflows/publish.yml` then deploys the `site/` that run built to GitHub Pages, and only when every check passed. + +## Write the document + +### Where text belongs + +The document has these parts, in this order: + +- **Introduction, Conformance, and Terminology.** How to read the document, the conformance classes, and the defined terms. +- **The ruleset format** (part 4). It follows the order of a ruleset document: the document itself, the Ruleset Object and its properties, then the Rule Object and its properties. +- **Documents** (part 5). What a linter lints, and what happens to a document before any rule runs: parsing, reference resolution, and formats. +- **Linting** (part 6). What a linter does, in pipeline order: load the ruleset, read the document, apply overrides, work out the rule state, select documents, select nodes, apply functions, and report results. +- **Functions** (part 7). The core functions and custom functions. +- **Security and privacy considerations**, then **Appendices A to E**: the requirements index, the conformance tests, the changes since the last release, using the schema, and provenance. + +Part 4 holds only `ruleset` requirements. Parts 5, 6, and 7 hold only `linter` requirements. The build fails when a requirement sits in a part of the other target. + +A section opens with informative text on what the thing is and how it works. Its requirements follow. The reason for a requirement goes in the paragraph just before it. Examples and issues sit next to the requirement they belong to. + +Section 6.1 introduces a running example: a base ruleset, a project ruleset that extends it, and one document. Each later section of Linting shows what happens to that example at its stage. The example is also the conformance test `/conf/linter/walkthrough`, and the example blocks show its fixtures. When you change a Linting section, check that its part of the example still holds. + +The core functions double as user documentation, so a ruleset author reads these sections on their own. Each section has, in this order: + +1. One sentence on what the function asserts, and one on when to use it. +2. "It applies to ...", naming the values the function checks. +3. The options table, or "This function takes no options." +4. One requirement, `/req/functions/`. +5. An example rule. + +### Style + +- Call each thing by one name, the defined term where there is one. +- Write headings in sentence case. Object names such as Ruleset Object keep their capitals. +- Write emphasis with asterisks, `*text*`. Bikeshed prints `_text_` with its underscores, and the build fails on it. If your editor rewrites asterisks to underscores on save, turn that off for `.bs` files. +- Link a section with `[[#id]]` or `[[#id|text]]`. Link a requirement with `/req/given/no-match`, because Bikeshed does not parse `[[#/req/...]]`. +- Link a discussion as `discussion #12`. The build fails on a bare "discussion #12". + +### Requirement and class blocks + +Bikeshed has no notion of an identified requirement. Requirements, recommendations, permissions, and conformance classes are HTML blocks with data attributes, and the build expands them before Bikeshed runs. + +| Block | Identifier | Keywords | Tests | +| ---------------------------------------- | --------------------- | ------------------------------------------------ | ------------------------------------------------------------ | +| `
`, a requirement | `/req//` | MUST, MUST NOT, SHALL, SHALL NOT, REQUIRED | At least one, as [the test rules](#the-test-rules) describe. | +| `
`, a recommendation | `/rec//` | SHOULD, SHOULD NOT, RECOMMENDED, NOT RECOMMENDED | None. The build rejects a test for a recommendation. | +| `
`, a permission | `/per//` | MAY, OPTIONAL | At least one that shows the permitted thing is accepted. | +| `
`, a conformance class | `/conf/` | None | None. | + +A requirement looks like this: + +```html +
+ A [=linter=] MUST NOT produce a [=result=] for a [=rule=] whose `given` + matches no node in the [=document=]. +
+``` + +- Put the opening tag and `
` each on a line of its own. Blocks do not nest. +- Set `data-class` to the conformance class. The class fixes the target, so a requirement has no target of its own. +- Leave `data-since` off a new requirement. `tools/release.py` adds it when it cuts the first release that contains the requirement. +- Leave `data-status` off a settled requirement. Set it to `proposed` for one that states behavior ahead of the implementations: its tests run but do not count in the conformance run, and a release refuses to include it, so a proposed requirement is a decision the community has to take before the next release. Set it to `removed`, with `data-removed=`, for one that is withdrawn; a removed requirement stays listed. The value `example` keeps a block out of the registry, and only the sample block in "How to read this document" uses it. +- Give a class block `data-target`, either `ruleset` or `linter`, and `data-depends` with the names of the classes it depends on. Its body says what the class covers. + +The identifier is the block's `id`, so `#/req/given/no-match` links to the requirement in every version that has it. It is permanent: + +- `` is what the requirement is about, usually a ruleset key or a step of linting, such as `given`, `overrides`, or `result`. It is not a section of the document, so moving a block does not change its identifier. +- `` and `` are lowercase words joined by hyphens. +- Never reuse an identifier, and never change what a requirement means under the same identifier. When a change alters what conforms, give the requirement a new identifier and mark the old one removed. + +The build compares each draft with every release of its major version and of earlier ones. A released identifier that is missing from a draft fails the build, and so does one whose kind or target changed. A released requirement whose text changed in the draft of its major version is reported, and you decide whether the change alters what conforms. The form of identifiers is an open question in the draft, so it can still change. + +### Requirements + +**Make each requirement one testable statement about one target.** The target is the subject: "A [=linter=] MUST ..." or "A [=ruleset=] MUST ...". Two sections take the thing the requirement constrains as subject instead, because that reads better: a core function's requirement, "`truthy` MUST fail when ...", and a result's, "The `code` of a [=result=] MUST be ...". A sentence without a keyword is allowed inside a block only to define a term the keyword sentence uses, such as which regular expression dialect `match` is written in. A rule of its own gets a keyword. + +**Use BCP 14 keywords in capitals only inside blocks, and only those of the block's kind.** The build fails on a keyword in capitals anywhere else. In informative text, write "has to" and "can". Avoid a lowercase "must" or "may" that a reader could take for a requirement. + +**Use the defined terms.** Link a term with `[=term=]`. Define a new term with `` in Terminology. Bikeshed fails the build on a link to a term that does not exist. + +**State the format as it behaves today.** The draft describes the format as Spectral and OpenLint behave, and every test passes against both. Probe both linters before you write a requirement. When the behavior could be better, write the requirement as the behavior is, and propose the change in an issue next to it. + +**Leave structure to the schema.** `/req/ruleset/schema` makes the ruleset schema normative, so the structure of a ruleset lives there. Write a ruleset requirement only for what a JSON Schema cannot express, such as a name that has to refer to something declared elsewhere, a cycle, or the options of a named function. The build lists those requirements in Appendix D. + +**State a constraint once.** A constraint on rulesets is a ruleset requirement. A linter's duties toward all of them are `/req/linter/reject-invalid` and `/req/linter/accept-valid`, so a ruleset constraint never gets a linter twin. The one exception is what a linter checks in an override only for the documents the override applies to, which is `/req/overrides/checked-when-applied`. + +**Name no implementation.** Spectral and OpenLint appear only in issues and in the provenance appendix, and an issue says OpenLint, not "the reference implementation". The build fails on a mention anywhere else, including schema descriptions and test purposes. Identifiers that rulesets use, such as `spectral:oas`, are not mentions. + +**Name a programming language only where the format takes something from it.** A requirement can pin semantics the format borrows from a language, such as the ECMAScript regular expressions of `pattern`. A language binding names its language, such as the JavaScript binding of custom functions. Nowhere else does the text assume what a linter is written in. + +**Pin references.** `[[!RFC8259]]` is a normative reference and `[[RFC9535]]` an informative one. When Bikeshed's bibliography lacks the exact version, add an entry to the `
` block at the top of `index.bs`.
+
+### Issues and notes
+
+Start a paragraph with `Issue:` for an open question or a proposed change. Bikeshed collects issues into the issues index.
+
+- Issues live only in the drafts. `tools/release.py` refuses to cut a release while one remains. Before a release, resolve each issue in the text, or turn it into a note that states the accepted limitation.
+- When you resolve the last issue in a draft, also rewrite the sentence in "How to read this document" that links to the issues index. Bikeshed generates the index only while issues remain, so the build and `tools/release.py` fail on the link until you do.
+- An issue that proposes a breaking change cannot be resolved in its own major version. Before that major version's next release, turn it into a note there, and resolve the issue in the later draft.
+- An issue that proposes a change says what happens today, then what the change would do: "`then: []` is accepted, and the rule then asserts nothing and never reports a result. Rejecting an empty array would catch a rule left unfinished."
+- An issue can name the implementation whose behavior it describes.
+
+Start a paragraph with `Note:` for a remark a reader could otherwise miss, such as two places that write severities differently. The reason for a requirement is not a note. It goes in the paragraph before the requirement.
+
+### Examples
+
+Put an example in `
` with a fenced code block. YAML is the default highlighting. Give rules descriptive names, such as `operation-summary`, so the example reads as a real ruleset. Test fixtures can use short names such as `r`. + +An example that shows a fixture names the file in `data-fixture`, relative to the draft's folder. The build fails when the example and the file differ, so copy the file into the example: + +````html +
+ ```yaml extends: ./acme-base.yaml ... ``` +
+```` + +### Tables generated from the schema + +Each draft's schema, `v/ruleset.schema.json`, is written in JSON Schema 2020-12. Its `$id` is the URL it is published at, such as `https://spec.openlint.org/v1/draft/ruleset.schema.json`. A release's copy takes the release's URL, such as `https://spec.openlint.org/1.0.0/ruleset.schema.json`, so the schema of each release has an identity of its own. The build fails when a schema's `$id` differs from its URL. `/v/ruleset.schema.json` serves the schema of the latest release of a major version, for editors that follow the newest release; until that major version has a release, it serves the draft's schema. + +To show the properties of an object, put a table marker on a line of its own. `data-def="#"` is the root object, and any other value names a definition under `$defs`: + +```html +
+``` + +The build generates the table from the schema, so the descriptions in the schema are text of the specification: + +- Every property needs a `description`. The build fails without one. +- Write each description as informative text: whole sentences, no BCP 14 keywords, and no implementation names. +- The Type column gives the JSON type. When a section of the document defines the value, the cell links to it with "see", and the detail lives there, not in the table. +- When you add a definition that a table links to, add it to `DEF_SECTIONS` in `tools/specbuild/schema_tables.py`. That map links each definition to the section that explains it. + +The `format` enum in the schema and `formats.json` list the same identifiers. The build generates the formats table from `formats.json`, with each identifier's detection condition, and fails when the two differ. + +The schema always matches the specification. A change to the schema and the matching change to the text go in the same pull request. After you change the schema, run `schema-check.cjs`. Every ruleset fixture has to keep its verdict, and CI runs the same check on every pull request. + +## Write tests + +### Tests as data + +Every requirement has tests, and every test is data. The build renders Appendix B, and the test list in each requirement block, from the same files that `tools/conformance.py` runs. + +A test is a directory, `conformance///` in the draft's folder, and its identifier is `/conf//`. Its topic is the ruleset key or linting step the test exercises, which can differ from its requirements' topic: most tests of `/req/ruleset/schema` are filed under the key they check. The directory holds `test.json` and the fixtures. A test for a ruleset requirement expects the ruleset to be valid or invalid: + +```json +{ + "for": ["/req/given/alias-defined"], + "purpose": "A rule referencing an alias the ruleset does not declare is invalid.", + "ruleset": "ruleset.yaml", + "expect": { "ruleset": "invalid" } +} +``` + +A test for a linter requirement expects results, or expects the linter to reject the ruleset: + +```json +{ + "for": ["/req/result/severity"], + "purpose": "A rule with `severity: hint` reports `severity: 3`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { "results": [{ "severity": 3 }, { "severity": 3 }] } +} +``` + +| Field | Meaning | +| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `for` | The requirements the test covers. They all belong to one class. | +| `purpose` | One sentence that says what conforms. | +| `ruleset` | The ruleset fixture. The default is `ruleset.yaml`. | +| `document` | The document fixture, for a linter test. A ruleset test has none, and the runner lints an empty document. | +| `files` | Other fixtures the test needs, such as an extended ruleset or a custom function. | +| `expect` | `{"ruleset": "valid"}`, `{"ruleset": "invalid"}`, `{"rejected": true}`, or `{"results": [...]}`, with `"ordered": true` when the order of the results matters. | + +The linter has to produce as many results as `results` lists. Each listed entry has to match one actual result on the fields the entry names. `"has"` lists fields that have to be present, whatever their value. `"${document}"` stands for the absolute path of the document fixture. Fixture names are paths inside the test directory; a name that leaves it, or a symbolic link, fails the build. + +Write the purpose as one sentence in the present tense that states the outcome, such as "A rule whose `given` is an object is invalid." Name no implementation in it. + +You never order or group tests by hand. The build lists each requirement's tests with the conforming side first. It groups a list of more than ten tests by section when the tests come from several sections. + +### The test rules + +1. A ruleset requirement has at least one invalid test and at least one valid test. The invalid fixture violates the requirement and is rejected. The valid fixture uses the same property and is loaded. +2. A linter requirement has at least one test that expects results, because a linter that does nothing passes a test that expects none. When a requirement says that something does not happen, add a control test in which the same mechanism produces results. +3. A permission has a test in which a fixture uses what is permitted and is accepted. Where a requirement forbids the same thing elsewhere, add a test that puts it there and is rejected. +4. An invalid fixture violates one requirement only, so that its rejection proves that requirement and not another. +5. A recommendation has no test. + +The build fails when a requirement lacks the tests that rule 1 or rule 2 asks for, and when a permission has no test that accepts it. `tools/schema-check.cjs` checks rule 4 against the schema: + +- For a test of `/req/ruleset/schema`, the schema's verdict has to equal the expectation. +- Every other ruleset fixture that parses has to pass the schema. Otherwise an invalid fixture for another requirement would violate the schema requirement too. +- The ruleset of a linter test that expects it to load has to pass the schema. + +### Run the tests against a linter + +The conformance runner takes the linter as a command template. `{ruleset}` and `{document}` stand for the fixture paths, and a relative path in the command is taken from the directory you run it in. Before the tests, the runner checks that the linter prints results for a ruleset every linter has to load, and stops when it does not. + +```sh +# OpenLint, after `yarn && yarn build` in a checkout of openlint/openlint next to this one +tools/conformance.py v1 --linter 'node ../openlint/packages/cli/dist/index.js lint -r {ruleset} -f json {document}' + +# Spectral, after `npm install @stoplight/spectral-cli@6.17.0` in a scratch directory (the version CI pins) +tools/conformance.py v1 --linter 'node /node_modules/@stoplight/spectral-cli/dist/index.js lint -r {ruleset} -f json {document}' +``` + +A full run takes about ten minutes per linter. While you work, add `--only ` to run only the tests whose identifier starts with that prefix, such as `--only /conf/given`. + +Run a new test against both linters before you rely on it. A test that fails on either linter means that the test or the requirement is wrong, because the draft states the current behavior. Tests of a `proposed` requirement are the exception: the runner reports them and does not count them. + +## Manage versions + +### Several drafts + +This section applies once there is more than one draft. Most changes apply to every major version: a clarification, a fixed test, or a new feature that keeps existing rulesets valid. Make such a change in each draft, in the same pull request. + +A change that breaks what an earlier major version allows goes into the later draft only: + +- To change what a requirement means in 2.x, mark it `data-status=removed` with `data-removed="2.0.0"` in `v2/index.bs`, and add the new behavior under a new identifier. +- To leave a requirement out of 2.x, mark it removed in `v2/index.bs` the same way. Never delete it from a later draft. +- To add something to 2.x only, add it to `v2/` alone. + +The community has not agreed yet on what counts as a breaking change. + +The build checks that no change is missed: + +- Every requirement of a draft is in the next draft, current or removed, and a removed requirement stays removed. +- A requirement that both drafts list as current has the same kind, class, status, and source text in both. Otherwise the build fails and names the line in each draft. +- A test that differs between the drafts, or that the later draft lacks, is reported. + +The build does not compare informative text. To see every difference between two drafts, run `diff v1/index.bs v2/index.bs`. Apart from what 2.x changes on purpose, the drafts differ only where they name their major version: the metadata, the schema URLs in Appendix D, and the schema's `$id`. + +### Start a new major version + +Start the next draft only when the community accepts the first change that breaks the current major version. To start it: + +1. Copy the latest draft folder, such as `v2/` to `v3/`. +2. Set the `$id` in the new `ruleset.schema.json` to the URL the draft's schema is published at, such as `https://spec.openlint.org/v3/draft/ruleset.schema.json`. The build fails until you do. +3. In the new `index.bs`, update every place that names the major version: `ED`, the `VERSIONLABEL` text macro, `Custom Warning Text`, and the schema URLs in Appendix D. A `diff` with the old draft shows these places. + +The build finds the new draft by its folder name and publishes it at `/v/draft/`. + +### Cut a release + +A release is a snapshot under `releases//`, and nobody edits it again. A fix ships as a new patch release. + +To cut a release, run `tools/release.py `. It cuts the release from the draft of its major version, such as `1.1.0` from `v1/` and `2.0.0` from `v2/`. The tool does these steps in order: + +1. It runs every check of the build, Bikeshed included, and refuses when an issue or a `proposed` requirement remains. +2. It adds `data-since=""` to every requirement in the draft that has none, and to the same requirements in every later draft. +3. It copies `index.bs`, the includes, `formats.json`, `conformance/`, and `ruleset.schema.json` into `releases//`. It rewrites the copy's metadata with the version label, the date, its own URL, a link to the draft, and no draft warning, and sets the schema's `$id` to the release's URL. +4. It points "Previous Version" and "Latest release" at the new release, in the draft of its major version and in every later draft whose major version has no release yet. + +It refuses to overwrite a release that exists. Record what changed since the last release in Appendix C of the draft. + +## Before you open a pull request + +1. Run `tools/build.py`, and fix every problem it reports. +2. Run `node tools/schema-check.cjs v1`, and again for every other draft. +3. Run the tests you added or changed against OpenLint and Spectral with `--only`. +4. After changing a tool, run `.venv/bin/ruff check tools/` and `python3 -m unittest discover -s tools/tests`. +5. Read the sections you changed in `site/v1/draft/index.html`. +6. Say in the pull request how AI was used, if it was. diff --git a/README.md b/README.md index a39fe12..88bc475 100644 --- a/README.md +++ b/README.md @@ -2,44 +2,41 @@ **The specification and JSON Schema for OpenLint rulesets: the Spectral ruleset format, specified on its own.** -A ruleset says where to look in a JSON or YAML document and what must hold there. Organizations publish their API style guides, security baselines and design standards as rulesets, then run them in CI against every OpenAPI, AsyncAPI, Arazzo and JSON Schema document they ship. +A ruleset is a set of rules for checking JSON and YAML documents. Each rule selects values in a document with a JSONPath expression and checks each selected value with a function. Organizations publish their API style guides, security baselines and design standards as rulesets, then run them in CI against every OpenAPI, AsyncAPI, Arazzo and JSON Schema document they ship. -The format has never been specified. It existed only as configuration for one linter, versioned with that tool. This repository treats the ruleset as an artifact in its own right: an independently versioned specification with a portable JSON Schema, so rules can be written, validated, shared and run by any tool. +The format has never been specified. It existed only as configuration for one linter, versioned with that tool. This repository treats the ruleset as an artifact in its own right: an independently versioned specification with a portable JSON Schema and conformance tests, so rules can be written, validated, shared and run by any linter. -| | | -| -- | -- | -| **Specification** | [SPECIFICATION.md](SPECIFICATION.md) | -| **JSON Schema** | [`schema/v1/ruleset.schema.json`](schema/v1/ruleset.schema.json) (draft 2020-12). Canonical `$id`: `https://spec.openlint.org/schema/v1/ruleset.schema.json`, not served yet | -| **Reference implementation** | [openlint/openlint](https://github.com/openlint/openlint) | -| **Website** | [openlint.org/spec](https://openlint.org/spec/) | -| **Published releases** | `spec.openlint.org`, proposed in [#13](https://github.com/orgs/openlint/discussions/13) and not set up yet | +| | | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| **Specification** | [`v1/index.bs`](v1/index.bs), the working draft of 1.x. It will be published at `https://spec.openlint.org/v1/draft/`, which is not served yet | +| **JSON Schema** | [`v1/ruleset.schema.json`](v1/ruleset.schema.json) (draft 2020-12) | +| **Conformance tests** | [`v1/conformance/`](v1/conformance/), one directory per test | +| **Reference implementation** | [openlint/openlint](https://github.com/openlint/openlint) | +| **Website** | [openlint.org/spec](https://openlint.org/spec/) | -> **Status: draft.** The document describes the format as implemented today, faithfully and without extension. It is not yet a formal specification: it will be rewritten to the principles below. The `v1` schema identity is stable: see [Versioning](SPECIFICATION.md#versioning). +> **Status: working draft.** The specification states the format as Spectral and OpenLint behave today, as identified requirements with conformance tests that pass against both. Where that behavior could be better, an issue in the draft proposes the change. There is no release yet. -## How the specification will be written +## Versions -Proposed by Joost Farla in [discussion #13](https://github.com/orgs/openlint/discussions/13) and not yet agreed: +Each release will live at its own URL that never changes, such as `https://spec.openlint.org/1.0.0/`, with its schema at `https://spec.openlint.org/1.0.0/ruleset.schema.json`. A fix ships as a new patch release. Each major version has a working draft in a folder of its own, published at `https://spec.openlint.org/v/draft/`. -1. **BCP 14 keywords.** MUST, SHOULD, MAY and the others have their [RFC 2119](https://www.rfc-editor.org/info/rfc2119/) meaning only in capitals ([RFC 8174](https://www.rfc-editor.org/rfc/rfc8174)), with the same boilerplate as OpenAPI, Arazzo and Overlay. Examples are informative. -2. **Defined terms.** A glossary of the terms requirements depend on, such as document, ruleset, rule, match, result and linter. Where a normative reference already defines a term, link to it. -3. **Exact normative references.** Every reference names an exact version, especially JSONPath, regular expressions, JSON Schema and YAML. -4. **One conformance target per requirement.** Each requirement applies to a ruleset or to a linter, never both. -5. **A permanent ID for every requirement,** such as `/req/given/no-match`, never reused. Removed requirements stay listed as removed. -6. **Conformance tests for every requirement,** listed in the spec and implemented by the conformance suite. -7. **A defined result model,** adopting the fields of Spectral's JSON output (`code`, `path`, `severity`, `message`, `range`, `source`) as normative. -8. **Immutable releases.** Each release lives at its own URL, such as `spec.openlint.org/1.0.0/`, and never changes. Work in progress lives at `spec.openlint.org/draft/`. +`https://spec.openlint.org/v1/` will serve the latest 1.x release. Editors that follow the newest release can point at `https://spec.openlint.org/v1/ruleset.schema.json`. Until 1.0.0 is released, it serves the draft's schema. Each schema's `$id` is the URL it is published at, so the schema of every release has an identity of its own. -Open questions: whether to group requirements into conformance classes, whether path-style IDs work for everyone, and what to publish with (Respec, Bikeshed or something else). +Within a major version, changes are additive and backward-compatible: new optional properties, newly registered format identifiers, clarified descriptions. A change that rejects a previously valid ruleset goes into the draft of the next major version. What counts as a breaking change is discussed in [#12](https://github.com/orgs/openlint/discussions/12). ## Validate a ruleset ```bash -npx ajv-cli validate --spec=draft2020 \ - -s schema/v1/ruleset.schema.json \ - -d .spectral.yaml +npx ajv-cli validate --spec=draft2020 --strict=false \ + -s v1/ruleset.schema.json \ + -d ruleset.yaml ``` -Editor setup is in [Using the schema](SPECIFICATION.md#using-the-schema). +Editor setup is in Appendix D of the draft, Using the schema. + +## Contribute + +[CONTRIBUTING.md](CONTRIBUTING.md) explains the layout of this repository, how to build and check the draft, how to write requirements, issues, and tests, and how to run the conformance tests against a linter. ## What this format is for @@ -47,25 +44,25 @@ Editor setup is in [Using the schema](SPECIFICATION.md#using-the-schema). ## Why this is separate from the linter -When the format ships only as part of one linter, it stops moving when the linter does. A team's governance rules usually outlast the CLI, the vendor, and often the API itself. So the rules stand on their own: specified, versioned independently, and implementable by anyone. The reference implementation follows this document, and other engines, such as [vacuum](https://github.com/daveshanley/vacuum), are welcome to implement it too. A written specification with a public conformance suite is what lets several engines agree. +When the format ships only as part of one linter, it stops moving when the linter does. A team's governance rules usually outlast the CLI, the vendor, and often the API itself. So the rules stand on their own: specified, versioned independently, and implementable by anyone. The reference implementation follows this document, and other linters, such as [vacuum](https://github.com/daveshanley/vacuum), are welcome to implement it too. A written specification with a public conformance suite is what lets several linters agree. ## Work in progress -| Topic | Discussion | -| -- | -- | -| Formalizing the specification: the principles above | [#13](https://github.com/orgs/openlint/discussions/13) | -| Backwards compatibility: what we promise, and to whom | [#12](https://github.com/orgs/openlint/discussions/12) | -| Document types ("formats") | [#5](https://github.com/orgs/openlint/discussions/5) | -| Aliases | [#2](https://github.com/orgs/openlint/discussions/2) | -| Rules testing | [#7](https://github.com/orgs/openlint/discussions/7) | +| Topic | Discussion | +| ---------------------------------------------------------------- | ------------------------------------------------------ | +| Formalizing the specification: the principles this draft applies | [#13](https://github.com/orgs/openlint/discussions/13) | +| Backwards compatibility: what we promise, and to whom | [#12](https://github.com/orgs/openlint/discussions/12) | +| Document types ("formats") | [#5](https://github.com/orgs/openlint/discussions/5) | +| Aliases | [#2](https://github.com/orgs/openlint/discussions/2) | +| Rules testing | [#7](https://github.com/orgs/openlint/discussions/7) | ## Where things go -| What | Where | -| -- | -- | -| Problems with the format, the schema or this document | [openlint/spec issues](https://github.com/openlint/spec/issues) | -| Problems with the linter | [openlint/openlint issues](https://github.com/openlint/openlint/issues) | -| Ideas, direction and questions | [OpenLint discussions](https://github.com/orgs/openlint/discussions) | +| What | Where | +| ----------------------------------------------------- | ----------------------------------------------------------------------- | +| Problems with the format, the schema or this document | [openlint/spec issues](https://github.com/openlint/spec/issues) | +| Problems with the linter | [openlint/openlint issues](https://github.com/openlint/openlint/issues) | +| Ideas, direction and questions | [OpenLint discussions](https://github.com/orgs/openlint/discussions) | The most useful contribution is a ruleset that behaves differently from what this document says. That's a bug in the specification. diff --git a/SPECIFICATION.md b/SPECIFICATION.md deleted file mode 100644 index 4a323d3..0000000 --- a/SPECIFICATION.md +++ /dev/null @@ -1,572 +0,0 @@ -# OpenLint Ruleset Specification - -**Status: draft.** This document describes the ruleset format as it is implemented today: -the Spectral ruleset format, documented faithfully and without extension. It is a complete -description, but not yet a formal specification. [Discussion #13](https://github.com/orgs/openlint/discussions/13) -proposes how it will be rewritten: BCP 14 keywords, a glossary of defined terms, exact -normative references, one conformance target and a permanent ID per requirement, conformance -tests for every requirement, a normative result model, and immutable releases at -`spec.openlint.org`. Until then, the **MUST** below is descriptive of the reference -implementation, not yet a normative requirement. - -Where this document and the reference implementation disagree, the implementation is right -and the document has to be corrected. A ruleset that behaves differently from what this -document says is a bug report: please [open an issue](https://github.com/openlint/spec/issues). - -## Contents - -- [Using the schema](#using-the-schema) -- [The Ruleset Object](#the-ruleset-object) -- [Rules](#rules) - - [The Rule Object](#the-rule-object) - - [`given` and path expressions](#given-and-path-expressions) - - [`then` and functions](#then-and-functions) - - [`severity`](#severity) - - [`formats`](#formats) - - [`message`](#message) - - [Rule shorthands](#rule-shorthands) -- [`extends`](#extends) -- [`aliases`](#aliases) -- [`overrides`](#overrides) -- [`parserOptions`](#parseroptions) -- [Custom functions](#custom-functions) -- [Extension properties](#extension-properties) -- [Conformance notes](#conformance-notes) -- [Runtime behavior (to be specified)](#runtime-behavior-to-be-specified) -- [Versioning](#versioning) -- [Provenance and license](#provenance-and-license) - ---- - -## Using the schema - -The schema validates a ruleset expressed as JSON or YAML. Its canonical `$id` is: - -``` -https://spec.openlint.org/schema/v1/ruleset.schema.json -``` - -**In an editor.** VS Code and other JSON Schema-aware editors give you completion and inline -validation. The format reserves no `$schema` key and rejects unknown root properties, so bind -the schema from outside the document rather than inside it. - -For YAML rulesets, a modeline comment: - -```yaml -# yaml-language-server: $schema=https://spec.openlint.org/schema/v1/ruleset.schema.json -extends: spectral:oas -``` - -For JSON rulesets, map it by filename in `.vscode/settings.json`: - -```json -{ - "json.schemas": [ - { - "fileMatch": [".spectral.json", "*.ruleset.json"], - "url": "https://spec.openlint.org/schema/v1/ruleset.schema.json" - } - ] -} -``` - -Do not add a `"$schema"` property to the ruleset itself — the linter will reject it. - -**In CI.** Any draft 2020-12 validator will do: - -```bash -npx ajv-cli validate --spec=draft2020 \ - -s schema/v1/ruleset.schema.json \ - -d .spectral.yaml -``` - -Validating a ruleset against this schema is a stricter and earlier check than running the -linter: it catches misspelled keywords, malformed `given` expressions, and structurally -invalid overrides before any document is linted. - ---- - -## The Ruleset Object - -A ruleset is a single JSON or YAML object. It **MUST** declare at least one of `extends`, -`rules`, or `overrides` — a ruleset that contributes nothing is invalid. - -| Field | Type | Description | -| --- | --- | --- | -| `description` | `string` | Human-readable description of what this ruleset governs. | -| `documentationUrl` | `string` (URL) | Documentation for the ruleset as a whole. Used as the fallback documentation link for any rule that does not declare its own. | -| `formats` | [Formats](#formats) | Document formats the ruleset applies to. Default for every rule that does not declare its own. When omitted, rules apply to every document. | -| `extends` | [Extends](#extends) | Other rulesets this one inherits from. | -| `rules` | `object` | Map of rule name to [Rule Object](#the-rule-object). | -| `aliases` | [Aliases](#aliases) | Named, reusable path expressions. | -| `overrides` | [Overrides](#overrides) | Per-file and per-location adjustments. | -| `parserOptions` | [ParserOptions](#parseroptions) | Severities for problems in the document itself, before rules run. | -| `functions` | `string[]` | Names of [custom functions](#custom-functions) to load. Serialized rulesets only. | -| `functionsDir` | `string` | Directory custom functions load from. Defaults to `functions`. | -| `x-*` | any | [Extension properties](#extension-properties). | - -No other properties are permitted at the root. - -A rule name is the stable identifier reported in lint results and used to reference the rule -from a consumer's `extends` or `overrides`. Rule names are the ruleset's public API: renaming -one silently breaks every downstream ruleset that disables or re-severities it. - -```yaml -description: Acme API style guide -documentationUrl: https://engineering.acme.com/api/governance -extends: [[spectral:oas, recommended]] -formats: [oas3] -rules: - acme-operation-summary: - description: Every operation needs a summary. - severity: error - given: $.paths[*][get,put,post,delete,options,head,patch,trace] - then: - field: summary - function: truthy -``` - ---- - -## Rules - -A rule is the unit of governance: **where to look** in a document, and **what must hold** -there. - -### The Rule Object - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `given` | [Given](#given-and-path-expressions) | ✔ | The part or parts of the document the rule applies to. | -| `then` | [Then](#then-and-functions) | ✔ | The assertion, or assertions, applied to each matched node. | -| `description` | `string` | | What the rule requires, in prose. | -| `message` | `string` | | Template for the reported message. See [`message`](#message). | -| `severity` | [Severity](#severity) | | How results are reported. Defaults to `warn`. | -| `formats` | [Formats](#formats) | | Formats this rule applies to. Overrides the ruleset-level `formats`. | -| `documentationUrl` | `string` (URL) | | Where to read about the rule and how to satisfy it. | -| `recommended` | `boolean` | | Whether the rule is on when a consumer extends the ruleset in `recommended` mode. Defaults to `true`. | -| `resolved` | `boolean` | | Whether the rule sees the document with `$ref`s resolved. Defaults to `true`. | -| `tags` | `string[]` | | Free-form labels for grouping and filtering. | -| `type` | `"style"` \| `"validation"` | | Coarse classification. `validation` asserts correctness, `style` asserts convention. | -| `extensions` | `object` | | Arbitrary consumer-defined metadata, ignored by the engine. | -| `x-*` | any | | [Extension properties](#extension-properties). | - -No other properties are permitted on a rule. - -`resolved` deserves a note. By default a rule runs against the document *after* `$ref`s have -been resolved, which is what you want when asserting on the effective shape of an API. Set -`resolved: false` when the rule is about references themselves — their form, their targets, -whether they are used at all — because in the resolved document those references no longer -exist. - -### `given` and path expressions - -`given` selects the nodes a rule runs against. It is either a single path expression or a -non-empty array of them. - -A **path expression** is a string that is one of: - -- a **JSONPath expression**, beginning with `$` — for example `$.paths[*]` or - `$..parameters[?(@.in === 'header')].name`; -- an **alias reference**, beginning with `#` and naming an [alias](#aliases), optionally - followed by a JSONPath subset — for example `#Info.description`. - -Anything else is invalid. A bare property name such as `paths` is a common authoring mistake -and is rejected by the schema. - -```yaml -given: - - $.info - - "#OperationObject" -``` - -### `then` and functions - -`then` is one function invocation, or an array of them all applied to every node `given` -matched. - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `function` | `string` | ✔ | Name of a core function or of a [custom function](#custom-functions). | -| `field` | `string` | | Narrow the assertion to one property of the matched node rather than the node itself. The special value `@key` targets the node's own key. | -| `functionOptions` | `object` | | Options passed to the function. Its accepted shape is defined by the function. | - -The reference implementation, [openlint/openlint](https://github.com/openlint/openlint), ships these core functions: - -| Function | Asserts that the target… | -| --- | --- | -| `truthy` | is present and not `false`, `""`, `0`, or `null`. | -| `falsy` | is absent, or one of `false`, `""`, `0`, or `null`. | -| `defined` | is present. | -| `undefined` | is absent. | -| `pattern` | matches (`match`) and/or does not match (`notMatch`) a regular expression. | -| `casing` | uses a given casing convention (`flat`, `camel`, `pascal`, `kebab`, `cobol`, `snake`, `macro`). | -| `length` | has a `min`/`max` length, or numeric value within bounds. | -| `alphabetical` | is sorted, optionally `keyedBy` a property. | -| `enumeration` | is one of a set of `values`. | -| `schema` | conforms to a supplied JSON Schema. | -| `xor` | has exactly one of a set of `properties`. | -| `or` | has at least one of a set of `properties`. | -| `unreferencedReusableObject` | (as a reusable-object container) has no members that are unreferenced within the documents being linted. Members referenced only by documents outside the lint run are reported as unreferenced. | - -`truthy` and `falsy` follow ECMAScript truthiness: empty arrays and empty objects are -**truthy**. This is stated explicitly because implementations in other languages may differ. - -```yaml -then: - - field: summary - function: truthy - - field: summary - function: length - functionOptions: - max: 120 -``` - -### `severity` - -How a rule's results are reported, and whether the rule runs at all. - -| Name | Numeric | -| --- | --- | -| `error` | `0` | -| `warn` | `1` | -| `info` | `2` | -| `hint` | `3` | -| `off` | `-1` | - -Both spellings are accepted wherever a severity is expected. When a rule omits `severity`, it -defaults to `warn`. - -### `formats` - -A **format** is a predicate that decides whether a given document is of a particular kind. -Scoping a rule to a format means the rule only runs against matching documents — so one -ruleset can govern an OpenAPI 3.1 description and an AsyncAPI 2.6 description without either -set of rules firing on the wrong one. - -`formats` is an array of format identifiers. The reference implementation registers: - -| Family | Identifiers | -| --- | --- | -| OpenAPI | `oas2`, `oas3`, `oas3_0`, `oas3_1`, `oas3_2` | -| AsyncAPI | `asyncapi2`, `aas2`, `aas2_0`–`aas2_6`, `aas3`, `aas3_0`, `aas3_1` | -| Arazzo | `arazzo1_0` | -| JSON Schema | `jsonSchema`, `jsonSchemaLoose`, `jsonSchemaDraft4`, `jsonSchemaDraft6`, `jsonSchemaDraft7`, `jsonSchemaDraft2019_09`, `jsonSchemaDraft2020_12` | - -The schema lists these as known values for editor completion but accepts any string, so -rulesets targeting formats added later — or registered by a host application — remain valid. - -A rule's `formats` overrides the ruleset's. Omitting both means the rule runs against every -document. - -### `message` - -Overrides the message reported for a result. Five placeholders are substituted: - -| Placeholder | Resolves to | -| --- | --- | -| `{{error}}` | The message the function produced. | -| `{{description}}` | The rule's `description`. | -| `{{path}}` | The full path to the offending value. | -| `{{property}}` | The key or index of the offending value. | -| `{{value}}` | The offending value itself. | - -```yaml -message: "{{description}} — `{{property}}` was {{value}}" -``` - -### Rule shorthands - -Inside `rules`, a value that is not an object adjusts a rule inherited through -[`extends`](#extends) rather than defining a new one: - -- a **severity name** (`error`, `warn`, `info`, `hint`, `off`) re-severities the rule; -- `false` disables the rule without changing its severity; -- `true` enables the rule without changing its severity, so it keeps whatever severity it - currently has, including one raised by an earlier ruleset. - -A rule therefore has an **enabled** state that is separate from its **severity**. Adjusting a -rule that the ruleset neither defines nor inherits is an error. - -```yaml -extends: spectral:oas -rules: - operation-tags: error # raise it - info-contact: false # switch it off - oas3-schema: true # switch it back on, at its current severity -``` - -Note that the numeric severities are **not** accepted in this position; only the names are. - ---- - -## `extends` - -Inherit from one or more other rulesets. An entry is a built-in identifier -(`spectral:oas`, `spectral:asyncapi`, `spectral:arazzo`), a file path, an npm module -specifier, or a URL. OpenLint-named built-ins such as `openlint:openapi` are proposed in -[discussion #6](https://github.com/orgs/openlint/discussions/6) and not yet part of the format. - -```yaml -extends: spectral:oas -``` - -An entry may instead be a two-element tuple pairing a ruleset with the set of its rules to -enable: - -| Mode | Enables | -| --- | --- | -| `recommended` | Only rules with `recommended: true`. The default when a bare string is used. | -| `all` | Every rule in the ruleset. | -| `off` | No rules — inherit the definitions without turning any of them on, then enable the ones you want by name. | - -```yaml -extends: - - [spectral:oas, all] - - [./team-baseline.yaml, "off"] - - https://example.com/rulesets/security.yaml -``` - -`off` is the mode that makes a large inherited ruleset adoptable: take the definitions, start -from silence, and switch rules on deliberately. - ---- - -## `aliases` - -Complex JSONPath expressions repeated across rules are the main source of drift in a large -ruleset. An alias names one, and rules reference it from `given` with a leading `#`. - -Alias names must match `^[A-Za-z][A-Za-z0-9_-]*$`. - -A **simple alias** is an array of path expressions — note that it is always an array, never a -bare string: - -```yaml -aliases: - Info: - - $..info - InfoDescription: - - "#Info.description" - PathItemObject: - - $.paths[*] - OperationObject: - - "#PathItemObject[get,put,post,delete,options,head,patch,trace]" -``` - -Aliases compose: `InfoDescription` and `OperationObject` above are defined in terms of other -aliases. - -A **scoped alias** resolves to different expressions depending on the format of the document -being linted, which is how one rule can govern structurally different specs: - -```yaml -aliases: - ParameterObject: - description: Every Parameter Object, wherever this spec version keeps them. - targets: - - formats: [oas2] - given: - - $.parameters[*] - - formats: [oas3] - given: - - $.components.parameters[*] -``` - -Each target requires both `formats` and `given`, and `given` in a target is always an array. - -Aliases may be declared at the root of the ruleset or within an [override](#overrides). - -An alias that refers to itself, directly or through other aliases, is an error. So is a `#` -reference to an alias that isn't defined. - -A file-scoped override's aliases are merged with the ruleset's aliases **by name**: an -override's alias replaces the same-named alias entirely rather than adding to it. -Pointer-scoped overrides cannot declare aliases. - ---- - -## `overrides` - -Overrides apply adjustments to a subset of the documents being linted, matched by glob. They -are an ordered array — later matching entries win over earlier ones, and every override wins -over the base ruleset. The array must not be empty. - -`files` entries are glob patterns, matched as the reference implementation's glob library -([minimatch](https://github.com/isaacs/minimatch)) matches them. A precise glob syntax is future -work. - -There are two forms, distinguished by whether the `files` globs carry a JSON Pointer fragment. - -**File-scoped** — globs without a `#` fragment. May change `rules`, `extends`, `formats`, -`parserOptions`, and `aliases`, and must contain at least `extends` or `rules`: - -```yaml -overrides: - - files: ["legacy/**/*.yaml"] - rules: - acme-operation-summary: warn - - files: ["v3/**/*.yaml"] - formats: [oas3_1] - extends: [[./strict.yaml, all]] -``` - -**Pointer-scoped** — every glob carries a `#` fragment naming a location inside the document. -May change **only** rule severities, and must contain `rules`: - -```yaml -overrides: - - files: ["api.yaml#/paths/~1legacy~1users"] - rules: - acme-operation-summary: "off" -``` - -This asymmetry is deliberate: a waiver aimed at one path in one file can silence a rule there, -but it cannot quietly redefine what the rule means. - ---- - -## `parserOptions` - -Severities for problems found while parsing the document, before any rule runs. - -| Field | Description | Default | -| --- | --- | --- | -| `duplicateKeys` | Duplicate keys in the document. | `error` | -| `incompatibleValues` | YAML values that have no JSON representation. | `error` | - -```yaml -parserOptions: - duplicateKeys: error - incompatibleValues: warn -``` - ---- - -## Custom functions - -When the core functions cannot express an assertion, a serialized ruleset can name custom -functions to load: - -```yaml -functions: - - dictionary -functionsDir: ./my-functions # optional; defaults to "functions" -rules: - no-evil-words: - message: "{{error}}" - given: - - $.info.title - - $.info.description - then: - function: dictionary -``` - -Each name in `functions` resolves to `/.js`, relative to the ruleset file. - -Two caveats worth stating plainly. First, custom functions are the point at which a ruleset -stops being portable data and starts being code — a ruleset that names them cannot be fully -understood, or safely consumed, from the ruleset document alone. Second, in the reference -implementation that code is **not** sandboxed; extending an untrusted remote ruleset that -carries functions is arbitrary code execution. Prefer the core functions where they suffice. - ---- - -## Extension properties - -Properties beginning with `x-` are permitted at the root of the ruleset and on any rule, and -are ignored by the engine. Use them to carry ownership, tickets, review dates, or anything -else your own tooling needs: - -```yaml -x-owner: platform-team -rules: - acme-operation-summary: - x-jira: GOV-142 - given: $.paths[*][*] - then: - field: summary - function: truthy -``` - -Rules additionally have an `extensions` object for the same purpose. - ---- - -## Conformance notes - -This specification documents the format as the reference implementation actually behaves, -including where that behavior is looser than a clean design would be. Four points where the -schema deliberately matches the implementation rather than tightening it: - -1. **`then` accepts unknown properties.** A misspelled `functionOption` (singular) is not - rejected; it is silently ignored, and the function runs with its defaults. The schema - permits this so that valid rulesets are not rejected, but authors should treat any - unrecognized key inside `then` as a bug. -2. **Format identifiers are open.** Any string is accepted, so a typo like `oas31` validates - and simply matches no document — meaning the rule never fires. Rules that never fire look - identical to rules that always pass. The known identifiers are listed in the schema for - editor completion; check them. -3. **Rule shorthands are name-only.** `severity: 1` is valid inside a rule definition, but - `my-rule: 1` inside `rules` is not — the shorthand position takes only the severity names - and booleans. The asymmetry is inherited, not intended. -4. **There is no self-describing key.** The root permits only the properties above plus - `x-*`, so a ruleset cannot point at its own schema with a `$schema` property the way most - JSON formats can. Schema binding has to happen outside the document, as shown in - [Using the schema](#using-the-schema). A format specified from the start would have - reserved that key. - -Further limits on scope: - -- The schema describes the **serialized (JSON/YAML)** form. Rulesets authored in JavaScript - can inline function implementations and whole ruleset objects, which have no JSON - representation and are therefore out of scope. -- **Format detection is implementation-defined.** This document lists the format identifiers but - not the rules an implementation uses to decide that a document matches one. Specifying - detection for each format is future work. -- `given` values are checked for **shape** — that they are JSONPath expressions or alias - references — not parsed as JSONPath. A syntactically invalid JSONPath will pass the schema - and fail at lint time. - ---- - -## Runtime behavior (to be specified) - -The following are currently **implementation-defined**, and will be specified: - -- **Resolution.** How `extends` entries and custom functions are found and loaded: file paths, - npm module specifiers and URLs, and what each is resolved relative to. -- **Inheritance and merge order.** Which definition wins when several `extends` entries and the - declaring ruleset define or adjust the same rule, and how the `recommended`, `all` and `off` - modes interact with rules adjusted by name afterwards. -- **Cycles.** Whether a ruleset that extends itself, directly or indirectly, is an error. - -## Versioning - -The schema lives under `schema/v1/` and is versioned independently of the reference -implementation. [Discussion #13](https://github.com/orgs/openlint/discussions/13) proposes that -each release of the specification live at its own URL that never changes, such as -`spec.openlint.org/1.0.0/`, with work in progress at `spec.openlint.org/draft/`. Within `v1`, changes will be additive and backward-compatible: new optional -properties, newly registered format identifiers, clarified descriptions. Anything that would -reject a previously valid ruleset gets a new directory and a new `$id`. - ---- - -## Provenance and license - -The format described here originates in -[Spectral](https://github.com/stoplightio/spectral) by Stoplight, licensed Apache-2.0. This -specification and schema were derived from the reference implementation's own internal -validation meta-schemas — `ruleset.schema.json`, `rule.schema.json`, `shared.json`, -`json-extensions.json`, and `js-extensions.json` in `packages/core/src/ruleset/meta/` — and -from its guides, then re-expressed as a single self-contained JSON Schema draft 2020-12 -document with the implementation's runtime hooks and validator-specific error-message -extensions removed. - -The schema is verified against the real-world rulesets published across the API Commons, -which it accepts, and against a suite of malformed rulesets, which it rejects. - -This document and schema were first drafted by the OpenLint community, before it took its name, -in [api-commons](https://github.com/api-commons/spotlight-spec), and moved here. - -This repository is licensed [Apache-2.0](LICENSE), the same as Spectral. diff --git a/assets/openlint-icon.svg b/assets/openlint-icon.svg new file mode 100644 index 0000000..70d1c56 --- /dev/null +++ b/assets/openlint-icon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..83f90ed --- /dev/null +++ b/package-lock.json @@ -0,0 +1,102 @@ +{ + "name": "@openlint/spec", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@openlint/spec", + "license": "Apache-2.0", + "devDependencies": { + "ajv": "8.20.0", + "ajv-formats": "2.1.1", + "yaml": "1.10.3" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..faca31a --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "@openlint/spec", + "private": true, + "description": "Checks the ruleset fixtures of the OpenLint ruleset specification against its JSON Schema.", + "license": "Apache-2.0", + "devDependencies": { + "ajv": "8.20.0", + "ajv-formats": "2.1.1", + "yaml": "1.10.3" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..31417ef --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[tool.ruff] +line-length = 140 +target-version = "py310" + +[tool.ruff.lint] +# E402: the tools import build.py after adjusting sys.path. +ignore = ["E402"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..60027aa --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +bikeshed==7.2.0 +ruff==0.17.1 diff --git a/schema/registry.schema.json b/schema/registry.schema.json new file mode 100644 index 0000000..9f0183a --- /dev/null +++ b/schema/registry.schema.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OpenLint specification registry", + "description": "The requirements, conformance classes, and conformance tests of one version of the OpenLint Ruleset Specification, as published next to it as requirements.json.", + "type": "object", + "required": ["specification", "version", "line", "label", "generated", "classes", "requirements", "tests"], + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "specification": { "type": "string" }, + "version": { "type": "string", "description": "`draft` or a semantic version." }, + "line": { "type": "integer", "description": "The major version the document belongs to. A draft is the working draft of this line." }, + "label": { "type": "string" }, + "generated": { "type": "string", "description": "ISO date. For a release, the release date." }, + "classes": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "name", "target", "depends", "members", "tests", "text"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "pattern": "^/conf/[a-z0-9-]+$" }, + "name": { "type": "string" }, + "target": { "enum": ["ruleset", "linter"] }, + "depends": { "type": "array", "items": { "type": "string" } }, + "members": { "type": "array", "items": { "type": "string" } }, + "tests": { "type": "array", "items": { "type": "string" } }, + "text": { "type": "string" } + } + } + }, + "requirements": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "kind", "class", "target", "status", "since", "section", "tests", "text"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "pattern": "^/(req|rec|per)(/[a-z0-9-]+){2,}$" }, + "kind": { "enum": ["requirement", "recommendation", "permission"] }, + "class": { "type": "string" }, + "target": { "enum": ["ruleset", "linter"] }, + "status": { "enum": ["current", "proposed", "removed"] }, + "since": { "type": "string" }, + "removed": { "type": "string", "description": "The version that removed it; present when status is removed." }, + "section": { "type": "string" }, + "tests": { "type": "array", "items": { "type": "string" } }, + "text": { "type": "string" } + } + } + }, + "tests": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "for", "class", "target", "purpose", "dir", "ruleset", "document", "files", "expect"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "pattern": "^/conf(/[a-z0-9-]+){2}$" }, + "for": { "type": "array", "items": { "type": "string" } }, + "class": { "type": "string" }, + "target": { "enum": ["ruleset", "linter"] }, + "purpose": { "type": "string" }, + "dir": { "type": "string" }, + "ruleset": { "type": "string" }, + "document": { "type": ["string", "null"] }, + "files": { "type": "array", "items": { "type": "string" } }, + "expect": { + "type": "object", + "additionalProperties": false, + "properties": { + "ruleset": { "enum": ["valid", "invalid"] }, + "rejected": { "const": true }, + "results": { "type": "array", "items": { "type": "object" } }, + "ordered": { "const": true, "description": "The results have to come in the listed order." } + } + } + } + } + } + } +} diff --git a/tools/build.py b/tools/build.py new file mode 100755 index 0000000..51ebdb5 --- /dev/null +++ b/tools/build.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Build every version of the specification into site/. + +Usage: + tools/build.py build all versions + tools/build.py v1 build one version: a draft (v1) or a release (1.0.0) + tools/build.py --check run every check, Bikeshed included, and write nothing + +Each major version has a working draft in v/: index.bs, its boilerplate +includes, formats.json, the ruleset schema as ruleset.schema.json, and a +conformance/ directory with one test per subdirectory. A release is a snapshot +of those under releases//. A draft is published at /v/draft/, a +release at //. /v/ is a copy of the latest release of that major +version; until it has one, /v/ruleset.schema.json is the draft's schema. + +The document declares conformance classes, requirements, recommendations, and +permissions as blocks: + +
+
+
+
+ +Tests are data: conformance///test.json names the requirements +it covers, a purpose, fixture files, and the expected outcome. + +Before Bikeshed runs, this script enforces what a document can check +mechanically: + + * every block has a permanent, well-formed ID + * every requirement belongs to one conformance class, which has one target + * a requirement uses MUST, a recommendation SHOULD, a permission MAY + * BCP 14 keywords in capitals appear only inside those blocks + * every requirement has at least one test; every test covers requirements + that exist and belong to the same class; every fixture file exists + * a ruleset requirement has a valid and an invalid test; a linter + requirement has a test that expects results; a permission has a test + that shows the permitted thing is accepted; a recommendation has none. + Tests are listed with what conforms first: valid or results, then + invalid, no results, or rejected + * an example block that names a fixture (data-fixture) is identical to it + * fixture names stay inside their test directory and are plain files; a + test.json has the field types the registry schema describes + * an include names a file next to the draft or one the build generates + * formats.json and the schema's Format enum list the same identifiers + * emphasis is written *text*, not _text_, and a discussion is always linked + * ids are unique, classes depend only on declared classes, blocks are closed + and do not nest, and a removed requirement names the removing version + * the issues index is linked only while issues remain, since Bikeshed + generates it only then + * identifiers are permanent: every requirement a release lists is still in + the draft of its major version and of every later one, current or removed with + data-removed=; a text change to a released requirement is + reported for the editor to judge + * a change made in one draft is not missed in another: every requirement of + a draft is in each later draft, and one that both list as current has the + same kind, class, status, and source text in both; a test that differs + between them, or that the later draft lacks, is reported + * requirements.json matches schema/registry.schema.json + * the ruleset schema's $id is the URL it is published at, so each release's + schema has an identity of its own + * every property in a table generated from the ruleset schema has a + description, and no description uses a BCP 14 keyword + * implementations are named only in issues and the provenance appendix, + never in requirements, informative text, schema descriptions, or test + purposes, so a release states every rule as its own + * a test's expectation matches its class's target + * the parts of the document hold one target each: the ruleset format holds + ruleset requirements, Documents, Linting, and Functions linter ones + +It then expands the blocks with their visible headers, replaces each +
with a property table generated +from the ruleset schema, generates the requirements index, the formats table, +the conformance test appendix, and the list of requirements the schema cannot +express, and writes a machine-readable registry (requirements.json) with its +schema next to each built document. The site gets a versions index, an +index.html in every published conformance/ directory, and /v/ as a copy +of each major version's latest release. +Every conformance/ directory it publishes gets an index.html, because GitHub +Pages lists no directories. +""" + +from __future__ import annotations + +import shutil +import sys +import tempfile +from pathlib import Path + +from specbuild.checks import check_across_versions +from specbuild.config import ROOT, SITE, SpecError, schema_file +from specbuild.site import build_version, latest_releases, remove_tree, write_versions_index +from specbuild.versions import discover_versions, parse_version + + +def main(argv: list[str]) -> int: + options = [a for a in argv if a.startswith("-")] + names = [a for a in argv if not a.startswith("-")] + if set(options) - {"--check"} or len(names) > 1: + print(__doc__, file=sys.stderr) + return 2 + check_only = "--check" in options + only = names[0] if names else None + try: + all_versions = [parse_version(name, path) for name, path in discover_versions(None)] + versions = [v for v in all_versions if only in (None, v.name)] + if not versions: + raise SpecError(f"no version named {only!r}") + cross, notices = check_across_versions(all_versions) + for n in notices: + print(f"notice: {n}") + if cross: + raise SpecError("\n".join(cross)) + if check_only: + with tempfile.TemporaryDirectory() as scratch: + for v in versions: + build_version(v, site=Path(scratch) / "site", build=Path(scratch) / "build") + for v in versions: + n_req = sum(1 for r in v.requirements if r.kind == "req") + print(f"{v.name}: {len(v.classes)} classes, {n_req} requirements, " + f"{len(v.requirements) - n_req} recommendations and permissions, {len(v.tests)} tests, checks passed") + if check_only: + return 0 + if not only: + remove_tree(SITE) + SITE.mkdir(exist_ok=True) + shutil.copytree(ROOT / "assets", SITE / "assets", dirs_exist_ok=True) + for v in versions: + print(f"built {build_version(v).relative_to(ROOT)}") + if not only: + print(f"wrote {write_versions_index(all_versions).relative_to(ROOT)}") + newest = latest_releases(all_versions) + for line in sorted({v.major for v in all_versions}): + alias = SITE / f"v{line}" + if line in newest: + shutil.copytree(SITE / newest[line].site_path, alias, dirs_exist_ok=True) + print(f"wrote {alias.relative_to(ROOT)}/ from site/{newest[line].site_path}/, the latest {line}.x release") + else: + draft = next(v for v in all_versions if v.is_draft and v.major == line) + shutil.copy(schema_file(draft.source_dir), alias / "ruleset.schema.json") + print(f"wrote {alias.relative_to(ROOT)}/ruleset.schema.json from {draft.name}/, which has no release yet") + return 0 + except SpecError as e: + print(f"error:\n{e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/conformance.py b/tools/conformance.py new file mode 100755 index 0000000..df034de --- /dev/null +++ b/tools/conformance.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Run a version's conformance tests against a linter. + +Usage: + tools/conformance.py v1 --linter 'openlint lint -r {ruleset} -f json {document}' + tools/conformance.py v1 --linter '...' --only /conf/given + +The linter command is a template. {ruleset} and {document} are replaced with +fixture paths, and the command runs in the test's directory so relative +references in fixtures resolve. The contract is small: + + * print the results for the document as a JSON array on stdout, in the + result model of the specification (code, path, severity, message, range, + source), with an empty array when there are none; + * when the ruleset cannot be loaded, print nothing parseable as JSON on + stdout and exit with a nonzero status. + +A test of a requirement marked data-status=proposed states behavior ahead of +the implementations. It runs and is reported, but does not fail the run. + +A linter that breaks the contract fails the test whatever it expects: it runs +longer than TIMEOUT seconds, or it exits with status 0 without printing results. +Before the tests, the runner lints a ruleset that every linter has to load; a +linter that prints no results for it is not run against the tests at all. A +relative path in the command is taken from the directory the runner starts in. + +A test for a ruleset requirement runs the linter on an empty document and +passes when the linter loads (valid) or rejects (invalid) the ruleset. A test +for a linter requirement compares the results with the expectation: the same +number of results, each expected entry matching one actual result on the +fields it names, in any order unless the expectation sets "ordered". In an +expected entry, "has" lists fields that must be present and "${document}" +stands for the absolute path of the document fixture. +""" + +from __future__ import annotations + +import argparse +import json +import shlex +import subprocess +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from specbuild.config import SpecError +from specbuild.fixtures import fixture_path +from specbuild.versions import discover_versions, parse_version + +TIMEOUT = 120 +PREFLIGHT_RULESET = "rules:\n preflight:\n given: $\n then:\n function: truthy\n" + + +def linter_command(template: str) -> list[str]: + """The template split into arguments, with a relative path that exists made absolute, since tests run elsewhere.""" + parts = shlex.split(template) + return [str(Path(p).resolve()) if "{" not in p and not Path(p).is_absolute() and Path(p).exists() else p for p in parts] + + +def run_linter(cmd: list[str], ruleset: Path, document: Path, cwd: Path) -> tuple[str, list | None, str]: + """What the linter did: "results" with the results, "rejected", or "broken" when it broke the contract.""" + cmd = [part.replace("{ruleset}", str(ruleset)).replace("{document}", str(document)) for part in cmd] + try: + proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=TIMEOUT, check=False) + except subprocess.TimeoutExpired: + return "broken", None, f"the linter ran longer than {TIMEOUT} seconds" + except OSError as e: + return "broken", None, f"the linter could not be started: {e}" + stdout = proc.stdout.lstrip() + if stdout.startswith("["): + try: + results, _ = json.JSONDecoder().raw_decode(stdout) + if isinstance(results, list): + return "results", results, proc.stderr.strip() + except json.JSONDecodeError: + pass + detail = proc.stderr.strip() or proc.stdout.strip() + if proc.returncode == 0: + return "broken", None, f"the linter exited with status 0 without printing results as JSON: {detail[:200]}" + return "rejected", None, detail or f"exit {proc.returncode}" + + +def matches(expected: dict, actual: dict, document: Path) -> bool: + """Partial match: every field the expectation names must agree, nested objects recursively.""" + if not isinstance(actual, dict): + return False + for key, value in expected.items(): + if key == "has": + if not all(k in actual for k in value): + return False + continue + if isinstance(value, dict): + if not matches(value, actual.get(key), document): + return False + continue + if isinstance(value, str): + value = value.replace("${document}", str(document)) + if actual.get(key) != value: + return False + return True + + +def compare(expected: list[dict], actual: list[dict], document: Path, ordered: bool = False) -> str | None: + if len(expected) != len(actual): + return f"expected {len(expected)} results, got {len(actual)}: {summarize(actual)}" + if ordered: + for i, (e, a) in enumerate(zip(expected, actual, strict=True)): + if not matches(e, a, document): + return f"result {i} does not match {json.dumps(e)}; got {summarize(actual)}" + return None + remaining = list(actual) + for e in expected: + hit = next((a for a in remaining if matches(e, a, document)), None) + if hit is None: + return f"no result matches {json.dumps(e)}; got {summarize(actual)}" + remaining.remove(hit) + return None + + +def summarize(results: list[dict]) -> str: + return json.dumps([{k: r.get(k) for k in ("code", "path", "severity") if k in r} for r in results]) + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Run a version's conformance tests against a linter.") + parser.add_argument("version", help="a draft such as v1, or a release such as 1.0.0") + parser.add_argument("--linter", required=True, metavar="COMMAND", help="the linter command, with {ruleset} and {document}") + parser.add_argument("--only", default="", metavar="PREFIX", help="run only the tests whose id starts with PREFIX") + args = parser.parse_args(argv) + version, only = args.version, args.only + cmd = linter_command(args.linter) + try: + [(_, source_dir)] = discover_versions(version) + parsed = parse_version(version, source_dir) + except SpecError as e: + print(f"error: {version} does not pass its checks:\n{e}", file=sys.stderr) + return 1 + reqs = {r.id: r for r in parsed.requirements} + failures: list[tuple[str, str]] = [] + passed = 0 + ran: set[str] = set() + proposed: list[tuple[str, bool]] = [] + with tempfile.TemporaryDirectory() as tmp: + empty = Path(tmp) / "empty.yaml" + empty.write_text("{}\n") + preflight = Path(tmp) / "preflight.yaml" + preflight.write_text(PREFLIGHT_RULESET) + outcome, _, detail = run_linter(cmd, preflight, empty, Path(tmp)) + if outcome != "results": + print("error: the linter printed no results for a ruleset every linter has to load:\n " + "\n ".join(detail.splitlines()[:6]), file=sys.stderr) + return 2 + for test in parsed.tests: + if only and not test.id.startswith(only): + continue + ruleset = fixture_path(test.dir, test.ruleset) + document = fixture_path(test.dir, test.document) if test.document else empty + ran.add(test.id) + outcome, results, detail = run_linter(cmd, ruleset, document, test.dir) + e = test.expect + if outcome == "broken": + ok, reason = False, detail + elif "ruleset" in e: + loaded = outcome == "results" + ok = loaded == (e["ruleset"] == "valid") + reason = "" if ok else ("linter loaded a ruleset expected to be invalid" if loaded + else f"linter rejected a ruleset expected to be valid: {detail}") + elif e.get("rejected"): + ok = outcome == "rejected" + reason = "" if ok else f"linter accepted the ruleset and produced {summarize(results)}" + elif outcome == "rejected": + ok, reason = False, f"linter rejected the ruleset: {detail}" + else: + reason = compare(e.get("results", []), results, document, ordered=bool(e.get("ordered"))) or "" + ok = not reason + if any(reqs[r].status == "proposed" for r in test.covers): + proposed.append((test.id, ok)) + print(f"{'pass' if ok else 'fail'} {test.id} (proposed requirement, not counted)") + if not ok: + print(f" {reason}") + continue + mark = "pass" if ok else "FAIL" + print(f"{mark} {test.id}") + if ok: + passed += 1 + else: + print(f" {reason}") + failures.append((test.id, reason)) + + total = passed + len(failures) + if not total and not proposed: + print(f"error: no test matches {only!r}", file=sys.stderr) + return 2 + print(f"\n{passed} of {total} tests passed") + if proposed: + print(f"{sum(ok for _, ok in proposed)} of {len(proposed)} tests of proposed requirements pass; they do not count") + failed_reqs = sorted({r for t, _ in failures for r in next(x for x in parsed.tests if x.id == t).covers}) + if failed_reqs: + print("requirements with a failing test:") + for rid in failed_reqs: + print(f" {rid} ({reqs[rid].klass})") + failed = {t for t, _ in failures} + classes_ok = [c.id for c in parsed.classes if c.tests and set(c.tests) <= ran and not set(c.tests) & failed + and not any(reqs[r].status == "proposed" for r in c.members)] + if classes_ok: + print("conformance classes passed in full: " + ", ".join(classes_ok)) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/release.py b/tools/release.py new file mode 100755 index 0000000..064930b --- /dev/null +++ b/tools/release.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Cut an immutable release from the working draft of its major version. + +Usage: + tools/release.py 1.0.0 + +A release is cut from the draft of its major version: 1.1.0 from v1/, 2.0.0 +from v2/. What it does, in order: + + 1. Runs every check of the build, Bikeshed and the comparison across + versions included. Refuses when any requirement is still marked + data-status=proposed, or when any Issue block remains: a release carries only settled requirements and answered + questions. An issue that is an accepted limitation becomes a Note first. + Once the last issue is gone, the draft can no longer link to the issues + index, which Bikeshed generates only while issues remain. + 2. Stamps data-since="" on every requirement in v/index.bs + that has no data-since yet, and on the same requirements in every later + draft. The stamp stays in the drafts: from now on those requirements are + "since " everywhere. + 3. Snapshots index.bs, the includes, formats.json, conformance/, and the + schema into releases// and rewrites the copy's metadata so that it + describes itself as that release: label, fixed date, its own URL, no + working-draft warning, and a pointer to the draft. The schema's $id + becomes the release's URL. + 4. Points "Previous Version" and "Latest release" at the new release, in the + draft of its major version and in every later draft whose major version has no release yet. + +The release directory is never edited again. A fix becomes a new patch release. +""" + +from __future__ import annotations + +import re +import shutil +import sys +import tempfile +from datetime import date +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from specbuild.checks import check_across_versions, with_id +from specbuild.config import BASE_URL, RELEASES, ROOT, SpecError +from specbuild.scan import METADATA_OPEN, open_issues, parse_attrs +from specbuild.site import build_version +from specbuild.versions import discover_versions, parse_version + +REQ_OPEN = re.compile(r'^(\s*[^>]*)>\s*$') + + +def stamp_since(source: str, version: str, only: set[str] | None = None) -> tuple[str, set[str]]: + """Stamp every settled requirement, recommendation, and permission that has no data-since yet, or only those named.""" + stamped = set() + out = [] + for line in source.split("\n"): + m = REQ_OPEN.match(line) + attrs = parse_attrs(m.group("attrs")) if m else {} + bid = attrs.get("id", "") + if (m and "data-since" not in attrs and attrs.get("data-status") not in ("example", "proposed") + and (only is None or bid in only)): + line = f'{m.group(1)}{m.group("attrs")} data-since="{version}">' + stamped.add(bid) + out.append(line) + return "\n".join(out), stamped + + +def edit_metadata(source: str, set_keys: dict[str, str], drop_keys: set[str]) -> str: + """Rewrite the metadata block: replace or append set_keys, remove drop_keys.""" + lines = source.split("\n") + start = next((i for i, l in enumerate(lines) if METADATA_OPEN.match(l)), None) + end = next((i for i in range(start, len(lines)) if lines[i].startswith("
")), None) if start is not None else None + if end is None: + raise SpecError("index.bs has no complete " in stripped: + in_pre = False + continue + if in_fence or in_pre: + continue + if UNDERSCORE_EMPHASIS.search(re.sub(r"`[^`]*`", "", line)): + errors.append(f"{rel_path(source_dir, 'index.bs')}:{n}: _text_ is printed with its underscores; write *text* for emphasis") + return errors + + +DISCUSSION = re.compile(r'(?:)?discussion #(\d+)\b') + + +def check_discussion_links(source_dir: Path, source: str) -> list[str]: + """A discussion is always mentioned as a link, so a reader can follow it from any version.""" + errors = [] + for n, line in enumerate(source.split("\n"), start=1): + for m in DISCUSSION.finditer(line): + if m.group(1) != m.group(2): + errors.append(f"{rel_path(source_dir, 'index.bs')}:{n}: discussion #{m.group(2)} needs a link to " + f"https://github.com/orgs/openlint/discussions/{m.group(2)}") + return errors + + +def check_across_versions(versions: list[Version]) -> tuple[list[str], list[str]]: + """Identifiers are permanent, and a change made in one draft is not missed in a later one.""" + drafts = sorted((v for v in versions if v.is_draft), key=lambda v: v.major) + errors: list[str] = [] + notices: list[str] = [] + for rel in sorted((v for v in versions if not v.is_draft), key=lambda v: semver_key(v.name)): + for draft in (d for d in drafts if d.major >= rel.major): + current = {r.id: r for r in draft.requirements} + for r in rel.requirements: + d = current.get(r.id) + if d is None: + errors.append(f"{r.id} was released in {rel.name} but is gone from the {draft.name} draft; keep it with data-status=removed data-removed=") + continue + if d.kind != r.kind or d.target != r.target: + errors.append(f"{r.id} changed kind or target in the {draft.name} draft since {rel.name}; that is a new requirement with a new identifier") + if draft.major == rel.major and d.status != "removed" and d.text != r.text: + notices.append(f"{r.id}: text differs from {rel.name}; if what conforms changed, give it a new identifier and remove this one") + if draft.major == rel.major: + draft_tests = {t.id for t in draft.tests} + for t in rel.tests: + if t.id not in draft_tests: + notices.append(f"{t.id}: test released in {rel.name} is gone from the {draft.name} draft") + for earlier, later in itertools.pairwise(drafts): + errors.extend(compare_drafts(earlier, later, notices)) + return errors, notices + + +def compare_drafts(earlier: Version, later: Version, notices: list[str]) -> list[str]: + """The next draft lists every requirement of this one: removed stays removed, and current is the same unless removed there.""" + errors = [] + later_reqs = {r.id: r for r in later.requirements} + here = rel_path(earlier.source_dir, "index.bs") + there = rel_path(later.source_dir, "index.bs") + for r in earlier.requirements: + s = later_reqs.get(r.id) + if s is None: + errors.append(f"{here}:{r.line}: {r.id} is missing from the {later.name} draft; add it there too, " + f"or list it there with data-status=removed data-removed=") + elif s.status == "removed": + continue + elif r.status == "removed": + errors.append(f"{there}:{s.line}: {r.id} is removed in the {earlier.name} draft but current in {later.name}; " + f"a removed requirement stays removed in every later draft") + elif (r.kind, r.klass, r.status) != (s.kind, s.klass, s.status) or " ".join(r.body.split()) != " ".join(s.body.split()): + errors.append(f"{here}:{r.line} and {there}:{s.line}: {r.id} differs between the {earlier.name} and {later.name} drafts; " + f"make the same change in both, or give the {later.major}.x requirement a new identifier and remove this one there") + later_tests = {t.id: t for t in later.tests} + for t in earlier.tests: + u = later_tests.get(t.id) + if u is None: + notices.append(f"{t.id}: test in the {earlier.name} draft is not in {later.name}") + elif test_files(t) != test_files(u): + notices.append(f"{t.id}: test differs between the {earlier.name} and {later.name} drafts") + return errors diff --git a/tools/specbuild/config.py b/tools/specbuild/config.py new file mode 100644 index 0000000..2a30a82 --- /dev/null +++ b/tools/specbuild/config.py @@ -0,0 +1,59 @@ +"""Where the sources and the output live, and the error every check raises.""" + +from __future__ import annotations + +import re +import shutil +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + + +SITE = ROOT / "site" + + +BUILD = ROOT / ".build" + + +RELEASES = ROOT / "releases" + + +BASE_URL = "https://spec.openlint.org" + + +DRAFT_NAME = re.compile(r"v([1-9][0-9]*)") + + +SCHEMA_REQUIREMENT = "/req/ruleset/schema" + + +BIKESHED = shutil.which("bikeshed") or next((str(p) for p in (ROOT / ".venv/bin/bikeshed", Path.home() / ".local/bin/bikeshed") if p.exists()), "bikeshed") + + +def rel_path(source_dir: Path, name: str) -> str: + """A path for messages: relative to the repository root when the source is under it, as given otherwise.""" + try: + rel = source_dir.relative_to(ROOT) + except ValueError: + return str(source_dir / name) + return name if str(rel) == "." else f"{rel}/{name}" + + +def schema_file(source_dir: Path) -> Path: + return source_dir / "ruleset.schema.json" + + +def published_path(name: str) -> str: + """Where a version is published, relative to the site root: v1/draft for a draft, 1.0.0 for a release.""" + return f"{name}/draft" if DRAFT_NAME.fullmatch(name) else name + + +class SpecError(Exception): + pass + + +REGISTRY_SCHEMA = ROOT / "schema" / "registry.schema.json" + + +def semver_key(name: str) -> tuple: + return tuple(int(p) for p in name.split(".")) diff --git a/tools/specbuild/fixtures.py b/tools/specbuild/fixtures.py new file mode 100644 index 0000000..8edd8cf --- /dev/null +++ b/tools/specbuild/fixtures.py @@ -0,0 +1,102 @@ +"""Reading the conformance tests from their directories.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .config import rel_path +from .model import Test +from .scan import MENTION, TEST_ID + + +def fixture_path(test_dir: Path, name: str) -> Path: + """The file a fixture name refers to, which has to stay inside the test's directory.""" + if not isinstance(name, str) or not name or name.startswith(('/', '\\')) or '..' in Path(name).parts or ':' in name: + raise ValueError(f"fixture name {name!r} has to be a relative path inside the test directory") + path = test_dir / name + if not path.resolve().is_relative_to(test_dir.resolve()): + raise ValueError(f"fixture name {name!r} leaves the test directory") + return path + + +def load_tests(source_dir: Path) -> tuple[list[Test], list[str]]: + tests: list[Test] = [] + errors: list[str] = [] + conf_dir = source_dir / "conformance" + if not conf_dir.is_dir(): + return tests, [f"{rel_path(source_dir, 'conformance')}: directory missing"] + for spec_file in sorted(conf_dir.glob("*/*/test.json")): + test_dir = spec_file.parent + tid = f"/conf/{test_dir.parent.name}/{test_dir.name}" + loc = rel_path(source_dir, f"conformance/{test_dir.parent.name}/{test_dir.name}/test.json") + try: + data = json.loads(spec_file.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + errors.append(f"{loc}: {e}") + continue + if not TEST_ID.match(tid): + errors.append(f"{loc}: directory names do not form a test id of the form /conf//") + if not isinstance(data, dict): + errors.append(f"{loc}: test.json has to be an object") + continue + covers = data.get("for", []) + if isinstance(covers, str): + covers = [covers] + if not isinstance(covers, list) or not all(isinstance(c, str) for c in covers): + errors.append(f"{loc}: 'for' must be a requirement identifier or a list of them") + covers = [c for c in covers if isinstance(c, str)] if isinstance(covers, list) else [] + if not covers: + errors.append(f"{loc}: 'for' must name at least one requirement") + purpose = data.get("purpose", "") + if not isinstance(purpose, str): + errors.append(f"{loc}: 'purpose' must be a string") + purpose = "" + purpose = purpose.strip() + if not purpose: + errors.append(f"{loc}: 'purpose' is missing") + elif MENTION.search(purpose): + errors.append(f"{loc}: the purpose names an implementation; tests state what conforms") + ruleset = data.get("ruleset", "ruleset.yaml") + document = data.get("document") + files = data.get("files", []) + if not isinstance(files, list): + errors.append(f"{loc}: 'files' must be a list of fixture names") + files = [] + if not isinstance(ruleset, str): + errors.append(f"{loc}: 'ruleset' must be a fixture name") + ruleset = "ruleset.yaml" + if document is not None and not isinstance(document, str): + errors.append(f"{loc}: 'document' must be a fixture name") + document = None + if not all(isinstance(f, str) for f in files): + errors.append(f"{loc}: 'files' must be a list of fixture names") + files = [f for f in files if isinstance(f, str)] + for name in [ruleset, document, *files]: + if name is None: + continue + try: + exists = fixture_path(test_dir, name).is_file() + except ValueError as e: + errors.append(f"{loc}: {e}") + continue + if not exists: + errors.append(f"{loc}: fixture {name!r} does not exist") + for path in test_dir.rglob("*"): + if path.is_symlink(): + errors.append(f"{loc}: {path.relative_to(test_dir)} is a symbolic link; fixtures are plain files") + expect = data.get("expect") + if not isinstance(expect, dict) or not expect: + errors.append(f"{loc}: 'expect' is missing") + expect = {} + known = {"ruleset", "rejected", "results", "ordered"} + if not set(expect) <= known: + errors.append(f"{loc}: 'expect' keys must be among {sorted(known)}, got {sorted(expect)}") + if "ruleset" in expect and expect["ruleset"] not in ("valid", "invalid"): + errors.append(f"{loc}: expect.ruleset must be 'valid' or 'invalid'") + if "results" in expect and not (isinstance(expect["results"], list) and all(isinstance(r, dict) for r in expect["results"])): + errors.append(f"{loc}: expect.results must be a list of objects") + expect["results"] = [r for r in expect.get("results", []) if isinstance(r, dict)] if isinstance(expect.get("results"), list) else [] + tests.append(Test(id=tid, covers=covers, purpose=purpose, ruleset=ruleset, document=document, + files=files, expect=expect, dir=test_dir)) + return tests, errors diff --git a/tools/specbuild/model.py b/tools/specbuild/model.py new file mode 100644 index 0000000..8f35141 --- /dev/null +++ b/tools/specbuild/model.py @@ -0,0 +1,142 @@ +"""The things a version of the specification is made of: classes, requirements, tests, and the version itself.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from .config import DRAFT_NAME, published_path + +TARGETS = ("ruleset", "linter") + + +STATUSES = ("", "proposed", "removed", "example") + + +KIND_NAME = {"req": "requirement", "rec": "recommendation", "per": "permission"} + + +KIND_WORDS = { + "req": ("MUST NOT", "MUST", "SHALL NOT", "SHALL", "REQUIRED"), + "rec": ("SHOULD NOT", "SHOULD", "NOT RECOMMENDED", "RECOMMENDED"), + "per": ("MAY", "OPTIONAL"), +} + + +@dataclass +class ConformanceClass: + name: str + id: str + target: str + depends: list[str] + line: int + text: str + end_line: int = 0 + members: list[str] = field(default_factory=list) + tests: list[str] = field(default_factory=list) + + +@dataclass +class Requirement: + id: str + kind: str + klass: str + target: str + since: str + removed: str + status: str + section_id: str + section_title: str + line: int + text: str + body: str = "" + end_line: int = 0 + tests: list[str] = field(default_factory=list) + + +@dataclass +class Test: + id: str + covers: list[str] + purpose: str + ruleset: str + document: str | None + files: list[str] + expect: dict + dir: Path + klass: str = "" + target: str = "" + + @property + def section(self) -> str: + return self.id.split("/")[2] + + @property + def name(self) -> str: + return self.id.split("/")[3] + + +@dataclass +class Version: + name: str + source_dir: Path + metadata: dict[str, str] + classes: list[ConformanceClass] + requirements: list[Requirement] + tests: list[Test] + expanded: str + + @property + def is_draft(self) -> bool: + return DRAFT_NAME.fullmatch(self.name) is not None + + @property + def major(self) -> int: + """The major version: a draft's number, or a release's first number.""" + return int(self.name[1:]) if self.is_draft else int(self.name.split(".")[0]) + + @property + def site_path(self) -> str: + return published_path(self.name) + + @property + def title(self) -> str: + return self.metadata.get("Title", self.name) + + @property + def label(self) -> str: + macro = self.metadata.get("Text Macro", "") + return macro.split(" ", 1)[1] if macro.startswith("VERSIONLABEL ") else self.name + + +GROUP_ABOVE = 10 + + +def grouped(test_ids: list[str]) -> bool: + """Whether a requirement's tests are shown by section: a list too long to scan that spans several sections.""" + return len(test_ids) > GROUP_ABOVE and len({tid.split("/")[2] for tid in test_ids}) > 1 + + +def test_groups(test_ids: list[str], tests: dict[str, Test]) -> list[tuple[str, str]]: + """Each test with the heading to show before it, which is its section when the tests are grouped.""" + is_grouped = grouped(test_ids) + out, previous = [], None + for tid in test_ids: + section = tests[tid].section + out.append((tid, f"/conf/{section}/" if is_grouped and section != previous else "")) + previous = section + return out + + +def expect_kind(test: Test) -> str: + """valid, invalid, rejected, results (at least one), or empty.""" + e = test.expect + if "ruleset" in e: + return e["ruleset"] + if e.get("rejected"): + return "rejected" + return "results" if e.get("results") else "empty" + + +def test_files(test: Test) -> dict[str, bytes]: + return {str(p.relative_to(test.dir)): p.read_bytes() for p in sorted(test.dir.rglob("*")) if p.is_file()} diff --git a/tools/specbuild/registry.py b/tools/specbuild/registry.py new file mode 100644 index 0000000..1d18cda --- /dev/null +++ b/tools/specbuild/registry.py @@ -0,0 +1,80 @@ +"""requirements.json, the machine-readable registry, and its validation against registry.schema.json.""" + +from __future__ import annotations + +import json +import re +from datetime import date + +from .config import REGISTRY_SCHEMA, SpecError +from .model import KIND_NAME, Version + + +def registry(version: Version) -> dict: + return { + "$schema": "registry.schema.json", + "specification": version.title, + "version": "draft" if version.is_draft else version.name, + "line": version.major, + "label": version.label, + "generated": version.metadata.get("Date") or date.today().isoformat(), + "classes": [ + {"id": c.id, "name": c.name, "target": c.target, "depends": c.depends, + "members": c.members, "tests": c.tests, "text": c.text} + for c in version.classes + ], + "requirements": [ + { + "id": r.id, "kind": KIND_NAME[r.kind], "class": r.klass, "target": r.target, + "status": r.status or "current", "since": r.since or "draft", + **({"removed": r.removed} if r.removed else {}), + "section": r.section_id, "tests": r.tests, "text": r.text, + } + for r in version.requirements + ], + "tests": [ + { + "id": t.id, "for": t.covers, "class": t.klass, "target": t.target, "purpose": t.purpose, + "dir": f"conformance{t.id[len('/conf'):]}", "ruleset": t.ruleset, + "document": t.document, "files": t.files, "expect": t.expect, + } + for t in version.tests + ], + } + + +def validate(schema: dict, data, path: str = "$") -> list[str]: + """Check data against the subset of JSON Schema the registry schema uses.""" + errors: list[str] = [] + if "const" in schema and data != schema["const"]: + errors.append(f"{path}: expected {schema['const']!r}") + if "enum" in schema and data not in schema["enum"]: + errors.append(f"{path}: {data!r} is not one of {schema['enum']}") + if "type" in schema: + types = schema["type"] if isinstance(schema["type"], list) else [schema["type"]] + py = {"object": dict, "array": list, "string": str, "null": type(None), "boolean": bool, "number": (int, float), "integer": int} + if not any(isinstance(data, py[t]) for t in types): + errors.append(f"{path}: expected {' or '.join(types)}, got {type(data).__name__}") + return errors + if isinstance(data, str) and "pattern" in schema and not re.search(schema["pattern"], data): + errors.append(f"{path}: {data!r} does not match {schema['pattern']}") + if isinstance(data, dict): + for key in schema.get("required", []): + if key not in data: + errors.append(f"{path}: missing {key!r}") + props = schema.get("properties", {}) + for key, value in data.items(): + if key in props: + errors.extend(validate(props[key], value, f"{path}.{key}")) + elif schema.get("additionalProperties") is False: + errors.append(f"{path}: unexpected property {key!r}") + if isinstance(data, list) and "items" in schema: + for i, item in enumerate(data): + errors.extend(validate(schema["items"], item, f"{path}[{i}]")) + return errors + + +def check_registry(reg: dict, name: str) -> None: + problems = validate(json.loads(REGISTRY_SCHEMA.read_text(encoding="utf-8")), reg) + if problems: + raise SpecError(f"{name}: requirements.json does not match schema/registry.schema.json:\n " + "\n ".join(problems[:20])) diff --git a/tools/specbuild/render.py b/tools/specbuild/render.py new file mode 100644 index 0000000..c120904 --- /dev/null +++ b/tools/specbuild/render.py @@ -0,0 +1,233 @@ +"""HTML for requirement and class blocks, the appendices, and the listing pages.""" + +from __future__ import annotations + +import html +import json +import re +from pathlib import Path + +from .model import KIND_NAME, ConformanceClass, Requirement, Test, Version, expect_kind, grouped, test_groups +from .scan import KEYWORDS + +STYLE = "" + + +def quote_markup(text: str) -> str: + """Plain text for quoting inside the document: links stripped, code spans kept as .""" + text = re.sub(r"\[=(?:[^=|]*\|)?([^=]+)=\]", r"\1", text) + text = re.sub(r"\[\[!?([^\]]+)\]\]", r"\1", text) + text = re.sub(r"<[^>]+>", "", text) + text = " ".join(text.split()) + text = html.escape(text, quote=False).replace("{{", "&bs{;{").replace("[[", "&bs[;[") + return re.sub(r"`([^`]*)`", r"\1", text) + + +def source_text(text: str) -> str: + """Text from test.json for the Bikeshed source: markup escaped, code spans left for Bikeshed to render.""" + parts = text.split("`") + if len(parts) % 2 == 0: + return html.escape(text, quote=False) + return "`".join(p if i % 2 else html.escape(p, quote=False) for i, p in enumerate(parts)) + + +def link(target_id: str, text: str | None = None, css: str = "") -> str: + t = html.escape(target_id) + cls = f' class="{css}"' if css else "" + return f'{html.escape(text or target_id)}' + + +def target_badge(target: str) -> str: + return f'{target}' + + +def status_badge(status: str) -> str: + return f'{status}' if status else "" + + +def kind_badge(kind: str) -> str: + return f'{KIND_NAME[kind]}' if kind != "req" else "" + + +def req_head(req: Requirement) -> str: + parts = [link(req.id, css="req-id"), target_badge(req.target), status_badge(req.status)] + if req.status != "example": + parts.append(link(f"/conf/{req.klass}", req.klass, css="req-class")) + parts.append(f'since {html.escape(req.since or "this draft")}') + return f'

{"".join(p for p in parts if p)}

' + + +def class_head(c: ConformanceClass) -> str: + deps = ", ".join(link(f"/conf/{d}", d) for d in c.depends) or "none" + return ( + f'

{link(c.id, css="req-id")}{target_badge(c.target)}' + f'depends on {deps}

' + ) + + +def class_members(c: ConformanceClass, requirements: dict[str, Requirement]) -> str: + counts = {k: 0 for k in KIND_NAME} + for rid in c.members: + counts[requirements[rid].kind] += 1 + bits = [f"{n} {KIND_NAME[k]}{'s' if n != 1 else ''}" for k, n in counts.items() if n] + bits.append(f"{len(c.tests)} test{'s' if len(c.tests) != 1 else ''}") + return f'

{", ".join(bits)}. {link("conf-class-" + c.name, "Tests")}, {link("index-class-" + c.name, "index")}.

' + + +def count(n: int, one: str, many: str = "") -> str: + return f"{n} {one if n == 1 else many or one + 's'}" + + +def requirements_index(version: Version) -> str: + reqs = {r.id: r for r in version.requirements} + counts = {k: sum(1 for r in version.requirements if r.kind == k and r.status != "removed") for k in KIND_NAME} + out = [ + (f"

{count(len(version.classes), 'conformance class', 'conformance classes')}, {count(counts['req'], 'requirement')}, " + f"{count(counts['rec'], 'recommendation')}, {count(counts['per'], 'permission')}, and {count(len(version.tests), 'conformance test')}. " + f'The same data is available as requirements.json, and the tests ' + f'with their fixtures under conformance/.

') + ] + for c in version.classes: + deps = ", ".join(link(f"/conf/{d}", d) for d in c.depends) or "none" + out.append(f'

{link(c.id)}

') + out.append(f"

{html.escape(c.text)} Target: {target_badge(c.target)} Depends on: {deps}.

") + rows = [] + for rid in c.members: + r = reqs[rid] + if grouped(r.tests): + tests = f'{link("tests" + r.id, f"{len(r.tests)} tests")}, grouped by section in [[#conformance-tests]]' + else: + tests = ", ".join(link(t) for t in r.tests) or ("Not tested: a recommendation." if r.kind == "rec" else "none") + rows.append( + "" + f"{link(r.id)}{status_badge(r.status)}" + f"{link(r.section_id, r.section_title)}" + f"{tests}" + f"{html.escape(r.since or 'this draft')}" + "" + ) + out.append( + '' + "" + f"{''.join(rows)}
IdentifierSectionTestsSince
" + ) + return "\n".join(out) + + +def expectation(test: Test) -> str: + e = test.expect + if "ruleset" in e: + text = ("The ruleset is valid: a linter loads it." if e["ruleset"] == "valid" + else "The ruleset is invalid: a linter rejects it.") + return f'

{text}

' + if e.get("rejected"): + return '

The linter rejects the ruleset and produces no results.

' + results = e.get("results", []) + if not results: + return '

Linting produces no results.

' + n = len(results) + body = html.escape(json.dumps(results, indent=2)) + return (f'

Linting produces exactly {n} result{"s" if n != 1 else ""}, ' + f'matching on the fields given, in any order:

{body}
') + + +def outcome_badge(test: Test) -> str: + e = test.expect + if "ruleset" in e: + text = e["ruleset"] + elif e.get("rejected"): + text = "rejected" + else: + n = len(e.get("results", [])) + text = f"{n} result{'s' if n != 1 else ''}" + return f'{text}' + + +def highlight_for(name: str) -> str: + """The syntax highlighting of a fixture, from its extension.""" + suffix = name.rsplit(".", 1)[-1].lower() if "." in name else "" + return {"json": "json", "js": "js", "cjs": "js", "mjs": "js"}.get(suffix, "yaml") + + +def render_test(test: Test) -> str: + rel = f"conformance{test.id[len('/conf'):]}" + fixtures = [(name, highlight_for(name)) for name in [test.ruleset, *([test.document] if test.document else []), *test.files]] + parts = [ + f'
', + f'{link(test.id, css="test-id")}{source_text(test.purpose)}{outcome_badge(test)}', + (f'

Covers {", ".join(link(r) for r in test.covers)}. ' + f'Fixtures in {html.escape(rel)}/.

'), + ] + for name, lang in fixtures: + content = html.escape((test.dir / name).read_text(encoding="utf-8").rstrip("\n")) + parts.append(f'

{html.escape(name)}

' + f'
{content}
') + if test.target == "ruleset": + parts.append('

Procedure

Load the ruleset with a linter.

') + else: + doc = f"`{html.escape(test.document)}`" if test.document else "an empty document" + parts.append(f'

Procedure

Lint {doc} with `{html.escape(test.ruleset)}` and collect the results as JSON.

') + parts.append(f'

Expected

{expectation(test)}') + parts.append("
") + return "\n".join(parts) + + +def conformance_appendix(version: Version) -> str: + reqs = {r.id: r for r in version.requirements} + by_id = {t.id: t for t in version.tests} + out = [ + ('

' + '

') + ] + for c in version.classes: + out.append(f'

Conformance class {link(c.id)}

') + if not c.tests: + out.append("

No tests yet.

") + shown: set[str] = set() + for rid in c.members: + req = reqs[rid] + if not req.tests: + continue + text = KEYWORDS.sub(r'\1', quote_markup(req.body)) + out.append(f'

{link(req.id, css="req-id")}{target_badge(req.target)}' + f'{status_badge(req.status)}in section {link(req.section_id, req.section_title)}

' + f'

{text}

') + for tid, group in test_groups(req.tests, by_id): + if group: + out.append(f'

{html.escape(group)}

') + if tid in shown: + test = by_id[tid] + out.append(f'

{link(tid, css="test-id")}{source_text(test.purpose)}' + f'{outcome_badge(test)}also covers {", ".join(link(r) for r in test.covers if r != rid)}; shown there

') + continue + shown.add(tid) + out.append(render_test(by_id[tid])) + out.append("
") + return "\n".join(out) + + +def plain_html(text: str) -> str: + return re.sub(r"`([^`]*)`", r"\1", html.escape(text, quote=False)) + + +def listing_page(title: str, up: str, up_label: str, body: str) -> str: + return f""" + + + + +{html.escape(title)} + + + +

{html.escape(up_label)}

+

{html.escape(title)}

+{body} + + +""" diff --git a/tools/specbuild/scan.py b/tools/specbuild/scan.py new file mode 100644 index 0000000..b1563ad --- /dev/null +++ b/tools/specbuild/scan.py @@ -0,0 +1,107 @@ +"""Reading the Bikeshed source: the block and heading syntax, metadata, and the text patterns the checks look for.""" + +from __future__ import annotations + +import re + +METADATA_OPEN = re.compile(r"""^\s*
""")
+
+
+ISSUE_PARAGRAPH = re.compile(r"^\s*Issue(\([^)]*\))?:")
+
+
+ISSUE_DIV = re.compile(r"""^\s*req|rec|per|class|bcp14)\b(?P[^>]*)>\s*$")
+
+
+ATTR = re.compile(r"""(?P[\w-]+)=(?:"(?P[^"]*)"|'(?P[^']*)'|(?P[^\s>]+))""")
+
+
+MD_HEADING = re.compile(r"^(#{1,6})\s+(?P.*?)\s+#*\s*\{#(?P[^}\s]+)[^}]*\}\s*$")
+
+
+HTML_HEADING = re.compile(r"""^\s*]*\bid=["']?(?P[^"'\s>]+)["']?[^>]*>(?P.*?)""")
+
+
+METADATA_KEY = re.compile(r"^(?P[^:]+):\s*(?P.*)$")
+
+
+# The specification states its rules as its own. Implementations are named only in issues, which a
+# release does not carry, and in the provenance appendix.
+# OpenLint the project (its community, maintainers, and the name of this specification) is not a mention of the linter.
+MENTION = re.compile(r"\bSpectral\b|\bOpenLint\b(?! (?:community|maintainers|Ruleset))|\[\[!?(?:SPECTRAL|OPENLINT)\]\]|[Rr]eference implementation")
+
+
+MENTION_ALLOWED_SECTIONS = {"provenance"}
+
+
+def bid_of(attrs: dict[str, str]) -> str:
+    return attrs.get("id", "a block")
+
+
+def parse_attrs(raw: str) -> dict[str, str]:
+    return {
+        m.group("name"): m.group("dq") or m.group("sq") or m.group("bare") or ""
+        for m in ATTR.finditer(raw)
+    }
+
+
+def strip_markup(text: str) -> str:
+    text = re.sub(r"\[=(?:[^=|]*\|)?([^=]+)=\]", r"\1", text)
+    text = re.sub(r"\[\[!?([^\]]+)\]\]", r"\1", text)
+    text = re.sub(r"<[^>]+>", "", text)
+    text = text.replace("`", "")
+    return " ".join(text.split())
+
+
+def read_metadata(lines: list[str]) -> dict[str, str]:
+    metadata: dict[str, str] = {}
+    inside = False
+    for line in lines:
+        if METADATA_OPEN.match(line):
+            inside = True
+            continue
+        if inside and line.startswith("
"): + break + if inside: + m = METADATA_KEY.match(line) + if m: + metadata[m.group("key").strip()] = m.group("value").strip() + return metadata + + +def open_issues(source: str) -> list[tuple[int, str]]: + """Every Issue block in the document, outside code fences.""" + found = [] + in_fence = False + for n, line in enumerate(source.split("\n"), start=1): + if line.strip().startswith("```"): + in_fence = not in_fence + if not in_fence and (ISSUE_PARAGRAPH.match(line) or ISSUE_DIV.match(line)): + found.append((n, line.strip())) + return found diff --git a/tools/specbuild/schema_tables.py b/tools/specbuild/schema_tables.py new file mode 100644 index 0000000..5fccfc6 --- /dev/null +++ b/tools/specbuild/schema_tables.py @@ -0,0 +1,167 @@ +"""Property tables generated from the ruleset schema, and the formats table.""" + +from __future__ import annotations + +import html +import json +import re +from pathlib import Path + +from .render import quote_markup +from .scan import KEYWORDS, MENTION + + +def load_formats(source_dir: Path) -> dict[str, dict[str, str]]: + """Family -> identifier -> the condition under which a linter detects the format, in prose with code spans.""" + return json.loads((source_dir / "formats.json").read_text(encoding="utf-8")) + + +def formats_table(source_dir: Path) -> str: + rows = [] + for family, ids in load_formats(source_dir).items(): + for i, (ident, condition) in enumerate(ids.items()): + family_cell = f'{html.escape(family)}' if i == 0 else "" + rows.append(f"{family_cell}{html.escape(ident)}{quote_markup(condition)}") + return ('' + f"{''.join(rows)}
FamilyIdentifierDetected when
") + + +SCHEMA_TABLE = re.compile(r'^
\s*$') + + +DEF_SECTIONS = { + "Formats": "rule-formats", "Extends": "extends", "Aliases": "aliases", "AliasTarget": "aliases", + "Overrides": "overrides", "ParserOptions": "parser-options", "Severity": "rule-severity", + "Given": "rule-given", "PathExpressionList": "rule-given", "Then": "rule-then", + "Rule": "rule-object", "Shorthand": "shorthands", +} + + +PLURAL = {"string": "strings", "non-empty string": "non-empty strings", "object": "objects", "boolean": "booleans", "number": "numbers"} + + +def either(items: list[str]) -> str: + return " or ".join(items) if len(items) <= 2 else ", ".join(items[:-1]) + ", or " + items[-1] + + +def sections_for(schema: dict, node) -> list[tuple[str, str]]: + """The sections that define the values a node accepts, through references, alternatives, arrays, and maps.""" + if not isinstance(node, dict): + return [] + if "$ref" in node: + name = node["$ref"].rsplit("/", 1)[-1] + if name in DEF_SECTIONS: + return [(DEF_SECTIONS[name], html.escape(schema["$defs"][name].get("title", name)))] + return sections_for(schema, schema["$defs"][name]) + found = [] + for alt in node.get("oneOf", []) + node.get("anyOf", []) + [node.get("then"), node.get("else"), node.get("items"), node.get("additionalProperties")]: + for s in sections_for(schema, alt): + if s not in found: + found.append(s) + return found + + +JSON_TYPE = {"string": "string", "number": "number", "integer": "integer", "boolean": "boolean", "null": "null", "object": "object"} + + +def coarse_type(schema: dict, node, plural: bool = False) -> str: + """The JSON type a node accepts, in a word or two, without the detail the linked section gives.""" + if node is True: + return "any" + if "$ref" in node: + return coarse_type(schema, schema["$defs"][node["$ref"].rsplit("/", 1)[-1]], plural) + if "enum" in node: + kinds = list(dict.fromkeys("string" if isinstance(v, str) else "number" for v in node["enum"])) + return either([PLURAL.get(k, k + "s") if plural else k for k in kinds]) + kind = node.get("type") + if kind == "array" and "items" not in node: + return "arrays" if plural else "array" + if kind == "array": + return f"{'arrays' if plural else 'array'} of {coarse_type(schema, node['items'], plural=True)}" + if kind == "object" and isinstance(node.get("additionalProperties"), dict): + return f"{'maps' if plural else 'map'} of {coarse_type(schema, node['additionalProperties'], plural=True)}" + if kind in JSON_TYPE: + return PLURAL.get(kind, kind + "s") if plural else kind + alts = node.get("oneOf") or node.get("anyOf") or ([node["then"], node["else"]] if "then" in node and "else" in node else None) + if alts: + return either(list(dict.fromkeys(coarse_type(schema, alt, plural) for alt in alts))) + raise ValueError(f"the table generator cannot describe {json.dumps(node)[:80]}") + + +def schema_type(schema: dict, node, plural: bool = False) -> str: + """The JSON shape a schema node accepts, in a few words.""" + if node is True: + return "any" + if "$ref" in node: + return schema_type(schema, schema["$defs"][node["$ref"].rsplit("/", 1)[-1]], plural) + if "enum" in node: + values = [f"{html.escape(v if isinstance(v, str) else json.dumps(v))}" for v in node["enum"]] + return either(values) if len(values) <= 2 else "one of " + ", ".join(values) + kind = node.get("type") + if kind == "string": + text = "non-empty string" if node.get("minLength", 0) >= 1 else "string" + text = PLURAL[text] if plural else text + if "format" in node: + return f"{text} ({'URI' if node['format'] == 'uri' else html.escape(node['format'])})" + return f"{text} ({html.escape(node['title'])})" if "pattern" in node and "title" in node else text + if kind == "array": + prefix = "non-empty array" if node.get("minItems", 0) >= 1 else "array" + return f"{prefix} of {schema_type(schema, node.get('items', True), plural=True)}" + if kind == "object" and isinstance(node.get("additionalProperties"), dict): + return f"{'maps' if plural else 'map'} of {schema_type(schema, node['additionalProperties'], plural=True)}" + if kind in ("object", "boolean"): + return PLURAL[kind] if plural else kind + for key in ("oneOf", "anyOf"): + if key in node: + return either(list(dict.fromkeys(schema_type(schema, alt, plural) for alt in node[key]))) + if "if" in node and "then" in node and "else" in node: + return either(list(dict.fromkeys(schema_type(schema, alt, plural) for alt in (node["then"], node["else"])))) + raise ValueError(f"the table generator cannot describe {json.dumps(node)[:80]}") + + +def type_cell(schema: dict, prop) -> str: + """The JSON type, with a link to the section that defines the values; the full shape when no section does.""" + sections = sections_for(schema, prop) + if not sections: + return schema_type(schema, prop) + return f"{coarse_type(schema, prop)}, see {either([f'[[#{s}|{title}]]' for s, title in sections])}" + + +def schema_table(schema: dict, pointer: str) -> tuple[str, list[str]]: + """The property table of one object in the ruleset schema, and what is missing to generate it.""" + node = schema if pointer == "#" else schema["$defs"].get(pointer) + if not isinstance(node, dict) or "properties" not in node: + return "", [f"data-def={pointer!r} names no object with properties in the ruleset schema"] + errors = [] + required = node.get("required", []) + rows = [] + for name in sorted(node["properties"], key=lambda n: n not in required): + prop = node["properties"][name] + description = prop.get("description") + if description is None and "$ref" in prop: + description = schema["$defs"][prop["$ref"].rsplit("/", 1)[-1]].get("description") + if not description: + errors.append(f"{pointer}: property {name!r} has no description in the ruleset schema, which the table is generated from") + elif KEYWORDS.search(description): + errors.append(f"{pointer}: the description of {name!r} uses a BCP 14 keyword, but generated tables are informative") + elif MENTION.search(description): + errors.append(f"{pointer}: the description of {name!r} names an implementation; that belongs in an issue") + try: + kind = type_cell(schema, prop) + except ValueError as e: + errors.append(f"{pointer}: property {name!r}: {e}") + kind = "" + req_cell = f"{'yes' if name in required else ''}" if required else "" + rows.append(f"{html.escape(name)}{kind}{req_cell}{quote_markup(description or '')}") + if "^x-" in node.get("patternProperties", {}): + rows.append(f'x-*any{"" if required else ""}' + '[[#extension-properties|Extension property]].') + notes = [] + at_least = [alt["required"][0] for alt in node.get("anyOf", []) if list(alt) == ["required"] and len(alt["required"]) == 1] + if at_least: + notes.append(f"At least one of {either([f'{html.escape(n)}' for n in at_least])} is required.") + if node.get("additionalProperties") is False: + notes.append("No other properties are allowed.") + head = "PropertyType" + ("Required" if required else "") + "Description" + note = f'\n

{" ".join(notes)}

' if notes else "" + return f'{head}{"".join(rows)}
{note}', errors diff --git a/tools/specbuild/site.py b/tools/specbuild/site.py new file mode 100644 index 0000000..5ac25b6 --- /dev/null +++ b/tools/specbuild/site.py @@ -0,0 +1,183 @@ +"""Writing site/: each version, the conformance pages, the versions index, and the per-major-version aliases.""" + +from __future__ import annotations + +import html +import json +import shutil +import subprocess +import sys +from pathlib import Path + +from .config import BASE_URL, BIKESHED, BUILD, REGISTRY_SCHEMA, SCHEMA_REQUIREMENT, SITE, SpecError, schema_file, semver_key +from .model import Test, Version, test_files +from .registry import check_registry, registry +from .render import conformance_appendix, link, listing_page, outcome_badge, plain_html, quote_markup, requirements_index +from .schema_tables import formats_table + + +def remove_tree(path: Path) -> None: + """Delete a directory; a missing one is fine, anything else that fails is an error.""" + try: + shutil.rmtree(path) + except FileNotFoundError: + pass + + +def build_version(version: Version, site: Path = SITE, build: Path = BUILD) -> Path: + build_dir = build / version.site_path + out_dir = site / version.site_path + remove_tree(build_dir) + build_dir.mkdir(parents=True) + out_dir.mkdir(parents=True, exist_ok=True) + for include in version.source_dir.glob("*.include"): + shutil.copy(include, build_dir / include.name) + (build_dir / "index.bs").write_text(version.expanded, encoding="utf-8") + (build_dir / "requirements-index.include").write_text(requirements_index(version), encoding="utf-8") + (build_dir / "conformance-tests.include").write_text(conformance_appendix(version), encoding="utf-8") + (build_dir / "formats-table.include").write_text(formats_table(version.source_dir), encoding="utf-8") + gaps = [r for r in version.requirements + if r.target == "ruleset" and r.kind == "req" and r.id != SCHEMA_REQUIREMENT and r.status != "removed"] + (build_dir / "schema-gaps.include").write_text( + "
    " + "".join(f"
  • {link(r.id)}: {quote_markup(r.body)}
  • " for r in gaps) + "
", encoding="utf-8") + reg = registry(version) + check_registry(reg, version.name) + out_file = out_dir / "index.html" + result = subprocess.run( # noqa: PLW1510, the exit code is read below + [BIKESHED, "--die-on=link-error", "spec", str(build_dir / "index.bs"), str(out_file)], + capture_output=True, text=True, + ) + sys.stdout.write(result.stdout) + sys.stderr.write(result.stderr) + if result.returncode != 0: + raise SpecError(f"bikeshed failed for {version.name}") + (out_dir / "requirements.json").write_text(json.dumps(reg, indent=2) + "\n", encoding="utf-8") + reg_schema = json.loads(REGISTRY_SCHEMA.read_text(encoding="utf-8")) + reg_schema = {"$schema": reg_schema.pop("$schema"), "$id": f"{BASE_URL}/{version.site_path}/registry.schema.json", **reg_schema} + (out_dir / "registry.schema.json").write_text(json.dumps(reg_schema, indent=2) + "\n", encoding="utf-8") + remove_tree(out_dir / "conformance") + shutil.copytree(version.source_dir / "conformance", out_dir / "conformance", symlinks=True) + write_conformance_pages(version, out_dir / "conformance") + shutil.copy(schema_file(version.source_dir), out_dir / "ruleset.schema.json") + return out_file + + +def write_conformance_pages(version: Version, root: Path) -> None: + """An index.html for conformance/, each section, and each test, which GitHub Pages would otherwise serve as 404.""" + def item(test: Test, href: str) -> str: + return f'
  • {html.escape(test.id)}: {plain_html(test.purpose)}
  • ' + + sections: dict[str, list[Test]] = {} + for test in version.tests: + sections.setdefault(test.section, []).append(test) + body = "".join(f'

    {html.escape(s)}

      {"".join(item(t, f"{s}/{t.name}/") for t in ts)}
    ' + for s, ts in sections.items()) + (root / "index.html").write_text(listing_page(f"Conformance tests: {version.label}", "../index.html#conformance-tests", "Back to the specification", body), encoding="utf-8") + for s, ts in sections.items(): + body = f'
      {"".join(item(t, f"{t.name}/") for t in ts)}
    ' + (root / s / "index.html").write_text(listing_page(f"/conf/{s}/", "../", "All conformance tests", body), encoding="utf-8") + for t in ts: + covers = ", ".join(f'{html.escape(r)}' for r in t.covers) + files = "".join(f'
  • {html.escape(name)}
  • ' for name in test_files(t)) + body = (f"

    {plain_html(t.purpose)}

    Covers {covers}. Expected: {outcome_badge(t)}.

    " + f"

    Files

      {files}
    ") + (root / s / t.name / "index.html").write_text(listing_page(t.id, "../", f"/conf/{s}/", body), encoding="utf-8") + + +def write_versions_index(versions: list[Version]) -> Path: + drafts = sorted((v for v in versions if v.is_draft), key=lambda v: v.major, reverse=True) + releases = sorted((v for v in versions if not v.is_draft), key=lambda v: semver_key(v.name), reverse=True) + title = versions[0].title + + def card(v: Version, note: str, schema_alias: str) -> str: + n = html.escape(v.site_path) + reqs = sum(1 for r in v.requirements if r.kind == "req" and r.status != "removed") + schema_note = f'
    {schema_alias}' if schema_alias else "" + return f"""
  • + +

    {note}{len(v.classes)} conformance classes, {reqs} requirements, {len(v.tests)} conformance tests.

    +

    Read the specification

    + + + + + +
  • """ + + newest = latest_releases(versions) + + def alias(line: int, until: str) -> str: + return f'Also served at /v{line}/ruleset.schema.json {until}.' + + items = [card(d, f"The working draft of {d.major}.x. Changes without notice. ", + "" if d.major in newest else alias(d.major, f"until {d.major}.x has a release")) + for d in drafts] + for v in releases: + latest = newest.get(v.major) is v + note = f'Latest {v.major}.x release, also served at /v{v.major}/. ' if latest else "" + items.append(card(v, note + f"Released {html.escape(v.metadata.get('Date', ''))}. ", + alias(v.major, f"while this is the latest {v.major}.x release") if latest else "")) + + page = f""" + + + + +{html.escape(title)}: versions + + + + +
    +

    {html.escape(title)}

    +

    Every release lives at its own path and never changes once published. Fixes ship as a new patch release. Each major version has a working draft, where its changes land first.

    +
      +{chr(10).join(items)} +
    + +
    + + +""" + SITE.mkdir(exist_ok=True) + path = SITE / "index.html" + path.write_text(page, encoding="utf-8") + return path + + +def latest_releases(versions: list[Version]) -> dict[int, Version]: + """The newest release of each line that has one.""" + newest: dict[int, Version] = {} + for v in sorted((v for v in versions if not v.is_draft), key=lambda v: semver_key(v.name)): + newest[v.major] = v + return newest diff --git a/tools/specbuild/spec.css b/tools/specbuild/spec.css new file mode 100644 index 0000000..de10d5a --- /dev/null +++ b/tools/specbuild/spec.css @@ -0,0 +1,85 @@ +.req, .rec, .per, .class, .test { + margin: 1.5em 0; + padding: .75em 1em .5em; + border-left: .4em solid #0b57d0; + background: rgba(11, 87, 208, .05); + border-radius: 0 4px 4px 0; +} +.rec { border-left-color: #7b3fa0; background: rgba(123, 63, 160, .05); } +.per { border-left-color: #0f8a6a; background: rgba(15, 138, 106, .05); } +.class { border-left-color: #b06000; background: rgba(176, 96, 0, .05); } +.test { border-left-color: #5f6368; background: rgba(95, 99, 104, .06); } +.req-kind { padding: 0 .5em; border-radius: 1em; font-size: .9em; line-height: 1.6; } +.req-kind-rec { background: #f1e6f7; color: #7b3fa0; } +.req-kind-per { background: #e3f5ef; color: #0f8a6a; } +.req-class { font-family: ui-monospace, Menlo, monospace; color: #5f6368; } +.class-deps, .class-members { color: #5f6368; } +.class-members { font-size: .85em; margin: .5em 0 0; } +.fixture-label { margin: .75em 0 .25em; font-size: .85em; font-weight: 600; color: #5f6368; text-transform: uppercase; letter-spacing: .04em; } +.test pre { margin: 0 0 .5em; } +details.test { margin: .5em 0; padding: .4em 1em; } +details.test > summary { align-items: baseline; cursor: pointer; list-style: none; } +details.test > summary::-webkit-details-marker { display: none; } +details.test > summary::before { content: "▸"; color: #5f6368; } +details.test[open] > summary::before { content: "▾"; } +details.test > summary .test-purpose { font-weight: normal; } +details.test > summary .test-id { font-size: .85em; } +.test-outcome { font-size: .8em; padding: 0 .5em; border-radius: 1em; background: #e8eaed; color: #3c4043; white-space: nowrap; } +.test-outcome-valid, .test-outcome-results { background: #e6f4ea; color: #137333; } +.test-outcome-invalid, .test-outcome-rejected { background: #fce8e6; color: #c5221f; } +.test-for { margin: .5em 0 0; font-size: .85em; color: #5f6368; } +.req-cited { padding-bottom: .75em; } +details.req-tests tr.test-group th, .req-cited p.test-group { text-align: left; font: 600 .95em ui-monospace, Menlo, monospace; color: #5f6368; padding: .9em 0 .2em; margin: 0; } +.req-cited p.test-group { padding: .9em 0 0; font-size: .85em; } +p.schema-source { margin-top: -.25em; font-size: .85em; color: #5f6368; } +@media (prefers-color-scheme: dark) { details.req-tests tr.test-group th, .req-cited p.test-group, p.schema-source { color: #9aa0a6; } } +.req-cited .req-back { margin-left: auto; font-size: .9em; color: #5f6368; } +.req-cited .req-back a { color: inherit; } +.req-cited-text { margin: 0 0 .25em; } +.req-cited details.test, .req-cited .test-shared { background: rgba(255, 255, 255, .7); border-left-color: #9aa0a6; margin: .4em 0 0; } +@media (prefers-color-scheme: dark) { .req-cited details.test, .req-cited .test-shared { background: rgba(0, 0, 0, .25); } } +.test-shared { display: flex; flex-wrap: wrap; gap: .5em 1em; align-items: baseline; margin: .5em 0; padding: .4em 1em .4em 2.2em; } +.test-shared .test-purpose { flex: 1 1 10em; } +.test-shared .test-for { flex-basis: 100%; margin: 0; } +.tests-controls button { font: inherit; font-size: .85em; padding: .2em .8em; } +details.req-tests { margin: .5em 0 0; font-size: .85em; color: #5f6368; } +details.req-tests > summary { cursor: pointer; list-style: none; font-weight: normal; } +details.req-tests > summary::-webkit-details-marker { display: none; } +details.req-tests > summary::before { content: "▸ "; } +details.req-tests[open] > summary::before { content: "▾ "; } +details.req-tests table { margin: .4em 0 0; border-collapse: collapse; width: 100%; } +details.req-tests td { padding: .3em .75em .3em 0; border-top: 1px solid rgba(95, 99, 104, .2); vertical-align: baseline; } +details.req-tests td:first-child { white-space: nowrap; width: 1%; } +details.req-tests td:last-child { text-align: right; width: 1%; padding-right: 0; } +details.req-tests td.test-purpose { color: #1b1b1b; } +@media (prefers-color-scheme: dark) { details.req-tests td.test-purpose { color: #e8e8e8; } } +details.test > summary { display: grid; grid-template-columns: auto 23em 1fr auto; column-gap: .75em; align-items: baseline; } +details.test > summary .test-id { white-space: normal; overflow-wrap: anywhere; } +details.test > summary .test-outcome { justify-self: end; } +.req-head, .test-head { + display: flex; flex-wrap: wrap; gap: .5em; align-items: baseline; + margin: 0 0 .5em; font-size: .85em; +} +.req-id, .test-id { font-family: ui-monospace, Menlo, monospace; font-weight: 600; } +.req-target, .req-status, .test-for { + padding: 0 .5em; border-radius: 1em; font-size: .9em; line-height: 1.6; +} +.req-target-ruleset { background: #e6f4ea; color: #137333; } +.req-target-linter { background: #e8f0fe; color: #174ea6; } +.req-status-proposed { background: #fef7e0; color: #b06000; } +.req-status-removed { background: #fce8e6; color: #c5221f; } +.req-status-removed ~ * { text-decoration: line-through; } +.req-since { color: #5f6368; margin-left: auto; } +.req em.rfc2119 { font-style: normal; font-weight: 600; letter-spacing: .02em; } +.test-for { color: #5f6368; } +.req > p:last-child, .test > p:last-child { margin-bottom: 0; } +table.requirements-index td:first-child { font-family: ui-monospace, Menlo, monospace; font-size: .9em; } +@media (prefers-color-scheme: dark) { + .req { background: rgba(138, 180, 248, .08); } + .test { background: rgba(255, 255, 255, .05); } + .req-target-ruleset { background: #1e3a28; color: #81c995; } + .req-target-linter { background: #1f2d4d; color: #8ab4f8; } + .req-status-proposed { background: #4a3a10; color: #fdd663; } + .req-status-removed { background: #4a1f1c; color: #f28b82; } + .req-since, .test-for { color: #9aa0a6; } +} diff --git a/tools/specbuild/versions.py b/tools/specbuild/versions.py new file mode 100644 index 0000000..3ebbc7d --- /dev/null +++ b/tools/specbuild/versions.py @@ -0,0 +1,372 @@ +"""Finding the versions in the repository and parsing one into its checked, expanded form. + +parse_version runs these steps in order and raises SpecError with every problem found: + + 1. scan_source: read the Bikeshed source line by line, find the blocks, the headings, and the + text-level problems (keywords outside blocks, implementation names, nesting). + 2. classify_blocks: turn each block into a ConformanceClass or a Requirement, checking its id, + status, and keywords. + 3. link_classes: resolve class dependencies and give each requirement its class and target. + 4. attach_tests: read the conformance tests and connect them to the requirements they cover. + 5. check_test_adequacy and check_part_targets: the test rules and the one-target-per-part rule. + 6. expand: add the generated headers and test lists to the source for Bikeshed. +""" + +from __future__ import annotations + +import html +import json +import re +from dataclasses import dataclass, field +from pathlib import Path + +from .checks import ( + check_discussion_links, + check_fixture_examples, + check_formats, + check_includes, + check_issues_index, + check_schema_id, + check_underscore_emphasis, +) +from .config import DRAFT_NAME, RELEASES, ROOT, SpecError, rel_path, schema_file +from .fixtures import load_tests +from .model import KIND_NAME, KIND_WORDS, STATUSES, TARGETS, ConformanceClass, Requirement, Test, Version, expect_kind, grouped, test_groups +from .render import STYLE, class_head, class_members, link, outcome_badge, req_head, source_text +from .scan import ( + BLOCK_ID, + HTML_HEADING, + ISSUE_DIV, + ISSUE_PARAGRAPH, + KEYWORDS, + MD_HEADING, + MENTION, + MENTION_ALLOWED_SECTIONS, + METADATA_OPEN, + OPEN_TAG, + PART_TARGETS, + bid_of, + parse_attrs, + read_metadata, + strip_markup, +) +from .schema_tables import SCHEMA_TABLE, schema_table + +OUTCOME_RANK = {"valid": 0, "results": 0, "empty": 1, "invalid": 1, "rejected": 2} + + +@dataclass +class Block: + """One
    in the source, with the section it sits in.""" + + kind: str + attrs: dict[str, str] + start: int + end: int + body: str + section_id: str + section_title: str + + +@dataclass +class Scan: + """What one pass over the source finds.""" + + out: list[str] = field(default_factory=list) + blocks: list[Block] = field(default_factory=list) + part_of_section: dict[str, str] = field(default_factory=dict) + errors: list[str] = field(default_factory=list) + + +def scan_source(lines: list[str], schema: dict, err) -> Scan: + """One pass over the source: blocks, headings and parts, keyword emphasis, schema tables, and the line-level checks. + + out has exactly one entry per source line, so later steps can address lines by number. + """ + scan = Scan() + section_id, section_title, part = "", "", "" + block: Block | None = None + body: list[str] = [] + in_fence = in_pre = in_metadata = False + in_issue: str | None = None + + for n, line in enumerate(lines, start=1): + stripped = line.strip() + if METADATA_OPEN.match(stripped): + in_metadata = True + elif in_metadata and stripped.startswith(""): + in_metadata = False + scan.out.append(line) + continue + if stripped.startswith("```"): + in_fence = not in_fence + if stripped.startswith("" not in stripped: + in_pre = True + elif in_pre and "" in stripped: + in_pre = False + scan.out.append(line) + continue + in_text = not (in_fence or in_pre or in_metadata) + + heading = MD_HEADING.match(line) or HTML_HEADING.match(line) + if heading and in_text: + section_id, section_title = heading.group("id"), strip_markup(heading.group("text")) + if MD_HEADING.match(line): + if heading.group(1) == "#": + part = section_id + scan.part_of_section[section_id] = part + + table = SCHEMA_TABLE.match(line) + if table and block is None and in_text: + rendered, problems = schema_table(schema, table.group("def")) + for problem in problems: + err(n, problem) + scan.out.append(rendered) + continue + + opened = OPEN_TAG.match(line) + if opened and block is None: + block = Block(kind=opened.group("class"), attrs=parse_attrs(opened.group("attrs")), start=n, end=0, body="", + section_id=section_id, section_title=section_title) + body = [] + scan.out.append(line) + continue + if block is not None: + if MENTION.search(line): + err(n, f"names an implementation inside {bid_of(block.attrs)}; requirements state the rule itself") + if stripped == "
    ": + scan.out.append(line) + block.end, block.body = n, "\n".join(body) + scan.blocks.append(block) + block = None + continue + if opened: + err(n, f"
    opened inside another block; blocks do not nest") + body.append(line) + if block.kind in KIND_NAME: + line = KEYWORDS.sub(r'\1', line) + scan.out.append(line) + continue + + if in_text: + if ISSUE_PARAGRAPH.match(line): + in_issue = "paragraph" + elif ISSUE_DIV.match(line): + in_issue = "div" + elif in_issue == "paragraph" and not stripped: + in_issue = None + if in_text and KEYWORDS.search(line): + err(n, f"BCP 14 keyword outside an identified requirement: {stripped[:80]!r}") + if in_text and MENTION.search(line) and in_issue is None and section_id not in MENTION_ALLOWED_SECTIONS: + err(n, f"names an implementation outside an issue: {stripped[:80]!r}") + if in_issue == "div" and stripped == "
    ": + in_issue = None + scan.out.append(line) + + if block is not None: + err(block.start, f"
    is never closed") + return scan + + +def classify_blocks(blocks: list[Block], err) -> tuple[list[ConformanceClass], list[Requirement]]: + """Each block becomes a class or a requirement, after its id, status, and keywords are checked.""" + classes: list[ConformanceClass] = [] + requirements: list[Requirement] = [] + for b in blocks: + text = strip_markup(b.body) + bid = b.attrs.get("id", "") + if b.kind == "class": + if not BLOCK_ID["class"].match(bid): + err(b.start, f"class id {bid!r} is not of the form /conf/") + target = b.attrs.get("data-target", "") + if target not in TARGETS: + err(b.start, f"class {bid} needs data-target=ruleset or data-target=linter, got {target!r}") + classes.append(ConformanceClass(name=bid.rsplit("/", 1)[-1], id=bid, target=target, depends=b.attrs.get("data-depends", "").split(), + line=b.start, text=text, end_line=b.end)) + elif b.kind in KIND_NAME: + status = b.attrs.get("data-status", "") + if not BLOCK_ID[b.kind].match(bid): + err(b.start, f"{KIND_NAME[b.kind]} id {bid!r} is not of the form /{b.kind}//") + if status not in STATUSES: + err(b.start, f"{bid} has unknown data-status {status!r}") + words = KEYWORDS.findall(b.body) + if status == "removed" and not b.attrs.get("data-removed"): + err(b.start, f"{bid} is removed but has no data-removed=") + if status != "removed": + if not any(w in KIND_WORDS[b.kind] for w in words): + err(b.start, f"{KIND_NAME[b.kind]} {bid} contains none of {', '.join(KIND_WORDS[b.kind])}") + wrong = sorted({w for w in words if w not in KIND_WORDS[b.kind]}) + if wrong: + err(b.start, f"{KIND_NAME[b.kind]} {bid} uses {', '.join(wrong)}, which belong to another kind of block") + requirements.append(Requirement( + id=bid, kind=b.kind, klass=b.attrs.get("data-class", ""), target="", + since=b.attrs.get("data-since", ""), removed=b.attrs.get("data-removed", ""), status=status, + section_id=b.section_id, section_title=b.section_title, line=b.start, text=text, body=b.body, end_line=b.end, + )) + return classes, requirements + + +def link_classes(classes: list[ConformanceClass], requirements: list[Requirement], err) -> tuple[dict[str, ConformanceClass], dict[str, Requirement]]: + """Classes depend on declared classes, and each requirement takes its target from its class.""" + class_by_name: dict[str, ConformanceClass] = {} + for c in classes: + if c.name in class_by_name: + err(c.line, f"duplicate class {c.id} (first at line {class_by_name[c.name].line})") + class_by_name[c.name] = c + for c in classes: + for d in c.depends: + if d not in class_by_name: + err(c.line, f"class {c.id} depends on {d!r}, which is not declared") + elif d == c.name: + err(c.line, f"class {c.id} depends on itself") + by_id: dict[str, Requirement] = {} + for req in requirements: + if req.id in by_id: + err(req.line, f"duplicate id {req.id} (first at line {by_id[req.id].line})") + by_id[req.id] = req + if req.status == "example": + req.target = "ruleset" + continue + if req.klass not in class_by_name: + err(req.line, f"{req.id} needs data-class naming a declared class, got {req.klass!r}") + continue + req.target = class_by_name[req.klass].target + class_by_name[req.klass].members.append(req.id) + return class_by_name, by_id + + +def attach_tests(tests: list[Test], by_id: dict[str, Requirement], class_by_name: dict[str, ConformanceClass], + source_dir: Path, errors: list[str]) -> dict[str, Test]: + """Each test joins the requirements it covers, which all belong to one class, and the class fixes what it may expect.""" + seen: dict[str, Test] = {} + for test in tests: + loc = rel_path(source_dir, f"conformance{test.id[len('/conf'):]}/test.json") + seen[test.id] = test + klasses = set() + for rid in test.covers: + req = by_id.get(rid) + if req is None or req.status == "example": + errors.append(f"{loc}: covers {rid}, which is not defined") + continue + if req.kind == "rec": + errors.append(f"{loc}: covers {rid}, which is a recommendation; a SHOULD cannot be failed, so it has no conformance test") + continue + req.tests.append(test.id) + klasses.add(req.klass) + if len(klasses) > 1: + errors.append(f"{loc}: covers requirements from several classes ({', '.join(sorted(klasses))}); a test belongs to one class") + elif klasses: + test.klass = klasses.pop() + test.target = class_by_name[test.klass].target + class_by_name[test.klass].tests.append(test.id) + if test.target == "ruleset" and set(test.expect) != {"ruleset"}: + errors.append(f"{loc}: a test for a ruleset requirement expects {{\"ruleset\": \"valid\" | \"invalid\"}}") + if test.target == "linter" and "ruleset" in test.expect: + errors.append(f"{loc}: a test for a linter requirement expects results or rejection, not ruleset validity") + for req in by_id.values(): + by_section = grouped(req.tests) + req.tests.sort(key=lambda tid: (seen[tid].section if by_section else "", OUTCOME_RANK.get(expect_kind(seen[tid]), 3), tid)) + return seen + + +def check_test_adequacy(requirements: list[Requirement], seen: dict[str, Test], err) -> None: + """The test rules: a ruleset requirement has a valid and an invalid test, a linter requirement one that expects results, a permission one that accepts.""" + for req in requirements: + if req.kind == "rec" or req.status in ("removed", "example"): + continue + if not req.tests: + err(req.line, f"{KIND_NAME[req.kind]} {req.id} has no conformance test") + continue + kinds = {expect_kind(seen[t]) for t in req.tests} + if req.kind == "per": + if not kinds & {"valid", "results"}: + err(req.line, f"permission {req.id} needs a test that shows the permitted thing is accepted: a valid fixture, or results") + continue + if req.target == "ruleset": + for needed in ("valid", "invalid"): + if needed not in kinds: + err(req.line, f"requirement {req.id} has no {needed} test; a ruleset requirement needs one of each") + elif "results" not in kinds: + err(req.line, f"requirement {req.id} has only tests that expect nothing; a linter requirement needs a test that expects results") + + +def check_part_targets(requirements: list[Requirement], part_of_section: dict[str, str], err) -> None: + """A part of the document holds requirements of one target.""" + for req in requirements: + if req.status == "example": + continue + expected = PART_TARGETS.get(part_of_section.get(req.section_id, "")) + if expected and req.target and req.target != expected: + err(req.line, f"{req.id} has target {req.target} but sits in part {part_of_section[req.section_id]!r}, which holds {expected} requirements only") + + +def expand(out: list[str], classes: list[ConformanceClass], requirements: list[Requirement], by_id: dict[str, Requirement], seen: dict[str, Test]) -> str: + """The source with the generated headers, member counts, test lists, and stylesheet, for Bikeshed.""" + for c in classes: + out[c.line - 1] = out[c.line - 1] + class_head(c) + out[c.end_line - 1] = class_members(c, by_id) + "\n" + out[c.end_line - 1] + for req in requirements: + out[req.line - 1] = out[req.line - 1] + req_head(req) + if req.tests: + rows = [] + for tid, group in test_groups(req.tests, seen): + if group: + rows.append(f'{html.escape(group)}') + rows.append(f'{link(tid, css="test-id")}{source_text(seen[tid].purpose)}' + f'{outcome_badge(seen[tid])}') + n_tests = len(req.tests) + out[req.end_line - 1] = (f'
    {n_tests} test{"s" if n_tests != 1 else ""}' + f'{"".join(rows)}
    \n' + out[req.end_line - 1]) + metadata_end = next(i for i, line in enumerate(out) if line.startswith("")) + out.insert(metadata_end + 1, "\n" + STYLE) + return "\n".join(out) + + +def parse_version(name: str, source_dir: Path) -> Version: + """Read index.bs and conformance/, run every check, and return the version with its expanded source.""" + source = (source_dir / "index.bs").read_text(encoding="utf-8") + lines = source.split("\n") + metadata = read_metadata(lines) + try: + schema = json.loads(schema_file(source_dir).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + raise SpecError(f"{rel_path(source_dir, 'ruleset.schema.json')}: {e}") from e + errors: list[str] = [] + + def err(line_no: int, message: str) -> None: + errors.append(f"{rel_path(source_dir, 'index.bs')}:{line_no}: {message}") + + scan = scan_source(lines, schema, err) + classes, requirements = classify_blocks(scan.blocks, err) + class_by_name, by_id = link_classes(classes, requirements, err) + tests, test_errors = load_tests(source_dir) + errors.extend(test_errors) + for check in (check_formats, lambda d: check_schema_id(name, d)): + errors.extend(check(source_dir)) + for check in (check_fixture_examples, check_issues_index, check_underscore_emphasis, check_discussion_links, check_includes): + errors.extend(check(source_dir, source)) + seen = attach_tests(tests, by_id, class_by_name, source_dir, errors) + check_test_adequacy(requirements, seen, err) + check_part_targets(requirements, scan.part_of_section, err) + if errors: + raise SpecError("\n".join(errors)) + return Version( + name=name, source_dir=source_dir, metadata=metadata, classes=classes, + requirements=[r for r in requirements if r.status != "example"], + tests=tests, expanded=expand(scan.out, classes, requirements, by_id, seen), + ) + + +def discover_versions(only: str | None) -> list[tuple[str, Path]]: + found = [] + drafts = [p.parent for p in ROOT.glob("v*/index.bs") if DRAFT_NAME.fullmatch(p.parent.name)] + for path in sorted(drafts, key=lambda p: int(p.name[1:])): + if only in (None, path.name): + found.append((path.name, path)) + if RELEASES.is_dir(): + for path in sorted(RELEASES.iterdir()): + if (path / "index.bs").exists() and re.fullmatch(r"\d+\.\d+\.\d+", path.name) and only in (None, path.name): + found.append((path.name, path)) + if not found: + raise SpecError(f"no version named {only!r}" if only else "no versions found") + return found diff --git a/tools/tests/test_specbuild.py b/tools/tests/test_specbuild.py new file mode 100644 index 0000000..3632f52 --- /dev/null +++ b/tools/tests/test_specbuild.py @@ -0,0 +1,222 @@ +"""Unit tests for the build tooling. Run with: python3 -m unittest discover -s tools/tests""" + +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import conformance +import release +from specbuild.checks import check_discussion_links, check_issues_index, check_underscore_emphasis, compare_drafts +from specbuild.config import SpecError +from specbuild.fixtures import fixture_path +from specbuild.fixtures import load_tests as read_tests +from specbuild.model import Requirement, Test, Version +from specbuild.render import quote_markup, source_text +from specbuild.scan import parse_attrs +from specbuild.schema_tables import schema_table, type_cell +from specbuild.versions import parse_version + +SRC = Path("src") + + +def requirement(rid: str, body: str, status: str = "", kind: str = "req", klass: str = "linter-core", line: int = 1) -> Requirement: + return Requirement(id=rid, kind=kind, klass=klass, target="linter", since="", removed="", status=status, + section_id="s", section_title="S", line=line, text=body, body=body) + + +def version(name: str, reqs: list[Requirement], tests: list[Test] | None = None) -> Version: + return Version(name=name, source_dir=SRC / name, metadata={}, classes=[], requirements=reqs, tests=tests or [], expanded="") + + +class Attributes(unittest.TestCase): + def test_every_quoting(self): + attrs = parse_attrs(' id="/req/a/b" data-class=linter-core data-status=\'proposed\'') + self.assertEqual(attrs, {"id": "/req/a/b", "data-class": "linter-core", "data-status": "proposed"}) + + +class Fixtures(unittest.TestCase): + def test_subpath_is_allowed_and_escapes_are_not(self): + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + self.assertEqual(fixture_path(d, "legacy/doc.yaml"), d / "legacy/doc.yaml") + for bad in ("../x.yaml", "/etc/hosts", "a/../../x", ""): + with self.assertRaises(ValueError, msg=bad): + fixture_path(d, bad) + + def test_load_tests_reads_ids_and_reports_problems(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + good = root / "conformance/given/present" + good.mkdir(parents=True) + (good / "ruleset.yaml").write_text("rules: {}\n") + (good / "test.json").write_text(json.dumps({"for": "/req/given/evaluate", "purpose": "Runs.", "expect": {"ruleset": "valid"}})) + bad = root / "conformance/given/broken" + bad.mkdir() + (bad / "test.json").write_text(json.dumps({"for": ["/req/x/y"], "purpose": "", "ruleset": "../../x.yaml", "expect": {"nope": 1}})) + tests, errors = read_tests(root) + self.assertEqual([t.id for t in tests], ["/conf/given/broken", "/conf/given/present"]) + self.assertEqual(tests[1].covers, ["/req/given/evaluate"]) + joined = "\n".join(errors) + self.assertIn("'purpose' is missing", joined) + self.assertIn("relative path inside the test directory", joined) + self.assertIn("'expect' keys must be among", joined) + + +class Escaping(unittest.TestCase): + def test_source_text_escapes_outside_code_spans(self): + self.assertEqual(source_text("a `x` c"), "a <b> `x` c") + + def test_quote_markup_strips_links_and_keeps_code(self): + self.assertEqual(quote_markup("A [=linter=] MUST see `$ref` [[!RFC8259]]"), "A linter MUST see $ref RFC8259") + + +SCHEMA = { + "$defs": { + "Severity": {"title": "Severity", "enum": ["error", "warn"]}, + "Thing": {"type": "object", "properties": { + "severity": {"$ref": "#/$defs/Severity", "description": "How bad it is."}, + "tags": {"type": "array", "items": {"type": "string"}, "description": "Labels."}, + "secret": {"type": "string"}, + "loud": {"type": "string", "description": "A linter MUST shout."}, + }, "required": ["severity"]}, + } +} + + +class SchemaTables(unittest.TestCase): + def test_type_cell_links_defined_values_and_describes_the_rest(self): + self.assertEqual(type_cell(SCHEMA, {"$ref": "#/$defs/Severity"}), "string, see [[#rule-severity|Severity]]") + self.assertEqual(type_cell(SCHEMA, {"type": "array", "items": {"type": "string"}}), "array of strings") + + def test_table_reports_missing_descriptions_and_keywords(self): + html, problems = schema_table(SCHEMA, "Thing") + self.assertIn("severitystring, see [[#rule-severity|Severity]]yes", html) + self.assertIn("'secret' has no description", "\n".join(problems)) + self.assertIn("'loud' uses a BCP 14 keyword", "\n".join(problems)) + + +class LineChecks(unittest.TestCase): + def test_discussion_mentions_need_their_link(self): + ok = 'See discussion #13.' + self.assertEqual(check_discussion_links(SRC, ok), []) + self.assertEqual(len(check_discussion_links(SRC, "See discussion #13.")), 1) + self.assertEqual(len(check_discussion_links(SRC, ok.replace("/13", "/12"))), 1) + + def test_underscore_emphasis_is_caught_outside_code(self): + self.assertEqual(check_underscore_emphasis(SRC, "x `_a_` y"), []) + self.assertEqual(len(check_underscore_emphasis(SRC, "_This is informative._")), 1) + + def test_issues_index_link_needs_an_issue(self): + self.assertEqual(check_issues_index(SRC, "Issue: open.\n\nSee [[#issues-index]]."), []) + self.assertEqual(len(check_issues_index(SRC, "See [[#issues-index]].")), 1) + + +class AcrossDrafts(unittest.TestCase): + def test_a_later_draft_lists_the_same_requirements(self): + a = requirement("/req/a/b", "A linter MUST x.") + notices: list[str] = [] + self.assertEqual(compare_drafts(version("v1", [a]), version("v2", [a]), notices), []) + missing = compare_drafts(version("v1", [a]), version("v2", []), notices) + self.assertIn("is missing from the v2 draft", missing[0]) + changed = compare_drafts(version("v1", [a]), version("v2", [requirement("/req/a/b", "A linter MUST y.")]), notices) + self.assertIn("differs between the v1 and v2 drafts", changed[0]) + removed_later = compare_drafts(version("v1", [a]), version("v2", [requirement("/req/a/b", "A linter MUST x.", status="removed")]), notices) + self.assertEqual(removed_later, []) + current_again = compare_drafts(version("v1", [requirement("/req/a/b", "x", status="removed")]), version("v2", [a]), notices) + self.assertIn("stays removed", current_again[0]) + + +class Runner(unittest.TestCase): + def test_results_match_on_named_fields_and_order_when_asked(self): + doc = Path("/tmp/doc.yaml") + actual = [{"code": "b", "path": ["x"], "severity": 1, "source": "/tmp/doc.yaml"}, {"code": "a", "path": [], "severity": 0}] + self.assertIsNone(conformance.compare([{"code": "a"}, {"code": "b", "has": ["severity"], "source": "${document}"}], actual, doc)) + self.assertIn("result 0 does not match", conformance.compare([{"code": "a"}, {"code": "b"}], actual, doc, ordered=True)) + self.assertIn("expected 1 results", conformance.compare([{"code": "a"}], actual, doc)) + + def test_a_linter_that_breaks_the_contract(self): + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + (d / "r.yaml").write_text("rules: {}\n") + (d / "d.yaml").write_text("{}\n") + quiet = conformance.run_linter([sys.executable, "-c", "print('nope')"], d / "r.yaml", d / "d.yaml", d) + self.assertEqual(quiet[0], "broken") + rejected = conformance.run_linter([sys.executable, "-c", "import sys; sys.exit(2)"], d / "r.yaml", d / "d.yaml", d) + self.assertEqual(rejected[0], "rejected") + fine = conformance.run_linter([sys.executable, "-c", "print('[{\"code\": \"r\"}]')"], d / "r.yaml", d / "d.yaml", d) + self.assertEqual(fine[:2], ("results", [{"code": "r"}])) + + +class Release(unittest.TestCase): + def test_stamping_and_metadata(self): + src = ('
    \n
    \n
    \n
    \n' + '
    \n
    \n') + out, stamped = release.stamp_since(src, "1.0.0") + self.assertEqual(stamped, {"/req/a/b"}) + self.assertIn('data-class=x data-since="1.0.0">', out) + meta = "\nbody\n" + edited = release.edit_metadata(meta, {"Date": "2026-01-01", "Title": "U"}, {"Warning"}) + self.assertEqual(edited, "\nbody\n") + + +MINIMAL_BS = """ + +# Conformance # {#conformance} + +
    +Everything. +
    + +# Linting # {#linting} + +## Given ## {#given} + +
    +A linter MUST evaluate `given`. +
    + +
    +A linter SHOULD not. +
    +""" + + +class Parsing(unittest.TestCase): + def test_a_tiny_specification_is_checked(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "v1" + root.mkdir() + (root / "index.bs").write_text(MINIMAL_BS) + (root / "formats.json").write_text("{}") + (root / "ruleset.schema.json").write_text(json.dumps({"$id": "https://spec.openlint.org/v1/draft/ruleset.schema.json", + "$defs": {"Format": {"enum": []}}})) + t = root / "conformance/given/evaluate" + t.mkdir(parents=True) + (t / "ruleset.yaml").write_text("rules: {}\n") + (t / "document.yaml").write_text("{}\n") + (t / "test.json").write_text(json.dumps({"for": ["/req/given/evaluate"], "purpose": "Runs.", "document": "document.yaml", + "expect": {"results": [{"code": "r"}]}})) + with self.assertRaises(SpecError) as caught: + parse_version("v1", root) + message = str(caught.exception) + self.assertIn("/req/given/quiet uses SHOULD", message) + self.assertIn("/req/given/quiet has no conformance test", message) + (root / "index.bs").write_text(MINIMAL_BS.replace("A linter SHOULD not.", "A linter MUST not.") + .replace('id="/req/given/quiet" data-class=linter-core', 'id="/req/given/quiet" data-class=linter-core data-status=removed data-removed="1.1.0"')) + v = parse_version("v1", root) + self.assertEqual([r.id for r in v.requirements], ["/req/given/evaluate", "/req/given/quiet"]) + self.assertEqual(v.tests[0].klass, "linter-core") + self.assertIn('MUST', v.expanded) + + +if __name__ == "__main__": + unittest.main() diff --git a/v1/conformance/aliases/chain/ruleset.yaml b/v1/conformance/aliases/chain/ruleset.yaml new file mode 100644 index 0000000..38f5e4b --- /dev/null +++ b/v1/conformance/aliases/chain/ruleset.yaml @@ -0,0 +1,10 @@ +aliases: + Info: + - $.info + InfoTitle: + - "#Info.title" +rules: + r: + given: "#InfoTitle" + then: + function: truthy diff --git a/v1/conformance/aliases/chain/test.json b/v1/conformance/aliases/chain/test.json new file mode 100644 index 0000000..e677669 --- /dev/null +++ b/v1/conformance/aliases/chain/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/aliases/no-cycle" + ], + "purpose": "An alias defined through another alias, without a cycle, is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/aliases/compose/document.yaml b/v1/conformance/aliases/compose/document.yaml new file mode 100644 index 0000000..f3370ab --- /dev/null +++ b/v1/conformance/aliases/compose/document.yaml @@ -0,0 +1,3 @@ +info: + title: Petstore + description: "" diff --git a/v1/conformance/aliases/compose/ruleset.yaml b/v1/conformance/aliases/compose/ruleset.yaml new file mode 100644 index 0000000..d5a6e41 --- /dev/null +++ b/v1/conformance/aliases/compose/ruleset.yaml @@ -0,0 +1,10 @@ +aliases: + Info: + - $..info + InfoDescription: + - "#Info.description" +rules: + info-description: + given: "#InfoDescription" + then: + function: truthy diff --git a/v1/conformance/aliases/compose/test.json b/v1/conformance/aliases/compose/test.json new file mode 100644 index 0000000..02617ef --- /dev/null +++ b/v1/conformance/aliases/compose/test.json @@ -0,0 +1,19 @@ +{ + "for": [ + "/req/aliases/compose" + ], + "purpose": "An alias defined through another alias expands to the full path.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "info-description", + "path": [ + "info", + "description" + ] + } + ] + } +} diff --git a/v1/conformance/aliases/cycle/ruleset.yaml b/v1/conformance/aliases/cycle/ruleset.yaml new file mode 100644 index 0000000..634c3c3 --- /dev/null +++ b/v1/conformance/aliases/cycle/ruleset.yaml @@ -0,0 +1,10 @@ +aliases: + A: + - "#B" + B: + - "#A" +rules: + r: + given: "#A" + then: + function: truthy diff --git a/v1/conformance/aliases/cycle/test.json b/v1/conformance/aliases/cycle/test.json new file mode 100644 index 0000000..b409219 --- /dev/null +++ b/v1/conformance/aliases/cycle/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/aliases/no-cycle" + ], + "purpose": "Two aliases that refer to each other are invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/aliases/name-valid/ruleset.yaml b/v1/conformance/aliases/name-valid/ruleset.yaml new file mode 100644 index 0000000..a390302 --- /dev/null +++ b/v1/conformance/aliases/name-valid/ruleset.yaml @@ -0,0 +1,8 @@ +aliases: + Info_v2-x: + - $.info +rules: + r: + given: "#Info_v2-x" + then: + function: truthy diff --git a/v1/conformance/aliases/name-valid/test.json b/v1/conformance/aliases/name-valid/test.json new file mode 100644 index 0000000..606f21d --- /dev/null +++ b/v1/conformance/aliases/name-valid/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "An alias name of letters, digits, underscores, and hyphens starting with a letter is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/aliases/name/ruleset.yaml b/v1/conformance/aliases/name/ruleset.yaml new file mode 100644 index 0000000..3202e5e --- /dev/null +++ b/v1/conformance/aliases/name/ruleset.yaml @@ -0,0 +1,8 @@ +aliases: + 1bad: + - $.info +rules: + r: + given: "#1bad" + then: + function: truthy diff --git a/v1/conformance/aliases/name/test.json b/v1/conformance/aliases/name/test.json new file mode 100644 index 0000000..ccdc6db --- /dev/null +++ b/v1/conformance/aliases/name/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "An alias name starting with a digit is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/aliases/scoped-form-missing-formats/ruleset.yaml b/v1/conformance/aliases/scoped-form-missing-formats/ruleset.yaml new file mode 100644 index 0000000..f101a0a --- /dev/null +++ b/v1/conformance/aliases/scoped-form-missing-formats/ruleset.yaml @@ -0,0 +1,10 @@ +aliases: + Info: + targets: + - given: + - $.info +rules: + r: + given: "#Info" + then: + function: truthy diff --git a/v1/conformance/aliases/scoped-form-missing-formats/test.json b/v1/conformance/aliases/scoped-form-missing-formats/test.json new file mode 100644 index 0000000..4e30290 --- /dev/null +++ b/v1/conformance/aliases/scoped-form-missing-formats/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A scoped alias target without `formats` is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/aliases/scoped-form-valid/ruleset.yaml b/v1/conformance/aliases/scoped-form-valid/ruleset.yaml new file mode 100644 index 0000000..215ff18 --- /dev/null +++ b/v1/conformance/aliases/scoped-form-valid/ruleset.yaml @@ -0,0 +1,16 @@ +aliases: + ParameterObject: + targets: + - formats: [oas2] + given: + - $.parameters[*] + - formats: [oas3] + given: + - $.components.parameters[*] +rules: + parameter-name: + formats: [oas2, oas3] + given: "#ParameterObject" + then: + field: name + function: truthy diff --git a/v1/conformance/aliases/scoped-form-valid/test.json b/v1/conformance/aliases/scoped-form-valid/test.json new file mode 100644 index 0000000..793bcb0 --- /dev/null +++ b/v1/conformance/aliases/scoped-form-valid/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A scoped alias whose every target has `formats` and `given` is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/aliases/scoped-last-target/document.yaml b/v1/conformance/aliases/scoped-last-target/document.yaml new file mode 100644 index 0000000..83b97ff --- /dev/null +++ b/v1/conformance/aliases/scoped-last-target/document.yaml @@ -0,0 +1,4 @@ +openapi: 3.1.0 +info: + version: "1" +paths: {} diff --git a/v1/conformance/aliases/scoped-last-target/ruleset.yaml b/v1/conformance/aliases/scoped-last-target/ruleset.yaml new file mode 100644 index 0000000..c6b1b9d --- /dev/null +++ b/v1/conformance/aliases/scoped-last-target/ruleset.yaml @@ -0,0 +1,14 @@ +aliases: + I: + targets: + - formats: [oas3_1] + given: ["$.paths"] + - formats: [oas3] + given: ["$.info"] +rules: + r: + formats: [oas3, oas3_1] + given: "#I" + then: + field: title + function: truthy diff --git a/v1/conformance/aliases/scoped-last-target/test.json b/v1/conformance/aliases/scoped-last-target/test.json new file mode 100644 index 0000000..f651a7b --- /dev/null +++ b/v1/conformance/aliases/scoped-last-target/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/aliases/scoped" + ], + "purpose": "For an OpenAPI 3.1 document, which has both `oas3` and `oas3_1`, only the last matching target is used.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "info" + ] + } + ] + } +} diff --git a/v1/conformance/aliases/scoped-no-target/document.yaml b/v1/conformance/aliases/scoped-no-target/document.yaml new file mode 100644 index 0000000..83b97ff --- /dev/null +++ b/v1/conformance/aliases/scoped-no-target/document.yaml @@ -0,0 +1,4 @@ +openapi: 3.1.0 +info: + version: "1" +paths: {} diff --git a/v1/conformance/aliases/scoped-no-target/ruleset.yaml b/v1/conformance/aliases/scoped-no-target/ruleset.yaml new file mode 100644 index 0000000..636b235 --- /dev/null +++ b/v1/conformance/aliases/scoped-no-target/ruleset.yaml @@ -0,0 +1,16 @@ +aliases: + I: + targets: + - formats: [oas2] + given: ["$.info"] +rules: + swagger-version: + formats: [oas2] + given: $.swagger + then: + function: truthy + r: + given: "#I" + then: + field: title + function: truthy diff --git a/v1/conformance/aliases/scoped-no-target/test.json b/v1/conformance/aliases/scoped-no-target/test.json new file mode 100644 index 0000000..79a4784 --- /dev/null +++ b/v1/conformance/aliases/scoped-no-target/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/aliases/scoped" + ], + "purpose": "A scoped alias with no target for the document's formats matches nothing; only the format warning is reported.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "unrecognized-format" + } + ] + } +} diff --git a/v1/conformance/aliases/scoped-oas2/document.yaml b/v1/conformance/aliases/scoped-oas2/document.yaml new file mode 100644 index 0000000..0a7c3a3 --- /dev/null +++ b/v1/conformance/aliases/scoped-oas2/document.yaml @@ -0,0 +1,9 @@ +swagger: "2.0" +info: + title: Petstore + version: 1.0.0 +paths: {} +parameters: + limit: + name: "" + in: query diff --git a/v1/conformance/aliases/scoped-oas2/ruleset.yaml b/v1/conformance/aliases/scoped-oas2/ruleset.yaml new file mode 100644 index 0000000..215ff18 --- /dev/null +++ b/v1/conformance/aliases/scoped-oas2/ruleset.yaml @@ -0,0 +1,16 @@ +aliases: + ParameterObject: + targets: + - formats: [oas2] + given: + - $.parameters[*] + - formats: [oas3] + given: + - $.components.parameters[*] +rules: + parameter-name: + formats: [oas2, oas3] + given: "#ParameterObject" + then: + field: name + function: truthy diff --git a/v1/conformance/aliases/scoped-oas2/test.json b/v1/conformance/aliases/scoped-oas2/test.json new file mode 100644 index 0000000..c097584 --- /dev/null +++ b/v1/conformance/aliases/scoped-oas2/test.json @@ -0,0 +1,20 @@ +{ + "for": [ + "/req/aliases/scoped" + ], + "purpose": "On an OpenAPI 2 document, the scoped alias uses the `oas2` target.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "parameter-name", + "path": [ + "parameters", + "limit", + "name" + ] + } + ] + } +} diff --git a/v1/conformance/aliases/scoped-oas3/document.yaml b/v1/conformance/aliases/scoped-oas3/document.yaml new file mode 100644 index 0000000..0c575bf --- /dev/null +++ b/v1/conformance/aliases/scoped-oas3/document.yaml @@ -0,0 +1,10 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: {} +components: + parameters: + limit: + name: "" + in: query diff --git a/v1/conformance/aliases/scoped-oas3/ruleset.yaml b/v1/conformance/aliases/scoped-oas3/ruleset.yaml new file mode 100644 index 0000000..215ff18 --- /dev/null +++ b/v1/conformance/aliases/scoped-oas3/ruleset.yaml @@ -0,0 +1,16 @@ +aliases: + ParameterObject: + targets: + - formats: [oas2] + given: + - $.parameters[*] + - formats: [oas3] + given: + - $.components.parameters[*] +rules: + parameter-name: + formats: [oas2, oas3] + given: "#ParameterObject" + then: + field: name + function: truthy diff --git a/v1/conformance/aliases/scoped-oas3/test.json b/v1/conformance/aliases/scoped-oas3/test.json new file mode 100644 index 0000000..3ba4407 --- /dev/null +++ b/v1/conformance/aliases/scoped-oas3/test.json @@ -0,0 +1,21 @@ +{ + "for": [ + "/req/aliases/scoped" + ], + "purpose": "On an OpenAPI 3 document, the scoped alias uses the `oas3` target.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "parameter-name", + "path": [ + "components", + "parameters", + "limit", + "name" + ] + } + ] + } +} diff --git a/v1/conformance/aliases/simple-form-string/ruleset.yaml b/v1/conformance/aliases/simple-form-string/ruleset.yaml new file mode 100644 index 0000000..60e4586 --- /dev/null +++ b/v1/conformance/aliases/simple-form-string/ruleset.yaml @@ -0,0 +1,7 @@ +aliases: + Info: $.info +rules: + r: + given: "#Info" + then: + function: truthy diff --git a/v1/conformance/aliases/simple-form-string/test.json b/v1/conformance/aliases/simple-form-string/test.json new file mode 100644 index 0000000..5d98272 --- /dev/null +++ b/v1/conformance/aliases/simple-form-string/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A simple alias given as a bare string is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/aliases/simple-form-valid/ruleset.yaml b/v1/conformance/aliases/simple-form-valid/ruleset.yaml new file mode 100644 index 0000000..d49feb5 --- /dev/null +++ b/v1/conformance/aliases/simple-form-valid/ruleset.yaml @@ -0,0 +1,9 @@ +aliases: + Info: + - $.info + - $.externalDocs +rules: + r: + given: "#Info" + then: + function: truthy diff --git a/v1/conformance/aliases/simple-form-valid/test.json b/v1/conformance/aliases/simple-form-valid/test.json new file mode 100644 index 0000000..b6aeece --- /dev/null +++ b/v1/conformance/aliases/simple-form-valid/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A simple alias that is an array of path expressions is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/custom-functions/declaration-not-array/functions/isEmptyTitle.js b/v1/conformance/custom-functions/declaration-not-array/functions/isEmptyTitle.js new file mode 100644 index 0000000..729f0c1 --- /dev/null +++ b/v1/conformance/custom-functions/declaration-not-array/functions/isEmptyTitle.js @@ -0,0 +1,6 @@ +module.exports = (targetVal) => { + if (typeof targetVal !== 'string' || targetVal.trim() === '') { + return [{ message: 'Title is empty.' }]; + } + return []; +}; diff --git a/v1/conformance/custom-functions/declaration-not-array/ruleset.yaml b/v1/conformance/custom-functions/declaration-not-array/ruleset.yaml new file mode 100644 index 0000000..d17c1a3 --- /dev/null +++ b/v1/conformance/custom-functions/declaration-not-array/ruleset.yaml @@ -0,0 +1,6 @@ +functions: isEmptyTitle +rules: + r: + given: $.info.title + then: + function: isEmptyTitle diff --git a/v1/conformance/custom-functions/declaration-not-array/test.json b/v1/conformance/custom-functions/declaration-not-array/test.json new file mode 100644 index 0000000..42fd06e --- /dev/null +++ b/v1/conformance/custom-functions/declaration-not-array/test.json @@ -0,0 +1,13 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A `functions` value that is a string is invalid.", + "ruleset": "ruleset.yaml", + "files": [ + "functions/isEmptyTitle.js" + ], + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/custom-functions/declaration-valid/checks/isEmptyTitle.js b/v1/conformance/custom-functions/declaration-valid/checks/isEmptyTitle.js new file mode 100644 index 0000000..07b7e67 --- /dev/null +++ b/v1/conformance/custom-functions/declaration-valid/checks/isEmptyTitle.js @@ -0,0 +1 @@ +module.exports = (v) => (typeof v === 'string' && v.trim() !== '' ? [] : [{ message: 'Title is empty.' }]); diff --git a/v1/conformance/custom-functions/declaration-valid/ruleset.yaml b/v1/conformance/custom-functions/declaration-valid/ruleset.yaml new file mode 100644 index 0000000..16ea735 --- /dev/null +++ b/v1/conformance/custom-functions/declaration-valid/ruleset.yaml @@ -0,0 +1,8 @@ +functions: + - isEmptyTitle +functionsDir: ./checks +rules: + r: + given: $.info.title + then: + function: isEmptyTitle diff --git a/v1/conformance/custom-functions/declaration-valid/test.json b/v1/conformance/custom-functions/declaration-valid/test.json new file mode 100644 index 0000000..83064cd --- /dev/null +++ b/v1/conformance/custom-functions/declaration-valid/test.json @@ -0,0 +1,13 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "`functions` as an array of strings with a `functionsDir` string is valid.", + "ruleset": "ruleset.yaml", + "files": [ + "checks/isEmptyTitle.js" + ], + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/custom-functions/invoke/document.yaml b/v1/conformance/custom-functions/invoke/document.yaml new file mode 100644 index 0000000..85c5d4e --- /dev/null +++ b/v1/conformance/custom-functions/invoke/document.yaml @@ -0,0 +1,2 @@ +info: + title: An evil and bad title diff --git a/v1/conformance/custom-functions/invoke/functions/forbiddenWords.js b/v1/conformance/custom-functions/invoke/functions/forbiddenWords.js new file mode 100644 index 0000000..76254ea --- /dev/null +++ b/v1/conformance/custom-functions/invoke/functions/forbiddenWords.js @@ -0,0 +1,9 @@ +module.exports = (targetVal, options) => { + const problems = []; + for (const word of options.forbidden) { + if (String(targetVal).includes(word)) { + problems.push({ message: `Contains "${word}".` }); + } + } + return problems; +}; diff --git a/v1/conformance/custom-functions/invoke/ruleset.yaml b/v1/conformance/custom-functions/invoke/ruleset.yaml new file mode 100644 index 0000000..ee1bb5a --- /dev/null +++ b/v1/conformance/custom-functions/invoke/ruleset.yaml @@ -0,0 +1,9 @@ +functions: + - forbiddenWords +rules: + no-forbidden-words: + given: $.info.title + then: + function: forbiddenWords + functionOptions: + forbidden: [evil, bad] diff --git a/v1/conformance/custom-functions/invoke/test.json b/v1/conformance/custom-functions/invoke/test.json new file mode 100644 index 0000000..a9bcc50 --- /dev/null +++ b/v1/conformance/custom-functions/invoke/test.json @@ -0,0 +1,23 @@ +{ + "for": [ + "/req/custom-functions/invoke" + ], + "purpose": "The function receives the value and the options, and each problem it returns becomes a result with its message.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "functions/forbiddenWords.js" + ], + "expect": { + "results": [ + { + "code": "no-forbidden-words", + "message": "Contains \"evil\"." + }, + { + "code": "no-forbidden-words", + "message": "Contains \"bad\"." + } + ] + } +} diff --git a/v1/conformance/custom-functions/javascript-promise/document.yaml b/v1/conformance/custom-functions/javascript-promise/document.yaml new file mode 100644 index 0000000..ddd57cf --- /dev/null +++ b/v1/conformance/custom-functions/javascript-promise/document.yaml @@ -0,0 +1,2 @@ +info: + title: x diff --git a/v1/conformance/custom-functions/javascript-promise/functions/later.js b/v1/conformance/custom-functions/javascript-promise/functions/later.js new file mode 100644 index 0000000..3ec29d6 --- /dev/null +++ b/v1/conformance/custom-functions/javascript-promise/functions/later.js @@ -0,0 +1 @@ +module.exports = async (value, options) => [{ message: 'from a promise' }]; diff --git a/v1/conformance/custom-functions/javascript-promise/ruleset.yaml b/v1/conformance/custom-functions/javascript-promise/ruleset.yaml new file mode 100644 index 0000000..4f672dc --- /dev/null +++ b/v1/conformance/custom-functions/javascript-promise/ruleset.yaml @@ -0,0 +1,6 @@ +functions: [later] +rules: + r: + given: $.info + then: + function: later diff --git a/v1/conformance/custom-functions/javascript-promise/test.json b/v1/conformance/custom-functions/javascript-promise/test.json new file mode 100644 index 0000000..e416988 --- /dev/null +++ b/v1/conformance/custom-functions/javascript-promise/test.json @@ -0,0 +1,19 @@ +{ + "for": [ + "/req/custom-functions/javascript-promise" + ], + "purpose": "A function that returns a promise is awaited, and the problems it resolves to are reported.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "functions/later.js" + ], + "expect": { + "results": [ + { + "code": "r", + "message": "from a promise" + } + ] + } +} diff --git a/v1/conformance/custom-functions/load-custom-dir/checks/isEmptyTitle.js b/v1/conformance/custom-functions/load-custom-dir/checks/isEmptyTitle.js new file mode 100644 index 0000000..729f0c1 --- /dev/null +++ b/v1/conformance/custom-functions/load-custom-dir/checks/isEmptyTitle.js @@ -0,0 +1,6 @@ +module.exports = (targetVal) => { + if (typeof targetVal !== 'string' || targetVal.trim() === '') { + return [{ message: 'Title is empty.' }]; + } + return []; +}; diff --git a/v1/conformance/custom-functions/load-custom-dir/document.yaml b/v1/conformance/custom-functions/load-custom-dir/document.yaml new file mode 100644 index 0000000..ef93b31 --- /dev/null +++ b/v1/conformance/custom-functions/load-custom-dir/document.yaml @@ -0,0 +1,2 @@ +info: + title: " " diff --git a/v1/conformance/custom-functions/load-custom-dir/ruleset.yaml b/v1/conformance/custom-functions/load-custom-dir/ruleset.yaml new file mode 100644 index 0000000..4173526 --- /dev/null +++ b/v1/conformance/custom-functions/load-custom-dir/ruleset.yaml @@ -0,0 +1,8 @@ +functions: + - isEmptyTitle +functionsDir: ./checks +rules: + title-filled: + given: $.info.title + then: + function: isEmptyTitle diff --git a/v1/conformance/custom-functions/load-custom-dir/test.json b/v1/conformance/custom-functions/load-custom-dir/test.json new file mode 100644 index 0000000..247e34f --- /dev/null +++ b/v1/conformance/custom-functions/load-custom-dir/test.json @@ -0,0 +1,19 @@ +{ + "for": [ + "/req/custom-functions/javascript-load" + ], + "purpose": "With `functionsDir`, the function is loaded from that directory.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "checks/isEmptyTitle.js" + ], + "expect": { + "results": [ + { + "code": "title-filled", + "message": "Title is empty." + } + ] + } +} diff --git a/v1/conformance/custom-functions/load-default-dir/document.yaml b/v1/conformance/custom-functions/load-default-dir/document.yaml new file mode 100644 index 0000000..ef93b31 --- /dev/null +++ b/v1/conformance/custom-functions/load-default-dir/document.yaml @@ -0,0 +1,2 @@ +info: + title: " " diff --git a/v1/conformance/custom-functions/load-default-dir/functions/isEmptyTitle.js b/v1/conformance/custom-functions/load-default-dir/functions/isEmptyTitle.js new file mode 100644 index 0000000..729f0c1 --- /dev/null +++ b/v1/conformance/custom-functions/load-default-dir/functions/isEmptyTitle.js @@ -0,0 +1,6 @@ +module.exports = (targetVal) => { + if (typeof targetVal !== 'string' || targetVal.trim() === '') { + return [{ message: 'Title is empty.' }]; + } + return []; +}; diff --git a/v1/conformance/custom-functions/load-default-dir/ruleset.yaml b/v1/conformance/custom-functions/load-default-dir/ruleset.yaml new file mode 100644 index 0000000..e3fd70d --- /dev/null +++ b/v1/conformance/custom-functions/load-default-dir/ruleset.yaml @@ -0,0 +1,7 @@ +functions: + - isEmptyTitle +rules: + title-filled: + given: $.info.title + then: + function: isEmptyTitle diff --git a/v1/conformance/custom-functions/load-default-dir/test.json b/v1/conformance/custom-functions/load-default-dir/test.json new file mode 100644 index 0000000..bb6dfa3 --- /dev/null +++ b/v1/conformance/custom-functions/load-default-dir/test.json @@ -0,0 +1,19 @@ +{ + "for": [ + "/req/custom-functions/javascript-load" + ], + "purpose": "A function named in `functions` is loaded from `functions/.js` next to the ruleset.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "functions/isEmptyTitle.js" + ], + "expect": { + "results": [ + { + "code": "title-filled", + "message": "Title is empty." + } + ] + } +} diff --git a/v1/conformance/custom-functions/missing/document.yaml b/v1/conformance/custom-functions/missing/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/custom-functions/missing/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/custom-functions/missing/ruleset.yaml b/v1/conformance/custom-functions/missing/ruleset.yaml new file mode 100644 index 0000000..a6f8e6a --- /dev/null +++ b/v1/conformance/custom-functions/missing/ruleset.yaml @@ -0,0 +1,7 @@ +functions: + - doesNotExist +rules: + r: + given: $ + then: + function: doesNotExist diff --git a/v1/conformance/custom-functions/missing/test.json b/v1/conformance/custom-functions/missing/test.json new file mode 100644 index 0000000..8ec0e4b --- /dev/null +++ b/v1/conformance/custom-functions/missing/test.json @@ -0,0 +1,11 @@ +{ + "for": [ + "/req/custom-functions/missing" + ], + "purpose": "A ruleset naming a function whose file does not exist is rejected.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "rejected": true + } +} diff --git a/v1/conformance/custom-functions/present/document.yaml b/v1/conformance/custom-functions/present/document.yaml new file mode 100644 index 0000000..2542544 --- /dev/null +++ b/v1/conformance/custom-functions/present/document.yaml @@ -0,0 +1,2 @@ +info: + title: "" diff --git a/v1/conformance/custom-functions/present/functions/isEmptyTitle.js b/v1/conformance/custom-functions/present/functions/isEmptyTitle.js new file mode 100644 index 0000000..07b7e67 --- /dev/null +++ b/v1/conformance/custom-functions/present/functions/isEmptyTitle.js @@ -0,0 +1 @@ +module.exports = (v) => (typeof v === 'string' && v.trim() !== '' ? [] : [{ message: 'Title is empty.' }]); diff --git a/v1/conformance/custom-functions/present/ruleset.yaml b/v1/conformance/custom-functions/present/ruleset.yaml new file mode 100644 index 0000000..e3fd70d --- /dev/null +++ b/v1/conformance/custom-functions/present/ruleset.yaml @@ -0,0 +1,7 @@ +functions: + - isEmptyTitle +rules: + title-filled: + given: $.info.title + then: + function: isEmptyTitle diff --git a/v1/conformance/custom-functions/present/test.json b/v1/conformance/custom-functions/present/test.json new file mode 100644 index 0000000..6ae9792 --- /dev/null +++ b/v1/conformance/custom-functions/present/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/custom-functions/missing" + ], + "purpose": "A ruleset naming a function whose file exists is loaded and the function runs.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "functions/isEmptyTitle.js" + ], + "expect": { + "results": [ + { + "code": "title-filled" + } + ] + } +} diff --git a/v1/conformance/custom-functions/problem-path/document.yaml b/v1/conformance/custom-functions/problem-path/document.yaml new file mode 100644 index 0000000..ddd57cf --- /dev/null +++ b/v1/conformance/custom-functions/problem-path/document.yaml @@ -0,0 +1,2 @@ +info: + title: x diff --git a/v1/conformance/custom-functions/problem-path/functions/elsewhere.js b/v1/conformance/custom-functions/problem-path/functions/elsewhere.js new file mode 100644 index 0000000..5bdf0b7 --- /dev/null +++ b/v1/conformance/custom-functions/problem-path/functions/elsewhere.js @@ -0,0 +1 @@ +module.exports = (value, options) => [{ message: 'nowhere', path: ['nope', 'deeper'] }]; diff --git a/v1/conformance/custom-functions/problem-path/functions/pathed.js b/v1/conformance/custom-functions/problem-path/functions/pathed.js new file mode 100644 index 0000000..254b07a --- /dev/null +++ b/v1/conformance/custom-functions/problem-path/functions/pathed.js @@ -0,0 +1 @@ +module.exports = (value, options) => [{ message: 'at the title', path: ['info', 'title'] }]; diff --git a/v1/conformance/custom-functions/problem-path/ruleset.yaml b/v1/conformance/custom-functions/problem-path/ruleset.yaml new file mode 100644 index 0000000..d5a6581 --- /dev/null +++ b/v1/conformance/custom-functions/problem-path/ruleset.yaml @@ -0,0 +1,10 @@ +functions: [pathed, elsewhere] +rules: + pathed: + given: $.info + then: + function: pathed + elsewhere: + given: $.info + then: + function: elsewhere diff --git a/v1/conformance/custom-functions/problem-path/test.json b/v1/conformance/custom-functions/problem-path/test.json new file mode 100644 index 0000000..0ad895d --- /dev/null +++ b/v1/conformance/custom-functions/problem-path/test.json @@ -0,0 +1,27 @@ +{ + "for": [ + "/req/custom-functions/problem-path" + ], + "purpose": "A problem with a `path` is reported there; one whose path is not in the document is reported at the root.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "functions/pathed.js", + "functions/elsewhere.js" + ], + "expect": { + "results": [ + { + "code": "pathed", + "path": [ + "info", + "title" + ] + }, + { + "code": "elsewhere", + "path": [] + } + ] + } +} diff --git a/v1/conformance/custom-functions/returns-nothing/document.yaml b/v1/conformance/custom-functions/returns-nothing/document.yaml new file mode 100644 index 0000000..ddd57cf --- /dev/null +++ b/v1/conformance/custom-functions/returns-nothing/document.yaml @@ -0,0 +1,2 @@ +info: + title: x diff --git a/v1/conformance/custom-functions/returns-nothing/functions/nothing.js b/v1/conformance/custom-functions/returns-nothing/functions/nothing.js new file mode 100644 index 0000000..9f329c9 --- /dev/null +++ b/v1/conformance/custom-functions/returns-nothing/functions/nothing.js @@ -0,0 +1 @@ +module.exports = (value, options) => { return; }; diff --git a/v1/conformance/custom-functions/returns-nothing/functions/one.js b/v1/conformance/custom-functions/returns-nothing/functions/one.js new file mode 100644 index 0000000..98235b0 --- /dev/null +++ b/v1/conformance/custom-functions/returns-nothing/functions/one.js @@ -0,0 +1 @@ +module.exports = (value, options) => [{ message: 'one problem' }]; diff --git a/v1/conformance/custom-functions/returns-nothing/ruleset.yaml b/v1/conformance/custom-functions/returns-nothing/ruleset.yaml new file mode 100644 index 0000000..bd99f4a --- /dev/null +++ b/v1/conformance/custom-functions/returns-nothing/ruleset.yaml @@ -0,0 +1,10 @@ +functions: [nothing, one] +rules: + quiet: + given: $.info + then: + function: nothing + loud: + given: $.info + then: + function: one diff --git a/v1/conformance/custom-functions/returns-nothing/test.json b/v1/conformance/custom-functions/returns-nothing/test.json new file mode 100644 index 0000000..9594e7b --- /dev/null +++ b/v1/conformance/custom-functions/returns-nothing/test.json @@ -0,0 +1,22 @@ +{ + "for": [ + "/req/custom-functions/no-problems" + ], + "purpose": "A function that returns nothing reports no problems, while one that returns a problem does.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "functions/nothing.js", + "functions/one.js" + ], + "expect": { + "results": [ + { + "code": "loud", + "path": [ + "info" + ] + } + ] + } +} diff --git a/v1/conformance/document/empty-file/document.yaml b/v1/conformance/document/empty-file/document.yaml new file mode 100644 index 0000000..e69de29 diff --git a/v1/conformance/document/empty-file/ruleset.yaml b/v1/conformance/document/empty-file/ruleset.yaml new file mode 100644 index 0000000..b2d6c95 --- /dev/null +++ b/v1/conformance/document/empty-file/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + r: + given: $ + then: + function: truthy diff --git a/v1/conformance/document/empty-file/test.json b/v1/conformance/document/empty-file/test.json new file mode 100644 index 0000000..fba7497 --- /dev/null +++ b/v1/conformance/document/empty-file/test.json @@ -0,0 +1,16 @@ +{ + "for": [ + "/req/document/any-root" + ], + "purpose": "An empty file is the document `null`, and a rule on `$` sees it.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [] + } + ] + } +} diff --git a/v1/conformance/document/root-array/document.yaml b/v1/conformance/document/root-array/document.yaml new file mode 100644 index 0000000..44e2ace --- /dev/null +++ b/v1/conformance/document/root-array/document.yaml @@ -0,0 +1 @@ +[1, 2] diff --git a/v1/conformance/document/root-array/ruleset.yaml b/v1/conformance/document/root-array/ruleset.yaml new file mode 100644 index 0000000..1c6955a --- /dev/null +++ b/v1/conformance/document/root-array/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + r: + given: $ + then: + function: falsy diff --git a/v1/conformance/document/root-array/test.json b/v1/conformance/document/root-array/test.json new file mode 100644 index 0000000..ea671e1 --- /dev/null +++ b/v1/conformance/document/root-array/test.json @@ -0,0 +1,16 @@ +{ + "for": [ + "/req/document/any-root" + ], + "purpose": "A document whose root is an array is linted, and `$` selects the array.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [] + } + ] + } +} diff --git a/v1/conformance/document/utf8-keys/document.yaml b/v1/conformance/document/utf8-keys/document.yaml new file mode 100644 index 0000000..c5ebd4a --- /dev/null +++ b/v1/conformance/document/utf8-keys/document.yaml @@ -0,0 +1,2 @@ +é: "" +😀: "" diff --git a/v1/conformance/document/utf8-keys/ruleset.yaml b/v1/conformance/document/utf8-keys/ruleset.yaml new file mode 100644 index 0000000..299dbab --- /dev/null +++ b/v1/conformance/document/utf8-keys/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + r: + given: $ + then: + field: "é" + function: truthy + s: + given: $ + then: + field: "😀" + function: truthy diff --git a/v1/conformance/document/utf8-keys/test.json b/v1/conformance/document/utf8-keys/test.json new file mode 100644 index 0000000..7f4335d --- /dev/null +++ b/v1/conformance/document/utf8-keys/test.json @@ -0,0 +1,24 @@ +{ + "for": [ + "/req/document/encoding" + ], + "purpose": "A document is read as UTF-8, so non-ASCII keys arrive intact in `path`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "\u00e9" + ] + }, + { + "code": "s", + "path": [ + "\ud83d\ude00" + ] + } + ] + } +} diff --git a/v1/conformance/extends/base-invalid/base.yaml b/v1/conformance/extends/base-invalid/base.yaml new file mode 100644 index 0000000..2781e04 --- /dev/null +++ b/v1/conformance/extends/base-invalid/base.yaml @@ -0,0 +1,5 @@ +rules: + r: + given: paths + then: + function: truthy diff --git a/v1/conformance/extends/base-invalid/ruleset.yaml b/v1/conformance/extends/base-invalid/ruleset.yaml new file mode 100644 index 0000000..ea42faa --- /dev/null +++ b/v1/conformance/extends/base-invalid/ruleset.yaml @@ -0,0 +1 @@ +extends: ./base.yaml diff --git a/v1/conformance/extends/base-invalid/test.json b/v1/conformance/extends/base-invalid/test.json new file mode 100644 index 0000000..54b2782 --- /dev/null +++ b/v1/conformance/extends/base-invalid/test.json @@ -0,0 +1,13 @@ +{ + "for": [ + "/req/linter/reject-invalid" + ], + "purpose": "A ruleset that extends a ruleset with an invalid `given` is rejected.", + "ruleset": "ruleset.yaml", + "files": [ + "base.yaml" + ], + "expect": { + "rejected": true + } +} diff --git a/v1/conformance/extends/entry-forms-valid/a.yaml b/v1/conformance/extends/entry-forms-valid/a.yaml new file mode 100644 index 0000000..603ce11 --- /dev/null +++ b/v1/conformance/extends/entry-forms-valid/a.yaml @@ -0,0 +1,5 @@ +rules: + a-rule: + given: $ + then: + function: falsy diff --git a/v1/conformance/extends/entry-forms-valid/b.yaml b/v1/conformance/extends/entry-forms-valid/b.yaml new file mode 100644 index 0000000..a51321f --- /dev/null +++ b/v1/conformance/extends/entry-forms-valid/b.yaml @@ -0,0 +1,5 @@ +rules: + b-rule: + given: $ + then: + function: falsy diff --git a/v1/conformance/extends/entry-forms-valid/base.yaml b/v1/conformance/extends/entry-forms-valid/base.yaml new file mode 100644 index 0000000..81db109 --- /dev/null +++ b/v1/conformance/extends/entry-forms-valid/base.yaml @@ -0,0 +1,5 @@ +rules: + base-rule: + given: $ + then: + function: falsy diff --git a/v1/conformance/extends/entry-forms-valid/c.yaml b/v1/conformance/extends/entry-forms-valid/c.yaml new file mode 100644 index 0000000..9481f54 --- /dev/null +++ b/v1/conformance/extends/entry-forms-valid/c.yaml @@ -0,0 +1,5 @@ +rules: + c-rule: + given: $ + then: + function: falsy diff --git a/v1/conformance/extends/entry-forms-valid/ruleset.yaml b/v1/conformance/extends/entry-forms-valid/ruleset.yaml new file mode 100644 index 0000000..13c2242 --- /dev/null +++ b/v1/conformance/extends/entry-forms-valid/ruleset.yaml @@ -0,0 +1,5 @@ +extends: + - ./base.yaml + - [./a.yaml, recommended] + - [./b.yaml, all] + - [./c.yaml, "off"] diff --git a/v1/conformance/extends/entry-forms-valid/test.json b/v1/conformance/extends/entry-forms-valid/test.json new file mode 100644 index 0000000..e214dd6 --- /dev/null +++ b/v1/conformance/extends/entry-forms-valid/test.json @@ -0,0 +1,16 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A string entry and tuples with each of the three modes are valid.", + "ruleset": "ruleset.yaml", + "files": [ + "base.yaml", + "a.yaml", + "b.yaml", + "c.yaml" + ], + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/extends/entry-invalid-mode/base.yaml b/v1/conformance/extends/entry-invalid-mode/base.yaml new file mode 100644 index 0000000..4608429 --- /dev/null +++ b/v1/conformance/extends/entry-invalid-mode/base.yaml @@ -0,0 +1,5 @@ +rules: + base-rule: + given: $ + then: + function: truthy diff --git a/v1/conformance/extends/entry-invalid-mode/ruleset.yaml b/v1/conformance/extends/entry-invalid-mode/ruleset.yaml new file mode 100644 index 0000000..69cc85a --- /dev/null +++ b/v1/conformance/extends/entry-invalid-mode/ruleset.yaml @@ -0,0 +1,2 @@ +extends: + - [./base.yaml, strict] diff --git a/v1/conformance/extends/entry-invalid-mode/test.json b/v1/conformance/extends/entry-invalid-mode/test.json new file mode 100644 index 0000000..f14e760 --- /dev/null +++ b/v1/conformance/extends/entry-invalid-mode/test.json @@ -0,0 +1,13 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "An extends tuple with the mode `strict` is invalid.", + "ruleset": "ruleset.yaml", + "files": [ + "base.yaml" + ], + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/extends/entry-tuple-length/base.yaml b/v1/conformance/extends/entry-tuple-length/base.yaml new file mode 100644 index 0000000..4608429 --- /dev/null +++ b/v1/conformance/extends/entry-tuple-length/base.yaml @@ -0,0 +1,5 @@ +rules: + base-rule: + given: $ + then: + function: truthy diff --git a/v1/conformance/extends/entry-tuple-length/ruleset.yaml b/v1/conformance/extends/entry-tuple-length/ruleset.yaml new file mode 100644 index 0000000..13a903b --- /dev/null +++ b/v1/conformance/extends/entry-tuple-length/ruleset.yaml @@ -0,0 +1,2 @@ +extends: + - [./base.yaml, all, extra] diff --git a/v1/conformance/extends/entry-tuple-length/test.json b/v1/conformance/extends/entry-tuple-length/test.json new file mode 100644 index 0000000..71dd8fb --- /dev/null +++ b/v1/conformance/extends/entry-tuple-length/test.json @@ -0,0 +1,13 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "An extends tuple with a third element is valid; elements after the mode are ignored.", + "ruleset": "ruleset.yaml", + "files": [ + "base.yaml" + ], + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/extends/load/base.yaml b/v1/conformance/extends/load/base.yaml new file mode 100644 index 0000000..0f7bb57 --- /dev/null +++ b/v1/conformance/extends/load/base.yaml @@ -0,0 +1,7 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/extends/load/document.yaml b/v1/conformance/extends/load/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/extends/load/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/extends/load/ruleset.yaml b/v1/conformance/extends/load/ruleset.yaml new file mode 100644 index 0000000..237a8ed --- /dev/null +++ b/v1/conformance/extends/load/ruleset.yaml @@ -0,0 +1,3 @@ +extends: ./base.yaml +rules: + acme-operation-summary: error diff --git a/v1/conformance/extends/load/test.json b/v1/conformance/extends/load/test.json new file mode 100644 index 0000000..7da9369 --- /dev/null +++ b/v1/conformance/extends/load/test.json @@ -0,0 +1,21 @@ +{ + "for": [ + "/req/extends/load" + ], + "purpose": "A rule defined only in the extended ruleset is available to the extending one by name.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "base.yaml" + ], + "expect": { + "results": [ + { + "code": "acme-operation-summary" + }, + { + "code": "acme-operation-summary" + } + ] + } +} diff --git a/v1/conformance/extends/mode-all-extra-element/base.yaml b/v1/conformance/extends/mode-all-extra-element/base.yaml new file mode 100644 index 0000000..b56abc8 --- /dev/null +++ b/v1/conformance/extends/mode-all-extra-element/base.yaml @@ -0,0 +1,7 @@ +rules: + base-rule: + recommended: false + given: $.info + then: + field: title + function: truthy diff --git a/v1/conformance/extends/mode-all-extra-element/document.yaml b/v1/conformance/extends/mode-all-extra-element/document.yaml new file mode 100644 index 0000000..83b97ff --- /dev/null +++ b/v1/conformance/extends/mode-all-extra-element/document.yaml @@ -0,0 +1,4 @@ +openapi: 3.1.0 +info: + version: "1" +paths: {} diff --git a/v1/conformance/extends/mode-all-extra-element/ruleset.yaml b/v1/conformance/extends/mode-all-extra-element/ruleset.yaml new file mode 100644 index 0000000..13a903b --- /dev/null +++ b/v1/conformance/extends/mode-all-extra-element/ruleset.yaml @@ -0,0 +1,2 @@ +extends: + - [./base.yaml, all, extra] diff --git a/v1/conformance/extends/mode-all-extra-element/test.json b/v1/conformance/extends/mode-all-extra-element/test.json new file mode 100644 index 0000000..910888e --- /dev/null +++ b/v1/conformance/extends/mode-all-extra-element/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/extends/mode-all" + ], + "purpose": "With `[./base.yaml, all, extra]`, the mode `all` applies and the extra element is ignored.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "base.yaml" + ], + "expect": { + "results": [ + { + "code": "base-rule" + } + ] + } +} diff --git a/v1/conformance/extends/mode-all/a.yaml b/v1/conformance/extends/mode-all/a.yaml new file mode 100644 index 0000000..945964d --- /dev/null +++ b/v1/conformance/extends/mode-all/a.yaml @@ -0,0 +1,12 @@ +rules: + r1: + description: Not on by default. + recommended: false + given: $ + then: + function: falsy + r2: + description: On by default. + given: $ + then: + function: falsy diff --git a/v1/conformance/extends/mode-all/document.yaml b/v1/conformance/extends/mode-all/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/extends/mode-all/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/extends/mode-all/ruleset.yaml b/v1/conformance/extends/mode-all/ruleset.yaml new file mode 100644 index 0000000..90267d2 --- /dev/null +++ b/v1/conformance/extends/mode-all/ruleset.yaml @@ -0,0 +1,2 @@ +extends: + - [./a.yaml, all] diff --git a/v1/conformance/extends/mode-all/test.json b/v1/conformance/extends/mode-all/test.json new file mode 100644 index 0000000..51016e1 --- /dev/null +++ b/v1/conformance/extends/mode-all/test.json @@ -0,0 +1,21 @@ +{ + "for": [ + "/req/extends/mode-all" + ], + "purpose": "With mode `all`, both rules run.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "a.yaml" + ], + "expect": { + "results": [ + { + "code": "r1" + }, + { + "code": "r2" + } + ] + } +} diff --git a/v1/conformance/extends/mode-default/a.yaml b/v1/conformance/extends/mode-default/a.yaml new file mode 100644 index 0000000..945964d --- /dev/null +++ b/v1/conformance/extends/mode-default/a.yaml @@ -0,0 +1,12 @@ +rules: + r1: + description: Not on by default. + recommended: false + given: $ + then: + function: falsy + r2: + description: On by default. + given: $ + then: + function: falsy diff --git a/v1/conformance/extends/mode-default/document.yaml b/v1/conformance/extends/mode-default/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/extends/mode-default/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/extends/mode-default/ruleset.yaml b/v1/conformance/extends/mode-default/ruleset.yaml new file mode 100644 index 0000000..3a3b7f0 --- /dev/null +++ b/v1/conformance/extends/mode-default/ruleset.yaml @@ -0,0 +1 @@ +extends: ./a.yaml diff --git a/v1/conformance/extends/mode-default/test.json b/v1/conformance/extends/mode-default/test.json new file mode 100644 index 0000000..96d1faf --- /dev/null +++ b/v1/conformance/extends/mode-default/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/extends/mode-recommended" + ], + "purpose": "Without a mode, only the rule that is recommended runs.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "a.yaml" + ], + "expect": { + "results": [ + { + "code": "r2" + } + ] + } +} diff --git a/v1/conformance/extends/mode-off/a.yaml b/v1/conformance/extends/mode-off/a.yaml new file mode 100644 index 0000000..945964d --- /dev/null +++ b/v1/conformance/extends/mode-off/a.yaml @@ -0,0 +1,12 @@ +rules: + r1: + description: Not on by default. + recommended: false + given: $ + then: + function: falsy + r2: + description: On by default. + given: $ + then: + function: falsy diff --git a/v1/conformance/extends/mode-off/document.yaml b/v1/conformance/extends/mode-off/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/extends/mode-off/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/extends/mode-off/ruleset.yaml b/v1/conformance/extends/mode-off/ruleset.yaml new file mode 100644 index 0000000..be01dd8 --- /dev/null +++ b/v1/conformance/extends/mode-off/ruleset.yaml @@ -0,0 +1,4 @@ +extends: + - [./a.yaml, "off"] +rules: + r1: true diff --git a/v1/conformance/extends/mode-off/test.json b/v1/conformance/extends/mode-off/test.json new file mode 100644 index 0000000..e621fb6 --- /dev/null +++ b/v1/conformance/extends/mode-off/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/extends/mode-off" + ], + "purpose": "With mode `off`, no inherited rule runs until it is enabled by name.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "a.yaml" + ], + "expect": { + "results": [ + { + "code": "r1" + } + ] + } +} diff --git a/v1/conformance/extends/mode-recommended/a.yaml b/v1/conformance/extends/mode-recommended/a.yaml new file mode 100644 index 0000000..945964d --- /dev/null +++ b/v1/conformance/extends/mode-recommended/a.yaml @@ -0,0 +1,12 @@ +rules: + r1: + description: Not on by default. + recommended: false + given: $ + then: + function: falsy + r2: + description: On by default. + given: $ + then: + function: falsy diff --git a/v1/conformance/extends/mode-recommended/document.yaml b/v1/conformance/extends/mode-recommended/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/extends/mode-recommended/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/extends/mode-recommended/ruleset.yaml b/v1/conformance/extends/mode-recommended/ruleset.yaml new file mode 100644 index 0000000..1be2ca6 --- /dev/null +++ b/v1/conformance/extends/mode-recommended/ruleset.yaml @@ -0,0 +1,2 @@ +extends: + - [./a.yaml, recommended] diff --git a/v1/conformance/extends/mode-recommended/test.json b/v1/conformance/extends/mode-recommended/test.json new file mode 100644 index 0000000..a7729d6 --- /dev/null +++ b/v1/conformance/extends/mode-recommended/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/extends/mode-recommended" + ], + "purpose": "With mode `recommended`, only the rule that is recommended runs.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "a.yaml" + ], + "expect": { + "results": [ + { + "code": "r2" + } + ] + } +} diff --git a/v1/conformance/extends/precedence-order/a.yaml b/v1/conformance/extends/precedence-order/a.yaml new file mode 100644 index 0000000..79165aa --- /dev/null +++ b/v1/conformance/extends/precedence-order/a.yaml @@ -0,0 +1,7 @@ +rules: + r: + description: from A + message: "{{description}}" + given: $ + then: + function: falsy diff --git a/v1/conformance/extends/precedence-order/b.yaml b/v1/conformance/extends/precedence-order/b.yaml new file mode 100644 index 0000000..ccb9c5b --- /dev/null +++ b/v1/conformance/extends/precedence-order/b.yaml @@ -0,0 +1,7 @@ +rules: + r: + description: from B + message: "{{description}}" + given: $ + then: + function: falsy diff --git a/v1/conformance/extends/precedence-order/document.yaml b/v1/conformance/extends/precedence-order/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/extends/precedence-order/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/extends/precedence-order/ruleset.yaml b/v1/conformance/extends/precedence-order/ruleset.yaml new file mode 100644 index 0000000..8c84904 --- /dev/null +++ b/v1/conformance/extends/precedence-order/ruleset.yaml @@ -0,0 +1,3 @@ +extends: + - ./a.yaml + - ./b.yaml diff --git a/v1/conformance/extends/precedence-order/test.json b/v1/conformance/extends/precedence-order/test.json new file mode 100644 index 0000000..0c61f92 --- /dev/null +++ b/v1/conformance/extends/precedence-order/test.json @@ -0,0 +1,20 @@ +{ + "for": [ + "/req/extends/order" + ], + "purpose": "Of two extended rulesets defining the same rule, the later one wins.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "a.yaml", + "b.yaml" + ], + "expect": { + "results": [ + { + "code": "r", + "message": "from B" + } + ] + } +} diff --git a/v1/conformance/extends/precedence/a.yaml b/v1/conformance/extends/precedence/a.yaml new file mode 100644 index 0000000..79165aa --- /dev/null +++ b/v1/conformance/extends/precedence/a.yaml @@ -0,0 +1,7 @@ +rules: + r: + description: from A + message: "{{description}}" + given: $ + then: + function: falsy diff --git a/v1/conformance/extends/precedence/b.yaml b/v1/conformance/extends/precedence/b.yaml new file mode 100644 index 0000000..ccb9c5b --- /dev/null +++ b/v1/conformance/extends/precedence/b.yaml @@ -0,0 +1,7 @@ +rules: + r: + description: from B + message: "{{description}}" + given: $ + then: + function: falsy diff --git a/v1/conformance/extends/precedence/document.yaml b/v1/conformance/extends/precedence/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/extends/precedence/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/extends/precedence/ruleset.yaml b/v1/conformance/extends/precedence/ruleset.yaml new file mode 100644 index 0000000..3355ad9 --- /dev/null +++ b/v1/conformance/extends/precedence/ruleset.yaml @@ -0,0 +1,10 @@ +extends: + - ./a.yaml + - ./b.yaml +rules: + r: + description: from C + message: "{{description}}" + given: $ + then: + function: falsy diff --git a/v1/conformance/extends/precedence/test.json b/v1/conformance/extends/precedence/test.json new file mode 100644 index 0000000..4beaf08 --- /dev/null +++ b/v1/conformance/extends/precedence/test.json @@ -0,0 +1,20 @@ +{ + "for": [ + "/req/extends/order" + ], + "purpose": "The extending ruleset's definition of `r` wins over both extended rulesets.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "a.yaml", + "b.yaml" + ], + "expect": { + "results": [ + { + "code": "r", + "message": "from C" + } + ] + } +} diff --git a/v1/conformance/extends/redefine-keeps-properties/base.yaml b/v1/conformance/extends/redefine-keeps-properties/base.yaml new file mode 100644 index 0000000..06acad8 --- /dev/null +++ b/v1/conformance/extends/redefine-keeps-properties/base.yaml @@ -0,0 +1,7 @@ +rules: + r: + severity: error + message: base message + given: $.nothing + then: + function: truthy diff --git a/v1/conformance/extends/redefine-keeps-properties/document.yaml b/v1/conformance/extends/redefine-keeps-properties/document.yaml new file mode 100644 index 0000000..bf5f2ba --- /dev/null +++ b/v1/conformance/extends/redefine-keeps-properties/document.yaml @@ -0,0 +1,3 @@ +openapi: 3.1.0 +info: + version: "1" diff --git a/v1/conformance/extends/redefine-keeps-properties/ruleset.yaml b/v1/conformance/extends/redefine-keeps-properties/ruleset.yaml new file mode 100644 index 0000000..4ab386d --- /dev/null +++ b/v1/conformance/extends/redefine-keeps-properties/ruleset.yaml @@ -0,0 +1,7 @@ +extends: ./base.yaml +rules: + r: + given: $.info + then: + field: title + function: truthy diff --git a/v1/conformance/extends/redefine-keeps-properties/test.json b/v1/conformance/extends/redefine-keeps-properties/test.json new file mode 100644 index 0000000..26494da --- /dev/null +++ b/v1/conformance/extends/redefine-keeps-properties/test.json @@ -0,0 +1,21 @@ +{ + "for": [ + "/req/rules/redefinition-merge", + "/req/severity/default" + ], + "purpose": "A redefinition that sets only `given` and `then` keeps the inherited rule's `severity` and `message`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "base.yaml" + ], + "expect": { + "results": [ + { + "code": "r", + "severity": 0, + "message": "base message" + } + ] + } +} diff --git a/v1/conformance/extends/resolvable/base.yaml b/v1/conformance/extends/resolvable/base.yaml new file mode 100644 index 0000000..81db109 --- /dev/null +++ b/v1/conformance/extends/resolvable/base.yaml @@ -0,0 +1,5 @@ +rules: + base-rule: + given: $ + then: + function: falsy diff --git a/v1/conformance/extends/resolvable/document.yaml b/v1/conformance/extends/resolvable/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/extends/resolvable/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/extends/resolvable/ruleset.yaml b/v1/conformance/extends/resolvable/ruleset.yaml new file mode 100644 index 0000000..ea42faa --- /dev/null +++ b/v1/conformance/extends/resolvable/ruleset.yaml @@ -0,0 +1 @@ +extends: ./base.yaml diff --git a/v1/conformance/extends/resolvable/test.json b/v1/conformance/extends/resolvable/test.json new file mode 100644 index 0000000..b2849a6 --- /dev/null +++ b/v1/conformance/extends/resolvable/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/extends/unresolvable" + ], + "purpose": "A ruleset extending a file that exists is loaded and the inherited rule runs.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "base.yaml" + ], + "expect": { + "results": [ + { + "code": "base-rule" + } + ] + } +} diff --git a/v1/conformance/extends/shape-array/base.yaml b/v1/conformance/extends/shape-array/base.yaml new file mode 100644 index 0000000..4608429 --- /dev/null +++ b/v1/conformance/extends/shape-array/base.yaml @@ -0,0 +1,5 @@ +rules: + base-rule: + given: $ + then: + function: truthy diff --git a/v1/conformance/extends/shape-array/other.yaml b/v1/conformance/extends/shape-array/other.yaml new file mode 100644 index 0000000..0f6a791 --- /dev/null +++ b/v1/conformance/extends/shape-array/other.yaml @@ -0,0 +1,5 @@ +rules: + other-rule: + given: $ + then: + function: truthy diff --git a/v1/conformance/extends/shape-array/ruleset.yaml b/v1/conformance/extends/shape-array/ruleset.yaml new file mode 100644 index 0000000..6ccc51f --- /dev/null +++ b/v1/conformance/extends/shape-array/ruleset.yaml @@ -0,0 +1,3 @@ +extends: + - ./base.yaml + - [./other.yaml, all] diff --git a/v1/conformance/extends/shape-array/test.json b/v1/conformance/extends/shape-array/test.json new file mode 100644 index 0000000..f7cee49 --- /dev/null +++ b/v1/conformance/extends/shape-array/test.json @@ -0,0 +1,14 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A ruleset whose `extends` is an array mixing a string and a tuple is valid.", + "ruleset": "ruleset.yaml", + "files": [ + "base.yaml", + "other.yaml" + ], + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/extends/shape-object/base.yaml b/v1/conformance/extends/shape-object/base.yaml new file mode 100644 index 0000000..4608429 --- /dev/null +++ b/v1/conformance/extends/shape-object/base.yaml @@ -0,0 +1,5 @@ +rules: + base-rule: + given: $ + then: + function: truthy diff --git a/v1/conformance/extends/shape-object/ruleset.yaml b/v1/conformance/extends/shape-object/ruleset.yaml new file mode 100644 index 0000000..b2ebecf --- /dev/null +++ b/v1/conformance/extends/shape-object/ruleset.yaml @@ -0,0 +1,2 @@ +extends: + ruleset: ./base.yaml diff --git a/v1/conformance/extends/shape-object/test.json b/v1/conformance/extends/shape-object/test.json new file mode 100644 index 0000000..4e60b54 --- /dev/null +++ b/v1/conformance/extends/shape-object/test.json @@ -0,0 +1,13 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A ruleset whose `extends` is an object is invalid.", + "ruleset": "ruleset.yaml", + "files": [ + "base.yaml" + ], + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/extends/shape-string/base.yaml b/v1/conformance/extends/shape-string/base.yaml new file mode 100644 index 0000000..4608429 --- /dev/null +++ b/v1/conformance/extends/shape-string/base.yaml @@ -0,0 +1,5 @@ +rules: + base-rule: + given: $ + then: + function: truthy diff --git a/v1/conformance/extends/shape-string/ruleset.yaml b/v1/conformance/extends/shape-string/ruleset.yaml new file mode 100644 index 0000000..ea42faa --- /dev/null +++ b/v1/conformance/extends/shape-string/ruleset.yaml @@ -0,0 +1 @@ +extends: ./base.yaml diff --git a/v1/conformance/extends/shape-string/test.json b/v1/conformance/extends/shape-string/test.json new file mode 100644 index 0000000..cd3aad9 --- /dev/null +++ b/v1/conformance/extends/shape-string/test.json @@ -0,0 +1,13 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A ruleset whose `extends` is a single string is valid.", + "ruleset": "ruleset.yaml", + "files": [ + "base.yaml" + ], + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/extends/unresolvable/document.yaml b/v1/conformance/extends/unresolvable/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/extends/unresolvable/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/extends/unresolvable/ruleset.yaml b/v1/conformance/extends/unresolvable/ruleset.yaml new file mode 100644 index 0000000..28a6a99 --- /dev/null +++ b/v1/conformance/extends/unresolvable/ruleset.yaml @@ -0,0 +1 @@ +extends: ./does-not-exist.yaml diff --git a/v1/conformance/extends/unresolvable/test.json b/v1/conformance/extends/unresolvable/test.json new file mode 100644 index 0000000..d1cea66 --- /dev/null +++ b/v1/conformance/extends/unresolvable/test.json @@ -0,0 +1,11 @@ +{ + "for": [ + "/req/extends/unresolvable" + ], + "purpose": "A ruleset extending a file that does not exist is rejected.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "rejected": true + } +} diff --git a/v1/conformance/extension-properties/accepted/ruleset.yaml b/v1/conformance/extension-properties/accepted/ruleset.yaml new file mode 100644 index 0000000..5c58b1b --- /dev/null +++ b/v1/conformance/extension-properties/accepted/ruleset.yaml @@ -0,0 +1,12 @@ +x-owner: platform-team +x-review: + last: 2026-10-01 + ticket: GOV-142 +rules: + acme-operation-summary: + x-jira: GOV-142 + x-rationale: Summaries feed the generated docs. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/extension-properties/accepted/test.json b/v1/conformance/extension-properties/accepted/test.json new file mode 100644 index 0000000..df42c7f --- /dev/null +++ b/v1/conformance/extension-properties/accepted/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/per/extension-properties/anywhere" + ], + "purpose": "A ruleset with extension properties of several types at the root and on a rule is accepted.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/extension-properties/ignored/document.yaml b/v1/conformance/extension-properties/ignored/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/extension-properties/ignored/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/extension-properties/ignored/ruleset.yaml b/v1/conformance/extension-properties/ignored/ruleset.yaml new file mode 100644 index 0000000..61528b6 --- /dev/null +++ b/v1/conformance/extension-properties/ignored/ruleset.yaml @@ -0,0 +1,9 @@ +x-owner: platform-team +rules: + acme-operation-summary: + x-jira: GOV-142 + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/extension-properties/ignored/test.json b/v1/conformance/extension-properties/ignored/test.json new file mode 100644 index 0000000..7961f68 --- /dev/null +++ b/v1/conformance/extension-properties/ignored/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/extension-properties/ignored" + ], + "purpose": "Extension properties at the root and on a rule change nothing about the results.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "acme-operation-summary" + }, + { + "code": "acme-operation-summary" + } + ] + } +} diff --git a/v1/conformance/formats/detect-aas2-6/document.yaml b/v1/conformance/formats/detect-aas2-6/document.yaml new file mode 100644 index 0000000..4dbf555 --- /dev/null +++ b/v1/conformance/formats/detect-aas2-6/document.yaml @@ -0,0 +1 @@ +asyncapi: 2.6.0 diff --git a/v1/conformance/formats/detect-aas2-6/ruleset.yaml b/v1/conformance/formats/detect-aas2-6/ruleset.yaml new file mode 100644 index 0000000..03b6d92 --- /dev/null +++ b/v1/conformance/formats/detect-aas2-6/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + is-aas2_6: + formats: [aas2_6] + given: $ + then: + function: falsy + is-aas2_5: + formats: [aas2_5] + given: $ + then: + function: falsy diff --git a/v1/conformance/formats/detect-aas2-6/test.json b/v1/conformance/formats/detect-aas2-6/test.json new file mode 100644 index 0000000..f8bd09b --- /dev/null +++ b/v1/conformance/formats/detect-aas2-6/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/formats/identifiers" + ], + "purpose": "A document with `asyncapi: 2.6.0` is `aas2_6` and not `aas2_5`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "is-aas2_6" + } + ] + } +} diff --git a/v1/conformance/formats/detect-aas2-needs-patch/document.yaml b/v1/conformance/formats/detect-aas2-needs-patch/document.yaml new file mode 100644 index 0000000..4dbf555 --- /dev/null +++ b/v1/conformance/formats/detect-aas2-needs-patch/document.yaml @@ -0,0 +1 @@ +asyncapi: 2.6.0 diff --git a/v1/conformance/formats/detect-aas2-needs-patch/ruleset.yaml b/v1/conformance/formats/detect-aas2-needs-patch/ruleset.yaml new file mode 100644 index 0000000..efbbe6b --- /dev/null +++ b/v1/conformance/formats/detect-aas2-needs-patch/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + is-aas2: + formats: [aas2] + given: $ + then: + function: falsy + is-aas3: + formats: [aas3] + given: $ + then: + function: falsy diff --git a/v1/conformance/formats/detect-aas2-needs-patch/test.json b/v1/conformance/formats/detect-aas2-needs-patch/test.json new file mode 100644 index 0000000..c010d75 --- /dev/null +++ b/v1/conformance/formats/detect-aas2-needs-patch/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/formats/identifiers" + ], + "purpose": "`asyncapi: \"2.6\"` without a patch number is not `aas2`, while `2.6.0` is.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "is-aas2" + } + ] + } +} diff --git a/v1/conformance/formats/detect-arazzo/document.yaml b/v1/conformance/formats/detect-arazzo/document.yaml new file mode 100644 index 0000000..c51a47a --- /dev/null +++ b/v1/conformance/formats/detect-arazzo/document.yaml @@ -0,0 +1 @@ +arazzo: 1.0.1 diff --git a/v1/conformance/formats/detect-arazzo/ruleset.yaml b/v1/conformance/formats/detect-arazzo/ruleset.yaml new file mode 100644 index 0000000..1a5ad00 --- /dev/null +++ b/v1/conformance/formats/detect-arazzo/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + is-arazzo1_0: + formats: [arazzo1_0] + given: $ + then: + function: falsy + is-oas3: + formats: [oas3] + given: $ + then: + function: falsy diff --git a/v1/conformance/formats/detect-arazzo/test.json b/v1/conformance/formats/detect-arazzo/test.json new file mode 100644 index 0000000..5b4f3c0 --- /dev/null +++ b/v1/conformance/formats/detect-arazzo/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/formats/identifiers" + ], + "purpose": "A document with `arazzo: 1.0.1` is `arazzo1_0`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "is-arazzo1_0" + } + ] + } +} diff --git a/v1/conformance/formats/detect-json-schema-2020-12/document.yaml b/v1/conformance/formats/detect-json-schema-2020-12/document.yaml new file mode 100644 index 0000000..ed86e41 --- /dev/null +++ b/v1/conformance/formats/detect-json-schema-2020-12/document.yaml @@ -0,0 +1,2 @@ +$schema: https://json-schema.org/draft/2020-12/schema +type: object diff --git a/v1/conformance/formats/detect-json-schema-2020-12/ruleset.yaml b/v1/conformance/formats/detect-json-schema-2020-12/ruleset.yaml new file mode 100644 index 0000000..969f15b --- /dev/null +++ b/v1/conformance/formats/detect-json-schema-2020-12/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + is-jsonSchemaDraft2020_12: + formats: [jsonSchemaDraft2020_12] + given: $ + then: + function: falsy + is-jsonSchemaDraft7: + formats: [jsonSchemaDraft7] + given: $ + then: + function: falsy diff --git a/v1/conformance/formats/detect-json-schema-2020-12/test.json b/v1/conformance/formats/detect-json-schema-2020-12/test.json new file mode 100644 index 0000000..79bc04e --- /dev/null +++ b/v1/conformance/formats/detect-json-schema-2020-12/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/formats/identifiers" + ], + "purpose": "A `$schema` of draft 2020-12 is `jsonSchemaDraft2020_12` and not draft 7.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "is-jsonSchemaDraft2020_12" + } + ] + } +} diff --git a/v1/conformance/formats/detect-json-schema-loose/document.yaml b/v1/conformance/formats/detect-json-schema-loose/document.yaml new file mode 100644 index 0000000..91bf309 --- /dev/null +++ b/v1/conformance/formats/detect-json-schema-loose/document.yaml @@ -0,0 +1 @@ +type: object diff --git a/v1/conformance/formats/detect-json-schema-loose/ruleset.yaml b/v1/conformance/formats/detect-json-schema-loose/ruleset.yaml new file mode 100644 index 0000000..cd9effa --- /dev/null +++ b/v1/conformance/formats/detect-json-schema-loose/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + is-jsonSchemaLoose: + formats: [jsonSchemaLoose] + given: $ + then: + function: falsy + is-jsonSchema: + formats: [jsonSchema] + given: $ + then: + function: falsy diff --git a/v1/conformance/formats/detect-json-schema-loose/test.json b/v1/conformance/formats/detect-json-schema-loose/test.json new file mode 100644 index 0000000..49d2a8d --- /dev/null +++ b/v1/conformance/formats/detect-json-schema-loose/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/formats/identifiers" + ], + "purpose": "A document with `type: object` and no `$schema` is `jsonSchemaLoose` but not `jsonSchema`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "is-jsonSchemaLoose" + } + ] + } +} diff --git a/v1/conformance/formats/detect-number-version/document.yaml b/v1/conformance/formats/detect-number-version/document.yaml new file mode 100644 index 0000000..312b15c --- /dev/null +++ b/v1/conformance/formats/detect-number-version/document.yaml @@ -0,0 +1 @@ +openapi: 3.0 diff --git a/v1/conformance/formats/detect-number-version/ruleset.yaml b/v1/conformance/formats/detect-number-version/ruleset.yaml new file mode 100644 index 0000000..ac59e93 --- /dev/null +++ b/v1/conformance/formats/detect-number-version/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + is-oas3: + formats: [oas3] + given: $ + then: + function: falsy + is-oas3_0: + formats: [oas3_0] + given: $ + then: + function: falsy diff --git a/v1/conformance/formats/detect-number-version/test.json b/v1/conformance/formats/detect-number-version/test.json new file mode 100644 index 0000000..4f2465b --- /dev/null +++ b/v1/conformance/formats/detect-number-version/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/formats/identifiers" + ], + "purpose": "An unquoted `openapi: 3.0` is the number 3, whose string form is `3`, so the document is `oas3` but not `oas3_0`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "is-oas3" + } + ] + } +} diff --git a/v1/conformance/formats/detect-oas2/document.yaml b/v1/conformance/formats/detect-oas2/document.yaml new file mode 100644 index 0000000..8810761 --- /dev/null +++ b/v1/conformance/formats/detect-oas2/document.yaml @@ -0,0 +1 @@ +swagger: "2.0" diff --git a/v1/conformance/formats/detect-oas2/ruleset.yaml b/v1/conformance/formats/detect-oas2/ruleset.yaml new file mode 100644 index 0000000..e8935ae --- /dev/null +++ b/v1/conformance/formats/detect-oas2/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + is-oas2: + formats: [oas2] + given: $ + then: + function: falsy + is-oas3: + formats: [oas3] + given: $ + then: + function: falsy diff --git a/v1/conformance/formats/detect-oas2/test.json b/v1/conformance/formats/detect-oas2/test.json new file mode 100644 index 0000000..2d3dc2d --- /dev/null +++ b/v1/conformance/formats/detect-oas2/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/formats/identifiers" + ], + "purpose": "A document with `swagger: \"2.0\"` is `oas2` and not `oas3`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "is-oas2" + } + ] + } +} diff --git a/v1/conformance/formats/detect-oas3-1/document.yaml b/v1/conformance/formats/detect-oas3-1/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/formats/detect-oas3-1/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/formats/detect-oas3-1/ruleset.yaml b/v1/conformance/formats/detect-oas3-1/ruleset.yaml new file mode 100644 index 0000000..6084cba --- /dev/null +++ b/v1/conformance/formats/detect-oas3-1/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + is-oas3-1: + formats: [oas3_1] + given: $ + then: + function: falsy + is-oas3-0: + formats: [oas3_0] + given: $ + then: + function: falsy diff --git a/v1/conformance/formats/detect-oas3-1/test.json b/v1/conformance/formats/detect-oas3-1/test.json new file mode 100644 index 0000000..04d5ec3 --- /dev/null +++ b/v1/conformance/formats/detect-oas3-1/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/formats/identifiers" + ], + "purpose": "An OpenAPI 3.1 document is detected as `oas3_1` and not as `oas3_0`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "is-oas3-1" + } + ] + } +} diff --git a/v1/conformance/formats/detection-declared-only/document.yaml b/v1/conformance/formats/detection-declared-only/document.yaml new file mode 100644 index 0000000..83b97ff --- /dev/null +++ b/v1/conformance/formats/detection-declared-only/document.yaml @@ -0,0 +1,4 @@ +openapi: 3.1.0 +info: + version: "1" +paths: {} diff --git a/v1/conformance/formats/detection-declared-only/ruleset.yaml b/v1/conformance/formats/detection-declared-only/ruleset.yaml new file mode 100644 index 0000000..0d078d2 --- /dev/null +++ b/v1/conformance/formats/detection-declared-only/ruleset.yaml @@ -0,0 +1,14 @@ +aliases: + I: + targets: + - formats: [oas3] + given: ["$.info"] + - formats: [oas3_1] + given: ["$.paths"] +rules: + r: + formats: [oas3] + given: "#I" + then: + field: title + function: truthy diff --git a/v1/conformance/formats/detection-declared-only/test.json b/v1/conformance/formats/detection-declared-only/test.json new file mode 100644 index 0000000..85a9264 --- /dev/null +++ b/v1/conformance/formats/detection-declared-only/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/formats/detection-scope" + ], + "purpose": "`oas3_1` is not detected when no rule names it, so for an OpenAPI 3.1 document the alias uses its `oas3` target, though an `oas3_1` target comes last.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "info" + ] + } + ] + } +} diff --git a/v1/conformance/formats/rule-overrides-ruleset/document.yaml b/v1/conformance/formats/rule-overrides-ruleset/document.yaml new file mode 100644 index 0000000..f1d55c0 --- /dev/null +++ b/v1/conformance/formats/rule-overrides-ruleset/document.yaml @@ -0,0 +1,5 @@ +swagger: "2.0" +info: + title: Petstore + version: 1.0.0 +paths: {} diff --git a/v1/conformance/formats/rule-overrides-ruleset/ruleset.yaml b/v1/conformance/formats/rule-overrides-ruleset/ruleset.yaml new file mode 100644 index 0000000..874ad15 --- /dev/null +++ b/v1/conformance/formats/rule-overrides-ruleset/ruleset.yaml @@ -0,0 +1,7 @@ +formats: [oas3] +rules: + r: + formats: [oas2] + given: $ + then: + function: falsy diff --git a/v1/conformance/formats/rule-overrides-ruleset/test.json b/v1/conformance/formats/rule-overrides-ruleset/test.json new file mode 100644 index 0000000..38691f6 --- /dev/null +++ b/v1/conformance/formats/rule-overrides-ruleset/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/formats/rule-overrides-ruleset" + ], + "purpose": "A rule's own `formats` replaces the ruleset's.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r" + } + ] + } +} diff --git a/v1/conformance/formats/rule-scope/document.yaml b/v1/conformance/formats/rule-scope/document.yaml new file mode 100644 index 0000000..f1d55c0 --- /dev/null +++ b/v1/conformance/formats/rule-scope/document.yaml @@ -0,0 +1,5 @@ +swagger: "2.0" +info: + title: Petstore + version: 1.0.0 +paths: {} diff --git a/v1/conformance/formats/rule-scope/ruleset.yaml b/v1/conformance/formats/rule-scope/ruleset.yaml new file mode 100644 index 0000000..d0dbd4b --- /dev/null +++ b/v1/conformance/formats/rule-scope/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + only-oas3: + formats: [oas3] + given: $ + then: + function: falsy + only-oas2: + formats: [oas2] + given: $ + then: + function: falsy diff --git a/v1/conformance/formats/rule-scope/test.json b/v1/conformance/formats/rule-scope/test.json new file mode 100644 index 0000000..fb68da5 --- /dev/null +++ b/v1/conformance/formats/rule-scope/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/formats/rule-scope" + ], + "purpose": "Of two rules scoped to different formats, only the one whose format the document has runs.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "only-oas2" + } + ] + } +} diff --git a/v1/conformance/formats/ruleset-default/document.yaml b/v1/conformance/formats/ruleset-default/document.yaml new file mode 100644 index 0000000..f1d55c0 --- /dev/null +++ b/v1/conformance/formats/ruleset-default/document.yaml @@ -0,0 +1,5 @@ +swagger: "2.0" +info: + title: Petstore + version: 1.0.0 +paths: {} diff --git a/v1/conformance/formats/ruleset-default/ruleset.yaml b/v1/conformance/formats/ruleset-default/ruleset.yaml new file mode 100644 index 0000000..28d52fd --- /dev/null +++ b/v1/conformance/formats/ruleset-default/ruleset.yaml @@ -0,0 +1,11 @@ +formats: [oas3] +rules: + r: + given: $ + then: + function: falsy + s: + formats: [oas2] + given: $ + then: + function: falsy diff --git a/v1/conformance/formats/ruleset-default/test.json b/v1/conformance/formats/ruleset-default/test.json new file mode 100644 index 0000000..ad86cc9 --- /dev/null +++ b/v1/conformance/formats/ruleset-default/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/formats/ruleset-default" + ], + "purpose": "A rule without `formats` takes the ruleset's, so it does not run on a document of another format, while a rule scoped to that format does.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "s" + } + ] + } +} diff --git a/v1/conformance/formats/shape-string/ruleset.yaml b/v1/conformance/formats/shape-string/ruleset.yaml new file mode 100644 index 0000000..d3f10e7 --- /dev/null +++ b/v1/conformance/formats/shape-string/ruleset.yaml @@ -0,0 +1,8 @@ +formats: oas3 +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/formats/shape-string/test.json b/v1/conformance/formats/shape-string/test.json new file mode 100644 index 0000000..47841c2 --- /dev/null +++ b/v1/conformance/formats/shape-string/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A ruleset whose `formats` is a string is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/formats/shape-valid/ruleset.yaml b/v1/conformance/formats/shape-valid/ruleset.yaml new file mode 100644 index 0000000..ec48aee --- /dev/null +++ b/v1/conformance/formats/shape-valid/ruleset.yaml @@ -0,0 +1,18 @@ +formats: [oas3] +aliases: + Params: + targets: + - formats: [oas3] + given: + - $.components.parameters[*] +rules: + r: + formats: [oas3_1] + given: "#Params" + then: + function: truthy +overrides: + - files: ["legacy/**"] + formats: [oas3_0] + rules: + r: warn diff --git a/v1/conformance/formats/shape-valid/test.json b/v1/conformance/formats/shape-valid/test.json new file mode 100644 index 0000000..557f544 --- /dev/null +++ b/v1/conformance/formats/shape-valid/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "`formats` as an array of strings on the ruleset, a rule, an override, and an alias target is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/formats/unknown-identifier/ruleset.yaml b/v1/conformance/formats/unknown-identifier/ruleset.yaml new file mode 100644 index 0000000..1a214ef --- /dev/null +++ b/v1/conformance/formats/unknown-identifier/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + r: + formats: [oas31] + given: $.info + then: + field: title + function: truthy diff --git a/v1/conformance/formats/unknown-identifier/test.json b/v1/conformance/formats/unknown-identifier/test.json new file mode 100644 index 0000000..2c6f83a --- /dev/null +++ b/v1/conformance/formats/unknown-identifier/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A rule whose `formats` names an identifier outside the table, `oas31`, is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/formats/unrecognized-document/document.yaml b/v1/conformance/formats/unrecognized-document/document.yaml new file mode 100644 index 0000000..f1d55c0 --- /dev/null +++ b/v1/conformance/formats/unrecognized-document/document.yaml @@ -0,0 +1,5 @@ +swagger: "2.0" +info: + title: Petstore + version: 1.0.0 +paths: {} diff --git a/v1/conformance/formats/unrecognized-document/ruleset.yaml b/v1/conformance/formats/unrecognized-document/ruleset.yaml new file mode 100644 index 0000000..c9fb7bd --- /dev/null +++ b/v1/conformance/formats/unrecognized-document/ruleset.yaml @@ -0,0 +1,6 @@ +formats: [oas3] +rules: + r: + given: $ + then: + function: falsy diff --git a/v1/conformance/formats/unrecognized-document/test.json b/v1/conformance/formats/unrecognized-document/test.json new file mode 100644 index 0000000..2b002d4 --- /dev/null +++ b/v1/conformance/formats/unrecognized-document/test.json @@ -0,0 +1,16 @@ +{ + "for": [ + "/req/formats/unrecognized-document" + ], + "purpose": "A document that has none of the ruleset's formats gets one warning and no rule results.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "unrecognized-format", + "severity": 1 + } + ] + } +} diff --git a/v1/conformance/formats/unrecognized-unscoped/base.yaml b/v1/conformance/formats/unrecognized-unscoped/base.yaml new file mode 100644 index 0000000..0718608 --- /dev/null +++ b/v1/conformance/formats/unrecognized-unscoped/base.yaml @@ -0,0 +1,5 @@ +rules: + s: + given: $ + then: + function: falsy diff --git a/v1/conformance/formats/unrecognized-unscoped/document.yaml b/v1/conformance/formats/unrecognized-unscoped/document.yaml new file mode 100644 index 0000000..c5e8ad4 --- /dev/null +++ b/v1/conformance/formats/unrecognized-unscoped/document.yaml @@ -0,0 +1,5 @@ +asyncapi: 2.6.0 +info: + title: Events + version: 1.0.0 +channels: {} diff --git a/v1/conformance/formats/unrecognized-unscoped/ruleset.yaml b/v1/conformance/formats/unrecognized-unscoped/ruleset.yaml new file mode 100644 index 0000000..9c0b7d4 --- /dev/null +++ b/v1/conformance/formats/unrecognized-unscoped/ruleset.yaml @@ -0,0 +1,7 @@ +extends: ./base.yaml +formats: [oas3] +rules: + r: + given: $ + then: + function: falsy diff --git a/v1/conformance/formats/unrecognized-unscoped/test.json b/v1/conformance/formats/unrecognized-unscoped/test.json new file mode 100644 index 0000000..abd8a6e --- /dev/null +++ b/v1/conformance/formats/unrecognized-unscoped/test.json @@ -0,0 +1,23 @@ +{ + "for": [ + "/req/formats/unrecognized-document", + "/req/formats/ruleset-default" + ], + "purpose": "A document with none of the ruleset's formats gets the warning, the ruleset's own rule does not run, and an unscoped rule from an extended ruleset does.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "base.yaml" + ], + "expect": { + "results": [ + { + "code": "unrecognized-format", + "severity": 1 + }, + { + "code": "s" + } + ] + } +} diff --git a/v1/conformance/formats/unscoped/document.yaml b/v1/conformance/formats/unscoped/document.yaml new file mode 100644 index 0000000..bb56b05 --- /dev/null +++ b/v1/conformance/formats/unscoped/document.yaml @@ -0,0 +1 @@ +hello: world diff --git a/v1/conformance/formats/unscoped/ruleset.yaml b/v1/conformance/formats/unscoped/ruleset.yaml new file mode 100644 index 0000000..1c6955a --- /dev/null +++ b/v1/conformance/formats/unscoped/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + r: + given: $ + then: + function: falsy diff --git a/v1/conformance/formats/unscoped/test.json b/v1/conformance/formats/unscoped/test.json new file mode 100644 index 0000000..eb6f8ba --- /dev/null +++ b/v1/conformance/formats/unscoped/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/formats/unscoped-runs-everywhere" + ], + "purpose": "Without `formats` anywhere, a rule runs on a document of no known format.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r" + } + ] + } +} diff --git a/v1/conformance/functions/alphabetical-case/document.yaml b/v1/conformance/functions/alphabetical-case/document.yaml new file mode 100644 index 0000000..193a6da --- /dev/null +++ b/v1/conformance/functions/alphabetical-case/document.yaml @@ -0,0 +1,2 @@ +ok: [a, B] +bad: [B, a] diff --git a/v1/conformance/functions/alphabetical-case/ruleset.yaml b/v1/conformance/functions/alphabetical-case/ruleset.yaml new file mode 100644 index 0000000..bff5eb4 --- /dev/null +++ b/v1/conformance/functions/alphabetical-case/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + r: + given: [$.ok, $.bad] + then: + function: alphabetical diff --git a/v1/conformance/functions/alphabetical-case/test.json b/v1/conformance/functions/alphabetical-case/test.json new file mode 100644 index 0000000..4d73511 --- /dev/null +++ b/v1/conformance/functions/alphabetical-case/test.json @@ -0,0 +1,19 @@ +{ + "for": [ + "/req/functions/alphabetical" + ], + "purpose": "Strings are compared with `localeCompare`, so `a` comes before `B` and `B` after `a`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "bad", + "0" + ] + } + ] + } +} diff --git a/v1/conformance/functions/alphabetical-keyed-by/document.yaml b/v1/conformance/functions/alphabetical-keyed-by/document.yaml new file mode 100644 index 0000000..f23b655 --- /dev/null +++ b/v1/conformance/functions/alphabetical-keyed-by/document.yaml @@ -0,0 +1,6 @@ +unsorted: + - name: b + - name: a +sorted: + - name: a + - name: b diff --git a/v1/conformance/functions/alphabetical-keyed-by/ruleset.yaml b/v1/conformance/functions/alphabetical-keyed-by/ruleset.yaml new file mode 100644 index 0000000..9a7c0e2 --- /dev/null +++ b/v1/conformance/functions/alphabetical-keyed-by/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + tags-sorted: + given: $.* + then: + function: alphabetical + functionOptions: + keyedBy: name diff --git a/v1/conformance/functions/alphabetical-keyed-by/test.json b/v1/conformance/functions/alphabetical-keyed-by/test.json new file mode 100644 index 0000000..bd461fb --- /dev/null +++ b/v1/conformance/functions/alphabetical-keyed-by/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/functions/alphabetical" + ], + "purpose": "With `keyedBy`, an array of objects is compared by that property.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "tags-sorted" + } + ] + } +} diff --git a/v1/conformance/functions/alphabetical-numbers/document.yaml b/v1/conformance/functions/alphabetical-numbers/document.yaml new file mode 100644 index 0000000..7359c64 --- /dev/null +++ b/v1/conformance/functions/alphabetical-numbers/document.yaml @@ -0,0 +1,2 @@ +ok: [2, 10] +bad: [10, 9] diff --git a/v1/conformance/functions/alphabetical-numbers/ruleset.yaml b/v1/conformance/functions/alphabetical-numbers/ruleset.yaml new file mode 100644 index 0000000..bff5eb4 --- /dev/null +++ b/v1/conformance/functions/alphabetical-numbers/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + r: + given: [$.ok, $.bad] + then: + function: alphabetical diff --git a/v1/conformance/functions/alphabetical-numbers/test.json b/v1/conformance/functions/alphabetical-numbers/test.json new file mode 100644 index 0000000..e8ce8ad --- /dev/null +++ b/v1/conformance/functions/alphabetical-numbers/test.json @@ -0,0 +1,19 @@ +{ + "for": [ + "/req/functions/alphabetical" + ], + "purpose": "Numbers are compared by value, so `[2, 10]` is in order and `[10, 9]` is not.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "bad", + "0" + ] + } + ] + } +} diff --git a/v1/conformance/functions/alphabetical/document.yaml b/v1/conformance/functions/alphabetical/document.yaml new file mode 100644 index 0000000..25c6cac --- /dev/null +++ b/v1/conformance/functions/alphabetical/document.yaml @@ -0,0 +1,2 @@ +tags: [b, a] +ok: [a, b] diff --git a/v1/conformance/functions/alphabetical/ruleset.yaml b/v1/conformance/functions/alphabetical/ruleset.yaml new file mode 100644 index 0000000..bd41535 --- /dev/null +++ b/v1/conformance/functions/alphabetical/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + sorted: + given: $.* + then: + function: alphabetical diff --git a/v1/conformance/functions/alphabetical/test.json b/v1/conformance/functions/alphabetical/test.json new file mode 100644 index 0000000..02719c8 --- /dev/null +++ b/v1/conformance/functions/alphabetical/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/functions/alphabetical" + ], + "purpose": "`alphabetical` fails on an unsorted array and passes on a sorted one.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "sorted" + } + ] + } +} diff --git a/v1/conformance/functions/casing-non-ascii/document.yaml b/v1/conformance/functions/casing-non-ascii/document.yaml new file mode 100644 index 0000000..b62491d --- /dev/null +++ b/v1/conformance/functions/casing-non-ascii/document.yaml @@ -0,0 +1,3 @@ +a: éclair +b: "" +c: fooBar diff --git a/v1/conformance/functions/casing-non-ascii/ruleset.yaml b/v1/conformance/functions/casing-non-ascii/ruleset.yaml new file mode 100644 index 0000000..c1d78fc --- /dev/null +++ b/v1/conformance/functions/casing-non-ascii/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + r: + given: [$.a, $.b, $.c] + then: + function: casing + functionOptions: + type: "camel" diff --git a/v1/conformance/functions/casing-non-ascii/test.json b/v1/conformance/functions/casing-non-ascii/test.json new file mode 100644 index 0000000..88c0cdb --- /dev/null +++ b/v1/conformance/functions/casing-non-ascii/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/functions/casing" + ], + "purpose": "Only ASCII letters count, so `\u00e9clair` is not camel case; the empty string passes.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "a" + ] + } + ] + } +} diff --git a/v1/conformance/functions/casing-types/document.yaml b/v1/conformance/functions/casing-types/document.yaml new file mode 100644 index 0000000..8386847 --- /dev/null +++ b/v1/conformance/functions/casing-types/document.yaml @@ -0,0 +1,6 @@ +camel: [fooBar, foo_bar] +pascal: [FooBar, fooBar] +snake: [foo_bar, foo-bar] +macro: [FOO_BAR, foo_bar] +cobol: [FOO-BAR, foo-bar] +flat: [foobar, fooBar] diff --git a/v1/conformance/functions/casing-types/ruleset.yaml b/v1/conformance/functions/casing-types/ruleset.yaml new file mode 100644 index 0000000..1cbd8cb --- /dev/null +++ b/v1/conformance/functions/casing-types/ruleset.yaml @@ -0,0 +1,37 @@ +rules: + camel: + given: $.camel[*] + then: + function: casing + functionOptions: + type: camel + pascal: + given: $.pascal[*] + then: + function: casing + functionOptions: + type: pascal + snake: + given: $.snake[*] + then: + function: casing + functionOptions: + type: snake + macro: + given: $.macro[*] + then: + function: casing + functionOptions: + type: macro + cobol: + given: $.cobol[*] + then: + function: casing + functionOptions: + type: cobol + flat: + given: $.flat[*] + then: + function: casing + functionOptions: + type: flat diff --git a/v1/conformance/functions/casing-types/test.json b/v1/conformance/functions/casing-types/test.json new file mode 100644 index 0000000..f01a5bc --- /dev/null +++ b/v1/conformance/functions/casing-types/test.json @@ -0,0 +1,30 @@ +{ + "for": [ + "/req/functions/casing" + ], + "purpose": "Each case accepts a value written in it and rejects one written in another case.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "camel" + }, + { + "code": "pascal" + }, + { + "code": "snake" + }, + { + "code": "macro" + }, + { + "code": "cobol" + }, + { + "code": "flat" + } + ] + } +} diff --git a/v1/conformance/functions/casing/document.yaml b/v1/conformance/functions/casing/document.yaml new file mode 100644 index 0000000..0b6899a --- /dev/null +++ b/v1/conformance/functions/casing/document.yaml @@ -0,0 +1,3 @@ +names: + - kebab-case + - camelCase diff --git a/v1/conformance/functions/casing/ruleset.yaml b/v1/conformance/functions/casing/ruleset.yaml new file mode 100644 index 0000000..aa90323 --- /dev/null +++ b/v1/conformance/functions/casing/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + kebab: + given: $.names[*] + then: + function: casing + functionOptions: + type: kebab diff --git a/v1/conformance/functions/casing/test.json b/v1/conformance/functions/casing/test.json new file mode 100644 index 0000000..69ec047 --- /dev/null +++ b/v1/conformance/functions/casing/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/functions/casing" + ], + "purpose": "`casing` with `type: kebab` fails on a camel-case string.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "kebab" + } + ] + } +} diff --git a/v1/conformance/functions/defined/document.yaml b/v1/conformance/functions/defined/document.yaml new file mode 100644 index 0000000..8bd71e7 --- /dev/null +++ b/v1/conformance/functions/defined/document.yaml @@ -0,0 +1,2 @@ +info: + title: Petstore diff --git a/v1/conformance/functions/defined/ruleset.yaml b/v1/conformance/functions/defined/ruleset.yaml new file mode 100644 index 0000000..087736d --- /dev/null +++ b/v1/conformance/functions/defined/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + has-title: + given: $.info + then: + field: title + function: defined + has-description: + given: $.info + then: + field: description + function: defined diff --git a/v1/conformance/functions/defined/test.json b/v1/conformance/functions/defined/test.json new file mode 100644 index 0000000..db83878 --- /dev/null +++ b/v1/conformance/functions/defined/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/functions/defined" + ], + "purpose": "`defined` fails on an absent property and passes on a present one.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "has-description" + } + ] + } +} diff --git a/v1/conformance/functions/enumeration-types/document.yaml b/v1/conformance/functions/enumeration-types/document.yaml new file mode 100644 index 0000000..850ef84 --- /dev/null +++ b/v1/conformance/functions/enumeration-types/document.yaml @@ -0,0 +1,2 @@ +s: "1" +n: 1.0 diff --git a/v1/conformance/functions/enumeration-types/ruleset.yaml b/v1/conformance/functions/enumeration-types/ruleset.yaml new file mode 100644 index 0000000..8cc07b9 --- /dev/null +++ b/v1/conformance/functions/enumeration-types/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + r: + given: [$.s, $.n] + then: + function: enumeration + functionOptions: + values: [1] diff --git a/v1/conformance/functions/enumeration-types/test.json b/v1/conformance/functions/enumeration-types/test.json new file mode 100644 index 0000000..95baf74 --- /dev/null +++ b/v1/conformance/functions/enumeration-types/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/functions/enumeration" + ], + "purpose": "`\"1\"` is not one of `[1]`, while `1.0` is.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "s" + ] + } + ] + } +} diff --git a/v1/conformance/functions/enumeration/document.yaml b/v1/conformance/functions/enumeration/document.yaml new file mode 100644 index 0000000..21cac5f --- /dev/null +++ b/v1/conformance/functions/enumeration/document.yaml @@ -0,0 +1,2 @@ +x: c +y: a diff --git a/v1/conformance/functions/enumeration/ruleset.yaml b/v1/conformance/functions/enumeration/ruleset.yaml new file mode 100644 index 0000000..af28cae --- /dev/null +++ b/v1/conformance/functions/enumeration/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + allowed: + given: $.* + then: + function: enumeration + functionOptions: + values: [a, b] diff --git a/v1/conformance/functions/enumeration/test.json b/v1/conformance/functions/enumeration/test.json new file mode 100644 index 0000000..26d0935 --- /dev/null +++ b/v1/conformance/functions/enumeration/test.json @@ -0,0 +1,17 @@ +{ + "for": [ + "/req/functions/enumeration" + ], + "purpose": "`enumeration` fails on a value outside `values`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "path": [ + "x" + ] + } + ] + } +} diff --git a/v1/conformance/functions/falsy/document.yaml b/v1/conformance/functions/falsy/document.yaml new file mode 100644 index 0000000..0374eba --- /dev/null +++ b/v1/conformance/functions/falsy/document.yaml @@ -0,0 +1,7 @@ +a: false +b: "" +c: 0 +d: null +e: [] +f: {} +g: x diff --git a/v1/conformance/functions/falsy/ruleset.yaml b/v1/conformance/functions/falsy/ruleset.yaml new file mode 100644 index 0000000..76172d5 --- /dev/null +++ b/v1/conformance/functions/falsy/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + all-falsy: + given: $.* + then: + function: falsy diff --git a/v1/conformance/functions/falsy/test.json b/v1/conformance/functions/falsy/test.json new file mode 100644 index 0000000..206b8ae --- /dev/null +++ b/v1/conformance/functions/falsy/test.json @@ -0,0 +1,27 @@ +{ + "for": [ + "/req/functions/falsy" + ], + "purpose": "`falsy` fails on an empty array, an empty object, and a string, and passes on `false`, `\"\"`, `0`, and `null`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "path": [ + "e" + ] + }, + { + "path": [ + "f" + ] + }, + { + "path": [ + "g" + ] + } + ] + } +} diff --git a/v1/conformance/functions/input-types/document.yaml b/v1/conformance/functions/input-types/document.yaml new file mode 100644 index 0000000..443ebd8 --- /dev/null +++ b/v1/conformance/functions/input-types/document.yaml @@ -0,0 +1,3 @@ +n: 5 +s: bcd +t: abc diff --git a/v1/conformance/functions/input-types/ruleset.yaml b/v1/conformance/functions/input-types/ruleset.yaml new file mode 100644 index 0000000..2a7ced9 --- /dev/null +++ b/v1/conformance/functions/input-types/ruleset.yaml @@ -0,0 +1,20 @@ +rules: + starts-with-a: + given: $.* + then: + function: pattern + functionOptions: + match: "^a" + absent: + given: $ + then: + field: missing + function: pattern + functionOptions: + match: "^a" + one-of: + given: $.s + then: + function: xor + functionOptions: + properties: [p, q] diff --git a/v1/conformance/functions/input-types/test.json b/v1/conformance/functions/input-types/test.json new file mode 100644 index 0000000..6beee10 --- /dev/null +++ b/v1/conformance/functions/input-types/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/functions/input-types" + ], + "purpose": "`pattern` passes on a number and on an absent value, and fails only on the string that does not match; `xor` passes on a string.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "starts-with-a", + "path": [ + "s" + ] + } + ] + } +} diff --git a/v1/conformance/functions/length-code-units/document.yaml b/v1/conformance/functions/length-code-units/document.yaml new file mode 100644 index 0000000..23b39fb --- /dev/null +++ b/v1/conformance/functions/length-code-units/document.yaml @@ -0,0 +1,2 @@ +e: "é" +emoji: "😀" diff --git a/v1/conformance/functions/length-code-units/ruleset.yaml b/v1/conformance/functions/length-code-units/ruleset.yaml new file mode 100644 index 0000000..69db7bb --- /dev/null +++ b/v1/conformance/functions/length-code-units/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + r: + given: [$.e, $.emoji] + then: + function: length + functionOptions: + min: 2 diff --git a/v1/conformance/functions/length-code-units/test.json b/v1/conformance/functions/length-code-units/test.json new file mode 100644 index 0000000..d7e7781 --- /dev/null +++ b/v1/conformance/functions/length-code-units/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/functions/length" + ], + "purpose": "A string's length is its number of UTF-16 code units: `\u00e9` is one, an emoji two.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "e" + ] + } + ] + } +} diff --git a/v1/conformance/functions/length-min/document.yaml b/v1/conformance/functions/length-min/document.yaml new file mode 100644 index 0000000..33549b2 --- /dev/null +++ b/v1/conformance/functions/length-min/document.yaml @@ -0,0 +1,6 @@ +o: + a: 1 +p: + a: 1 + b: 2 +s: x diff --git a/v1/conformance/functions/length-min/ruleset.yaml b/v1/conformance/functions/length-min/ruleset.yaml new file mode 100644 index 0000000..18b471c --- /dev/null +++ b/v1/conformance/functions/length-min/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + at-least-two: + given: $.* + then: + function: length + functionOptions: + min: 2 diff --git a/v1/conformance/functions/length-min/test.json b/v1/conformance/functions/length-min/test.json new file mode 100644 index 0000000..0a79370 --- /dev/null +++ b/v1/conformance/functions/length-min/test.json @@ -0,0 +1,24 @@ +{ + "for": [ + "/req/functions/length" + ], + "purpose": "`length` with `min: 2` fails on an object with one property and a string of one character, and passes on an object with two.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "at-least-two", + "path": [ + "o" + ] + }, + { + "code": "at-least-two", + "path": [ + "s" + ] + } + ] + } +} diff --git a/v1/conformance/functions/length/document.yaml b/v1/conformance/functions/length/document.yaml new file mode 100644 index 0000000..247e940 --- /dev/null +++ b/v1/conformance/functions/length/document.yaml @@ -0,0 +1,4 @@ +short: ab +long: abcdef +many: [1, 2, 3, 4, 5] +number: 3 diff --git a/v1/conformance/functions/length/ruleset.yaml b/v1/conformance/functions/length/ruleset.yaml new file mode 100644 index 0000000..35a3c92 --- /dev/null +++ b/v1/conformance/functions/length/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + bounded: + given: $.* + then: + function: length + functionOptions: + max: 4 diff --git a/v1/conformance/functions/length/test.json b/v1/conformance/functions/length/test.json new file mode 100644 index 0000000..1f13e28 --- /dev/null +++ b/v1/conformance/functions/length/test.json @@ -0,0 +1,22 @@ +{ + "for": [ + "/req/functions/length" + ], + "purpose": "`length` with `max: 4` fails on a string of six characters and an array of five elements, and passes on a number below the bound.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "path": [ + "long" + ] + }, + { + "path": [ + "many" + ] + } + ] + } +} diff --git a/v1/conformance/functions/or-null/document.yaml b/v1/conformance/functions/or-null/document.yaml new file mode 100644 index 0000000..f6baaf8 --- /dev/null +++ b/v1/conformance/functions/or-null/document.yaml @@ -0,0 +1,2 @@ +one: {a: null} +none: {} diff --git a/v1/conformance/functions/or-null/ruleset.yaml b/v1/conformance/functions/or-null/ruleset.yaml new file mode 100644 index 0000000..8ed6d16 --- /dev/null +++ b/v1/conformance/functions/or-null/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + r: + given: [$.one, $.none] + then: + function: or + functionOptions: + properties: ["a", "b"] diff --git a/v1/conformance/functions/or-null/test.json b/v1/conformance/functions/or-null/test.json new file mode 100644 index 0000000..a42d126 --- /dev/null +++ b/v1/conformance/functions/or-null/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/functions/or" + ], + "purpose": "A key whose value is null counts as present, so `{a: null}` satisfies `or` and `{}` does not.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "none" + ] + } + ] + } +} diff --git a/v1/conformance/functions/or/document.yaml b/v1/conformance/functions/or/document.yaml new file mode 100644 index 0000000..c2cc4d1 --- /dev/null +++ b/v1/conformance/functions/or/document.yaml @@ -0,0 +1,6 @@ +both: + p: 1 + q: 1 +one: + p: 1 +neither: {} diff --git a/v1/conformance/functions/or/ruleset.yaml b/v1/conformance/functions/or/ruleset.yaml new file mode 100644 index 0000000..6c5de8f --- /dev/null +++ b/v1/conformance/functions/or/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + any-of: + given: $.* + then: + function: or + functionOptions: + properties: [p, q] diff --git a/v1/conformance/functions/or/test.json b/v1/conformance/functions/or/test.json new file mode 100644 index 0000000..51aeb38 --- /dev/null +++ b/v1/conformance/functions/or/test.json @@ -0,0 +1,17 @@ +{ + "for": [ + "/req/functions/or" + ], + "purpose": "`or` fails unless at least one of `properties` is present.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "path": [ + "neither" + ] + } + ] + } +} diff --git a/v1/conformance/functions/pattern/document.yaml b/v1/conformance/functions/pattern/document.yaml new file mode 100644 index 0000000..37c3bbc --- /dev/null +++ b/v1/conformance/functions/pattern/document.yaml @@ -0,0 +1,2 @@ +a: abc +b: ABC diff --git a/v1/conformance/functions/pattern/ruleset.yaml b/v1/conformance/functions/pattern/ruleset.yaml new file mode 100644 index 0000000..5305c27 --- /dev/null +++ b/v1/conformance/functions/pattern/ruleset.yaml @@ -0,0 +1,13 @@ +rules: + must-match: + given: $.* + then: + function: pattern + functionOptions: + match: "^[a-z]+$" + must-not-match: + given: $.* + then: + function: pattern + functionOptions: + notMatch: "^abc$" diff --git a/v1/conformance/functions/pattern/test.json b/v1/conformance/functions/pattern/test.json new file mode 100644 index 0000000..2bab063 --- /dev/null +++ b/v1/conformance/functions/pattern/test.json @@ -0,0 +1,24 @@ +{ + "for": [ + "/req/functions/pattern" + ], + "purpose": "`match` fails on a string that does not match, `notMatch` fails on one that does.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "must-match", + "path": [ + "b" + ] + }, + { + "code": "must-not-match", + "path": [ + "a" + ] + } + ] + } +} diff --git a/v1/conformance/functions/schema-absent/document.yaml b/v1/conformance/functions/schema-absent/document.yaml new file mode 100644 index 0000000..8bd71e7 --- /dev/null +++ b/v1/conformance/functions/schema-absent/document.yaml @@ -0,0 +1,2 @@ +info: + title: Petstore diff --git a/v1/conformance/functions/schema-absent/ruleset.yaml b/v1/conformance/functions/schema-absent/ruleset.yaml new file mode 100644 index 0000000..73f6bd3 --- /dev/null +++ b/v1/conformance/functions/schema-absent/ruleset.yaml @@ -0,0 +1,9 @@ +rules: + contact-shape: + given: $.info + then: + field: contact + function: schema + functionOptions: + schema: + type: object diff --git a/v1/conformance/functions/schema-absent/test.json b/v1/conformance/functions/schema-absent/test.json new file mode 100644 index 0000000..fd77f2a --- /dev/null +++ b/v1/conformance/functions/schema-absent/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/functions/schema" + ], + "purpose": "`schema` fails on an absent value.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "contact-shape" + } + ] + } +} diff --git a/v1/conformance/functions/schema-all-errors/document.yaml b/v1/conformance/functions/schema-all-errors/document.yaml new file mode 100644 index 0000000..c9717f6 --- /dev/null +++ b/v1/conformance/functions/schema-all-errors/document.yaml @@ -0,0 +1 @@ +v: x diff --git a/v1/conformance/functions/schema-all-errors/ruleset.yaml b/v1/conformance/functions/schema-all-errors/ruleset.yaml new file mode 100644 index 0000000..b91c195 --- /dev/null +++ b/v1/conformance/functions/schema-all-errors/ruleset.yaml @@ -0,0 +1,8 @@ +rules: + r: + given: $.v + then: + function: schema + functionOptions: + schema: {"type": "string", "minLength": 3, "pattern": "^y"} + allErrors: true diff --git a/v1/conformance/functions/schema-all-errors/test.json b/v1/conformance/functions/schema-all-errors/test.json new file mode 100644 index 0000000..35a1283 --- /dev/null +++ b/v1/conformance/functions/schema-all-errors/test.json @@ -0,0 +1,24 @@ +{ + "for": [ + "/req/functions/schema" + ], + "purpose": "With `allErrors: true`, a value that fails two keywords gets two problems.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "v" + ] + }, + { + "code": "r", + "path": [ + "v" + ] + } + ] + } +} diff --git a/v1/conformance/functions/schema-default-draft/document.yaml b/v1/conformance/functions/schema-default-draft/document.yaml new file mode 100644 index 0000000..30d0486 --- /dev/null +++ b/v1/conformance/functions/schema-default-draft/document.yaml @@ -0,0 +1,2 @@ +t: [1] +o: {} diff --git a/v1/conformance/functions/schema-default-draft/ruleset.yaml b/v1/conformance/functions/schema-default-draft/ruleset.yaml new file mode 100644 index 0000000..0ea2691 --- /dev/null +++ b/v1/conformance/functions/schema-default-draft/ruleset.yaml @@ -0,0 +1,17 @@ +rules: + prefix: + given: $.t + then: + function: schema + functionOptions: + schema: + type: array + prefixItems: + - type: string + needs-a: + given: $.o + then: + function: schema + functionOptions: + schema: + required: [a] diff --git a/v1/conformance/functions/schema-default-draft/test.json b/v1/conformance/functions/schema-default-draft/test.json new file mode 100644 index 0000000..ea46d34 --- /dev/null +++ b/v1/conformance/functions/schema-default-draft/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/functions/schema" + ], + "purpose": "Without `$schema` or `dialect`, the schema is read as draft 7: `prefixItems` is ignored, `required` is not.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "needs-a" + } + ] + } +} diff --git a/v1/conformance/functions/schema-dialect/document.yaml b/v1/conformance/functions/schema-dialect/document.yaml new file mode 100644 index 0000000..9712c92 --- /dev/null +++ b/v1/conformance/functions/schema-dialect/document.yaml @@ -0,0 +1 @@ +t: [1] diff --git a/v1/conformance/functions/schema-dialect/ruleset.yaml b/v1/conformance/functions/schema-dialect/ruleset.yaml new file mode 100644 index 0000000..4d14efc --- /dev/null +++ b/v1/conformance/functions/schema-dialect/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + prefix: + given: $.t + then: + function: schema + functionOptions: + dialect: draft2020-12 + schema: + type: array + prefixItems: + - type: string diff --git a/v1/conformance/functions/schema-dialect/test.json b/v1/conformance/functions/schema-dialect/test.json new file mode 100644 index 0000000..a70b0f0 --- /dev/null +++ b/v1/conformance/functions/schema-dialect/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/functions/schema" + ], + "purpose": "With `dialect: draft2020-12`, `prefixItems` applies.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "prefix" + } + ] + } +} diff --git a/v1/conformance/functions/schema-first-error/document.yaml b/v1/conformance/functions/schema-first-error/document.yaml new file mode 100644 index 0000000..c9717f6 --- /dev/null +++ b/v1/conformance/functions/schema-first-error/document.yaml @@ -0,0 +1 @@ +v: x diff --git a/v1/conformance/functions/schema-first-error/ruleset.yaml b/v1/conformance/functions/schema-first-error/ruleset.yaml new file mode 100644 index 0000000..be28076 --- /dev/null +++ b/v1/conformance/functions/schema-first-error/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + r: + given: $.v + then: + function: schema + functionOptions: + schema: {"type": "string", "minLength": 3, "pattern": "^y"} diff --git a/v1/conformance/functions/schema-first-error/test.json b/v1/conformance/functions/schema-first-error/test.json new file mode 100644 index 0000000..b828fd2 --- /dev/null +++ b/v1/conformance/functions/schema-first-error/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/functions/schema" + ], + "purpose": "Without `allErrors`, a value that fails two keywords gets one problem.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "v" + ] + } + ] + } +} diff --git a/v1/conformance/functions/schema-format/document.yaml b/v1/conformance/functions/schema-format/document.yaml new file mode 100644 index 0000000..1f3446f --- /dev/null +++ b/v1/conformance/functions/schema-format/document.yaml @@ -0,0 +1,2 @@ +bad: not-an-email +ok: a@b.co diff --git a/v1/conformance/functions/schema-format/ruleset.yaml b/v1/conformance/functions/schema-format/ruleset.yaml new file mode 100644 index 0000000..ce2b5e4 --- /dev/null +++ b/v1/conformance/functions/schema-format/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + r: + given: [$.bad, $.ok] + then: + function: schema + functionOptions: + schema: {"type": "string", "format": "email"} diff --git a/v1/conformance/functions/schema-format/test.json b/v1/conformance/functions/schema-format/test.json new file mode 100644 index 0000000..82080cc --- /dev/null +++ b/v1/conformance/functions/schema-format/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/functions/schema" + ], + "purpose": "The `format` keyword is asserted, so `not-an-email` fails `format: email`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "bad" + ] + } + ] + } +} diff --git a/v1/conformance/functions/schema-ref/document.yaml b/v1/conformance/functions/schema-ref/document.yaml new file mode 100644 index 0000000..df4170b --- /dev/null +++ b/v1/conformance/functions/schema-ref/document.yaml @@ -0,0 +1 @@ +v: text diff --git a/v1/conformance/functions/schema-ref/ruleset.yaml b/v1/conformance/functions/schema-ref/ruleset.yaml new file mode 100644 index 0000000..c3b788d --- /dev/null +++ b/v1/conformance/functions/schema-ref/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + r: + given: $.v + then: + function: schema + functionOptions: + schema: {"$ref": "#/$defs/x", "$defs": {"x": {"type": "integer"}}} diff --git a/v1/conformance/functions/schema-ref/test.json b/v1/conformance/functions/schema-ref/test.json new file mode 100644 index 0000000..94e74f1 --- /dev/null +++ b/v1/conformance/functions/schema-ref/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/functions/schema" + ], + "purpose": "A `$ref` inside the option resolves against the option's own `$defs`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "v" + ] + } + ] + } +} diff --git a/v1/conformance/functions/schema/document.yaml b/v1/conformance/functions/schema/document.yaml new file mode 100644 index 0000000..562f8d7 --- /dev/null +++ b/v1/conformance/functions/schema/document.yaml @@ -0,0 +1,2 @@ +x: 1 +y: text diff --git a/v1/conformance/functions/schema/ruleset.yaml b/v1/conformance/functions/schema/ruleset.yaml new file mode 100644 index 0000000..6450708 --- /dev/null +++ b/v1/conformance/functions/schema/ruleset.yaml @@ -0,0 +1,8 @@ +rules: + strings: + given: $.* + then: + function: schema + functionOptions: + schema: + type: string diff --git a/v1/conformance/functions/schema/test.json b/v1/conformance/functions/schema/test.json new file mode 100644 index 0000000..d4b5576 --- /dev/null +++ b/v1/conformance/functions/schema/test.json @@ -0,0 +1,17 @@ +{ + "for": [ + "/req/functions/schema" + ], + "purpose": "`schema` fails on a value that does not validate against the given JSON Schema.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "path": [ + "x" + ] + } + ] + } +} diff --git a/v1/conformance/functions/truthy/document.yaml b/v1/conformance/functions/truthy/document.yaml new file mode 100644 index 0000000..0374eba --- /dev/null +++ b/v1/conformance/functions/truthy/document.yaml @@ -0,0 +1,7 @@ +a: false +b: "" +c: 0 +d: null +e: [] +f: {} +g: x diff --git a/v1/conformance/functions/truthy/ruleset.yaml b/v1/conformance/functions/truthy/ruleset.yaml new file mode 100644 index 0000000..79daeb4 --- /dev/null +++ b/v1/conformance/functions/truthy/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + all-truthy: + given: $.* + then: + function: truthy diff --git a/v1/conformance/functions/truthy/test.json b/v1/conformance/functions/truthy/test.json new file mode 100644 index 0000000..b6c1567 --- /dev/null +++ b/v1/conformance/functions/truthy/test.json @@ -0,0 +1,32 @@ +{ + "for": [ + "/req/functions/truthy" + ], + "purpose": "`truthy` fails on `false`, `\"\"`, `0`, and `null`, and passes on an empty array, an empty object, and a string.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "path": [ + "a" + ] + }, + { + "path": [ + "b" + ] + }, + { + "path": [ + "c" + ] + }, + { + "path": [ + "d" + ] + } + ] + } +} diff --git a/v1/conformance/functions/undefined/document.yaml b/v1/conformance/functions/undefined/document.yaml new file mode 100644 index 0000000..8bd71e7 --- /dev/null +++ b/v1/conformance/functions/undefined/document.yaml @@ -0,0 +1,2 @@ +info: + title: Petstore diff --git a/v1/conformance/functions/undefined/ruleset.yaml b/v1/conformance/functions/undefined/ruleset.yaml new file mode 100644 index 0000000..731e986 --- /dev/null +++ b/v1/conformance/functions/undefined/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + no-title: + given: $.info + then: + field: title + function: undefined + no-description: + given: $.info + then: + field: description + function: undefined diff --git a/v1/conformance/functions/undefined/test.json b/v1/conformance/functions/undefined/test.json new file mode 100644 index 0000000..6bd613a --- /dev/null +++ b/v1/conformance/functions/undefined/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/functions/undefined" + ], + "purpose": "`undefined` fails on a present property and passes on an absent one.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "no-title" + } + ] + } +} diff --git a/v1/conformance/functions/unreferenced-reusable-object/document.yaml b/v1/conformance/functions/unreferenced-reusable-object/document.yaml new file mode 100644 index 0000000..0f3d31e --- /dev/null +++ b/v1/conformance/functions/unreferenced-reusable-object/document.yaml @@ -0,0 +1,15 @@ +paths: + /pets: + get: + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Used" +components: + schemas: + Used: + type: object + Unused: + type: object diff --git a/v1/conformance/functions/unreferenced-reusable-object/ruleset.yaml b/v1/conformance/functions/unreferenced-reusable-object/ruleset.yaml new file mode 100644 index 0000000..c5723f9 --- /dev/null +++ b/v1/conformance/functions/unreferenced-reusable-object/ruleset.yaml @@ -0,0 +1,8 @@ +rules: + no-unused-schemas: + resolved: false + given: $.components.schemas + then: + function: unreferencedReusableObject + functionOptions: + reusableObjectsLocation: "#/components/schemas" diff --git a/v1/conformance/functions/unreferenced-reusable-object/test.json b/v1/conformance/functions/unreferenced-reusable-object/test.json new file mode 100644 index 0000000..5b869c6 --- /dev/null +++ b/v1/conformance/functions/unreferenced-reusable-object/test.json @@ -0,0 +1,19 @@ +{ + "for": [ + "/req/functions/unreferenced-reusable-object" + ], + "purpose": "`unreferencedReusableObject` reports a member of the container that nothing references.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "path": [ + "components", + "schemas", + "Unused" + ] + } + ] + } +} diff --git a/v1/conformance/functions/xor-null/document.yaml b/v1/conformance/functions/xor-null/document.yaml new file mode 100644 index 0000000..c3fa4a4 --- /dev/null +++ b/v1/conformance/functions/xor-null/document.yaml @@ -0,0 +1,2 @@ +one: {a: null} +two: {a: null, b: 1} diff --git a/v1/conformance/functions/xor-null/ruleset.yaml b/v1/conformance/functions/xor-null/ruleset.yaml new file mode 100644 index 0000000..95dfa35 --- /dev/null +++ b/v1/conformance/functions/xor-null/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + r: + given: [$.one, $.two] + then: + function: xor + functionOptions: + properties: ["a", "b"] diff --git a/v1/conformance/functions/xor-null/test.json b/v1/conformance/functions/xor-null/test.json new file mode 100644 index 0000000..7595948 --- /dev/null +++ b/v1/conformance/functions/xor-null/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/functions/xor" + ], + "purpose": "A key whose value is null counts as present, so `{a: null, b: 1}` has two of the properties.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "two" + ] + } + ] + } +} diff --git a/v1/conformance/functions/xor/document.yaml b/v1/conformance/functions/xor/document.yaml new file mode 100644 index 0000000..c2cc4d1 --- /dev/null +++ b/v1/conformance/functions/xor/document.yaml @@ -0,0 +1,6 @@ +both: + p: 1 + q: 1 +one: + p: 1 +neither: {} diff --git a/v1/conformance/functions/xor/ruleset.yaml b/v1/conformance/functions/xor/ruleset.yaml new file mode 100644 index 0000000..67590d3 --- /dev/null +++ b/v1/conformance/functions/xor/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + one-of: + given: $.* + then: + function: xor + functionOptions: + properties: [p, q] diff --git a/v1/conformance/functions/xor/test.json b/v1/conformance/functions/xor/test.json new file mode 100644 index 0000000..2d9d4c0 --- /dev/null +++ b/v1/conformance/functions/xor/test.json @@ -0,0 +1,22 @@ +{ + "for": [ + "/req/functions/xor" + ], + "purpose": "`xor` fails unless exactly one of `properties` is present.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "path": [ + "both" + ] + }, + { + "path": [ + "neither" + ] + } + ] + } +} diff --git a/v1/conformance/given/alias-declared/ruleset.yaml b/v1/conformance/given/alias-declared/ruleset.yaml new file mode 100644 index 0000000..5c6df1b --- /dev/null +++ b/v1/conformance/given/alias-declared/ruleset.yaml @@ -0,0 +1,8 @@ +aliases: + Info: + - $.info +rules: + r: + given: "#Info.title" + then: + function: truthy diff --git a/v1/conformance/given/alias-declared/test.json b/v1/conformance/given/alias-declared/test.json new file mode 100644 index 0000000..882c165 --- /dev/null +++ b/v1/conformance/given/alias-declared/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/given/alias-defined" + ], + "purpose": "A rule referencing an alias the ruleset declares is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/given/alias-expansion/document.yaml b/v1/conformance/given/alias-expansion/document.yaml new file mode 100644 index 0000000..f3370ab --- /dev/null +++ b/v1/conformance/given/alias-expansion/document.yaml @@ -0,0 +1,3 @@ +info: + title: Petstore + description: "" diff --git a/v1/conformance/given/alias-expansion/ruleset.yaml b/v1/conformance/given/alias-expansion/ruleset.yaml new file mode 100644 index 0000000..d4e280f --- /dev/null +++ b/v1/conformance/given/alias-expansion/ruleset.yaml @@ -0,0 +1,8 @@ +aliases: + Info: + - $..info +rules: + info-description: + given: "#Info.description" + then: + function: truthy diff --git a/v1/conformance/given/alias-expansion/test.json b/v1/conformance/given/alias-expansion/test.json new file mode 100644 index 0000000..a9c8086 --- /dev/null +++ b/v1/conformance/given/alias-expansion/test.json @@ -0,0 +1,19 @@ +{ + "for": [ + "/req/given/alias-expansion" + ], + "purpose": "`#Info.description` is evaluated as `$..info.description`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "info-description", + "path": [ + "info", + "description" + ] + } + ] + } +} diff --git a/v1/conformance/given/alias-redefined-both/base.yaml b/v1/conformance/given/alias-redefined-both/base.yaml new file mode 100644 index 0000000..50cd65d --- /dev/null +++ b/v1/conformance/given/alias-redefined-both/base.yaml @@ -0,0 +1,8 @@ +aliases: + X: + - $.info +rules: + base-rule: + given: $.info + then: + function: truthy diff --git a/v1/conformance/given/alias-redefined-both/ruleset.yaml b/v1/conformance/given/alias-redefined-both/ruleset.yaml new file mode 100644 index 0000000..621237b --- /dev/null +++ b/v1/conformance/given/alias-redefined-both/ruleset.yaml @@ -0,0 +1,10 @@ +extends: ./base.yaml +aliases: + X: + - $.info +rules: + base-rule: + given: "#X" + then: + field: title + function: truthy diff --git a/v1/conformance/given/alias-redefined-both/test.json b/v1/conformance/given/alias-redefined-both/test.json new file mode 100644 index 0000000..7e6dc63 --- /dev/null +++ b/v1/conformance/given/alias-redefined-both/test.json @@ -0,0 +1,13 @@ +{ + "for": [ + "/req/given/alias-defined" + ], + "purpose": "A rule that redefines an inherited rule with an alias both rulesets declare is valid.", + "ruleset": "ruleset.yaml", + "files": [ + "base.yaml" + ], + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/given/alias-redefined-own/base.yaml b/v1/conformance/given/alias-redefined-own/base.yaml new file mode 100644 index 0000000..400132e --- /dev/null +++ b/v1/conformance/given/alias-redefined-own/base.yaml @@ -0,0 +1,5 @@ +rules: + base-rule: + given: $.info + then: + function: truthy diff --git a/v1/conformance/given/alias-redefined-own/ruleset.yaml b/v1/conformance/given/alias-redefined-own/ruleset.yaml new file mode 100644 index 0000000..65d22fb --- /dev/null +++ b/v1/conformance/given/alias-redefined-own/ruleset.yaml @@ -0,0 +1,10 @@ +extends: ./base.yaml +aliases: + Y: + - $.info +rules: + base-rule: + given: "#Y" + then: + field: title + function: truthy diff --git a/v1/conformance/given/alias-redefined-own/test.json b/v1/conformance/given/alias-redefined-own/test.json new file mode 100644 index 0000000..6d24c5f --- /dev/null +++ b/v1/conformance/given/alias-redefined-own/test.json @@ -0,0 +1,13 @@ +{ + "for": [ + "/req/given/alias-defined" + ], + "purpose": "A rule that redefines an inherited rule with an alias only the extending ruleset declares is invalid.", + "ruleset": "ruleset.yaml", + "files": [ + "base.yaml" + ], + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/given/alias-undefined/ruleset.yaml b/v1/conformance/given/alias-undefined/ruleset.yaml new file mode 100644 index 0000000..6a9efef --- /dev/null +++ b/v1/conformance/given/alias-undefined/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + r: + given: "#Missing" + then: + function: truthy diff --git a/v1/conformance/given/alias-undefined/test.json b/v1/conformance/given/alias-undefined/test.json new file mode 100644 index 0000000..5be7fa7 --- /dev/null +++ b/v1/conformance/given/alias-undefined/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/given/alias-defined" + ], + "purpose": "A rule referencing an alias the ruleset does not declare is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/given/bare-property-name/ruleset.yaml b/v1/conformance/given/bare-property-name/ruleset.yaml new file mode 100644 index 0000000..2781e04 --- /dev/null +++ b/v1/conformance/given/bare-property-name/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + r: + given: paths + then: + function: truthy diff --git a/v1/conformance/given/bare-property-name/test.json b/v1/conformance/given/bare-property-name/test.json new file mode 100644 index 0000000..cc205e8 --- /dev/null +++ b/v1/conformance/given/bare-property-name/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A rule whose `given` is a bare property name is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/given/evaluate-all-matches/document.yaml b/v1/conformance/given/evaluate-all-matches/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/given/evaluate-all-matches/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/given/evaluate-all-matches/ruleset.yaml b/v1/conformance/given/evaluate-all-matches/ruleset.yaml new file mode 100644 index 0000000..0f7bb57 --- /dev/null +++ b/v1/conformance/given/evaluate-all-matches/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/given/evaluate-all-matches/test.json b/v1/conformance/given/evaluate-all-matches/test.json new file mode 100644 index 0000000..83332db --- /dev/null +++ b/v1/conformance/given/evaluate-all-matches/test.json @@ -0,0 +1,28 @@ +{ + "for": [ + "/req/given/evaluate" + ], + "purpose": "Two operations without `summary` produce one result each.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "acme-operation-summary", + "path": [ + "paths", + "/pets", + "get" + ] + }, + { + "code": "acme-operation-summary", + "path": [ + "paths", + "/pets", + "post" + ] + } + ] + } +} diff --git a/v1/conformance/given/evaluate-multiple-expressions/document.yaml b/v1/conformance/given/evaluate-multiple-expressions/document.yaml new file mode 100644 index 0000000..3eaf590 --- /dev/null +++ b/v1/conformance/given/evaluate-multiple-expressions/document.yaml @@ -0,0 +1,3 @@ +info: + title: "" + description: "" diff --git a/v1/conformance/given/evaluate-multiple-expressions/ruleset.yaml b/v1/conformance/given/evaluate-multiple-expressions/ruleset.yaml new file mode 100644 index 0000000..163e05b --- /dev/null +++ b/v1/conformance/given/evaluate-multiple-expressions/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + info-filled: + given: + - $.info.title + - $.info.description + then: + function: truthy diff --git a/v1/conformance/given/evaluate-multiple-expressions/test.json b/v1/conformance/given/evaluate-multiple-expressions/test.json new file mode 100644 index 0000000..1a6b085 --- /dev/null +++ b/v1/conformance/given/evaluate-multiple-expressions/test.json @@ -0,0 +1,26 @@ +{ + "for": [ + "/req/given/evaluate" + ], + "purpose": "Two path expressions in `given` each produce their results.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "info-filled", + "path": [ + "info", + "title" + ] + }, + { + "code": "info-filled", + "path": [ + "info", + "description" + ] + } + ] + } +} diff --git a/v1/conformance/given/jsonpath-filter/document.yaml b/v1/conformance/given/jsonpath-filter/document.yaml new file mode 100644 index 0000000..dfdf782 --- /dev/null +++ b/v1/conformance/given/jsonpath-filter/document.yaml @@ -0,0 +1,5 @@ +parameters: + - in: query + name: "" + - in: header + name: "" diff --git a/v1/conformance/given/jsonpath-filter/ruleset.yaml b/v1/conformance/given/jsonpath-filter/ruleset.yaml new file mode 100644 index 0000000..b3b4b6e --- /dev/null +++ b/v1/conformance/given/jsonpath-filter/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + param-name: + given: '$.parameters[?(@.in === "header")].name' + then: + function: truthy diff --git a/v1/conformance/given/jsonpath-filter/test.json b/v1/conformance/given/jsonpath-filter/test.json new file mode 100644 index 0000000..0e76baa --- /dev/null +++ b/v1/conformance/given/jsonpath-filter/test.json @@ -0,0 +1,20 @@ +{ + "for": [ + "/req/given/jsonpath" + ], + "purpose": "A filter expression in parentheses, `?(@.in === \"header\")`, selects the header parameter with an empty name; the index in its path is a string.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "param-name", + "path": [ + "parameters", + "1", + "name" + ] + } + ] + } +} diff --git a/v1/conformance/given/match/document.yaml b/v1/conformance/given/match/document.yaml new file mode 100644 index 0000000..5e8f9de --- /dev/null +++ b/v1/conformance/given/match/document.yaml @@ -0,0 +1,3 @@ +paths: + /pets: + get: {} diff --git a/v1/conformance/given/match/ruleset.yaml b/v1/conformance/given/match/ruleset.yaml new file mode 100644 index 0000000..dc960f5 --- /dev/null +++ b/v1/conformance/given/match/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + no-paths: + given: $.paths[*] + then: + function: falsy diff --git a/v1/conformance/given/match/test.json b/v1/conformance/given/match/test.json new file mode 100644 index 0000000..fb20ccf --- /dev/null +++ b/v1/conformance/given/match/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/given/no-match" + ], + "purpose": "The same rule on a document where `given` matches produces results.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "no-paths" + } + ] + } +} diff --git a/v1/conformance/given/no-match/document.yaml b/v1/conformance/given/no-match/document.yaml new file mode 100644 index 0000000..045c5bb --- /dev/null +++ b/v1/conformance/given/no-match/document.yaml @@ -0,0 +1,2 @@ +info: + title: No paths here diff --git a/v1/conformance/given/no-match/ruleset.yaml b/v1/conformance/given/no-match/ruleset.yaml new file mode 100644 index 0000000..dc960f5 --- /dev/null +++ b/v1/conformance/given/no-match/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + no-paths: + given: $.paths[*] + then: + function: falsy diff --git a/v1/conformance/given/no-match/test.json b/v1/conformance/given/no-match/test.json new file mode 100644 index 0000000..af6a7bf --- /dev/null +++ b/v1/conformance/given/no-match/test.json @@ -0,0 +1,11 @@ +{ + "for": [ + "/req/given/no-match" + ], + "purpose": "A rule whose `given` matches nothing produces no results, even though its function would fail on any node.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [] + } +} diff --git a/v1/conformance/given/once-per-node/document.yaml b/v1/conformance/given/once-per-node/document.yaml new file mode 100644 index 0000000..83b97ff --- /dev/null +++ b/v1/conformance/given/once-per-node/document.yaml @@ -0,0 +1,4 @@ +openapi: 3.1.0 +info: + version: "1" +paths: {} diff --git a/v1/conformance/given/once-per-node/ruleset.yaml b/v1/conformance/given/once-per-node/ruleset.yaml new file mode 100644 index 0000000..2e5bb9f --- /dev/null +++ b/v1/conformance/given/once-per-node/ruleset.yaml @@ -0,0 +1,9 @@ +rules: + r: + given: + - $.info + - $..info + - "$['info']" + then: + field: title + function: truthy diff --git a/v1/conformance/given/once-per-node/test.json b/v1/conformance/given/once-per-node/test.json new file mode 100644 index 0000000..f016089 --- /dev/null +++ b/v1/conformance/given/once-per-node/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/given/once-per-node" + ], + "purpose": "A node matched by three path expressions of one rule is checked once.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "info" + ] + } + ] + } +} diff --git a/v1/conformance/given/path-expression-forms/ruleset.yaml b/v1/conformance/given/path-expression-forms/ruleset.yaml new file mode 100644 index 0000000..57af737 --- /dev/null +++ b/v1/conformance/given/path-expression-forms/ruleset.yaml @@ -0,0 +1,10 @@ +aliases: + Info: + - $.info +rules: + r: + given: + - $.paths + - "#Info" + then: + function: truthy diff --git a/v1/conformance/given/path-expression-forms/test.json b/v1/conformance/given/path-expression-forms/test.json new file mode 100644 index 0000000..67bd1be --- /dev/null +++ b/v1/conformance/given/path-expression-forms/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A JSONPath expression starting with `$` and an alias reference starting with `#` are both valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/given/present/ruleset.yaml b/v1/conformance/given/present/ruleset.yaml new file mode 100644 index 0000000..0f7bb57 --- /dev/null +++ b/v1/conformance/given/present/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/given/present/test.json b/v1/conformance/given/present/test.json new file mode 100644 index 0000000..146689e --- /dev/null +++ b/v1/conformance/given/present/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A rule with `given` and `then` is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/given/required/ruleset.yaml b/v1/conformance/given/required/ruleset.yaml new file mode 100644 index 0000000..35b4ca6 --- /dev/null +++ b/v1/conformance/given/required/ruleset.yaml @@ -0,0 +1,4 @@ +rules: + r: + then: + function: truthy diff --git a/v1/conformance/given/required/test.json b/v1/conformance/given/required/test.json new file mode 100644 index 0000000..131e13d --- /dev/null +++ b/v1/conformance/given/required/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A rule with `then` but no `given` is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/given/resolved-default/document.yaml b/v1/conformance/given/resolved-default/document.yaml new file mode 100644 index 0000000..ed5d0a0 --- /dev/null +++ b/v1/conformance/given/resolved-default/document.yaml @@ -0,0 +1,8 @@ +components: + schemas: + Id: + type: integer + Pet: + properties: + id: + $ref: "#/components/schemas/Id" diff --git a/v1/conformance/given/resolved-default/ruleset.yaml b/v1/conformance/given/resolved-default/ruleset.yaml new file mode 100644 index 0000000..66522c6 --- /dev/null +++ b/v1/conformance/given/resolved-default/ruleset.yaml @@ -0,0 +1,6 @@ +rules: + id-has-type: + given: $.components.schemas.Pet.properties.id + then: + field: type + function: defined diff --git a/v1/conformance/given/resolved-default/test.json b/v1/conformance/given/resolved-default/test.json new file mode 100644 index 0000000..7c5409f --- /dev/null +++ b/v1/conformance/given/resolved-default/test.json @@ -0,0 +1,11 @@ +{ + "for": [ + "/req/given/resolved" + ], + "purpose": "By default `given` sees the resolved document, where the `$ref` has been replaced by a schema with `type`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [] + } +} diff --git a/v1/conformance/given/resolved-false/document.yaml b/v1/conformance/given/resolved-false/document.yaml new file mode 100644 index 0000000..ed5d0a0 --- /dev/null +++ b/v1/conformance/given/resolved-false/document.yaml @@ -0,0 +1,8 @@ +components: + schemas: + Id: + type: integer + Pet: + properties: + id: + $ref: "#/components/schemas/Id" diff --git a/v1/conformance/given/resolved-false/ruleset.yaml b/v1/conformance/given/resolved-false/ruleset.yaml new file mode 100644 index 0000000..4d00762 --- /dev/null +++ b/v1/conformance/given/resolved-false/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + id-has-type: + resolved: false + given: $.components.schemas.Pet.properties.id + then: + field: type + function: defined diff --git a/v1/conformance/given/resolved-false/test.json b/v1/conformance/given/resolved-false/test.json new file mode 100644 index 0000000..157d995 --- /dev/null +++ b/v1/conformance/given/resolved-false/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/given/resolved" + ], + "purpose": "With `resolved: false`, `given` sees the `$ref` object, which has no `type`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "id-has-type" + } + ] + } +} diff --git a/v1/conformance/given/shape-empty-array/ruleset.yaml b/v1/conformance/given/shape-empty-array/ruleset.yaml new file mode 100644 index 0000000..83e6d0b --- /dev/null +++ b/v1/conformance/given/shape-empty-array/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + r: + given: [] + then: + function: truthy diff --git a/v1/conformance/given/shape-empty-array/test.json b/v1/conformance/given/shape-empty-array/test.json new file mode 100644 index 0000000..32038cd --- /dev/null +++ b/v1/conformance/given/shape-empty-array/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A rule with `given: []` is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/given/shape-object/ruleset.yaml b/v1/conformance/given/shape-object/ruleset.yaml new file mode 100644 index 0000000..b50b5fa --- /dev/null +++ b/v1/conformance/given/shape-object/ruleset.yaml @@ -0,0 +1,6 @@ +rules: + r: + given: + path: $.info + then: + function: truthy diff --git a/v1/conformance/given/shape-object/test.json b/v1/conformance/given/shape-object/test.json new file mode 100644 index 0000000..917e9ce --- /dev/null +++ b/v1/conformance/given/shape-object/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A rule whose `given` is an object is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/given/shape-valid/ruleset.yaml b/v1/conformance/given/shape-valid/ruleset.yaml new file mode 100644 index 0000000..09760c3 --- /dev/null +++ b/v1/conformance/given/shape-valid/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + one: + given: $.info + then: + function: truthy + many: + given: + - $.info.title + - $.info.description + then: + function: truthy diff --git a/v1/conformance/given/shape-valid/test.json b/v1/conformance/given/shape-valid/test.json new file mode 100644 index 0000000..bf30084 --- /dev/null +++ b/v1/conformance/given/shape-valid/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "`given` as a single path expression and as an array of them are both valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/linter/accept-valid/document.yaml b/v1/conformance/linter/accept-valid/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/linter/accept-valid/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/linter/accept-valid/ruleset.yaml b/v1/conformance/linter/accept-valid/ruleset.yaml new file mode 100644 index 0000000..0f7bb57 --- /dev/null +++ b/v1/conformance/linter/accept-valid/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/linter/accept-valid/test.json b/v1/conformance/linter/accept-valid/test.json new file mode 100644 index 0000000..c0cbee5 --- /dev/null +++ b/v1/conformance/linter/accept-valid/test.json @@ -0,0 +1,19 @@ +{ + "for": [ + "/req/linter/reject-invalid", + "/req/linter/accept-valid" + ], + "purpose": "A valid ruleset is loaded and linting produces its results.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "acme-operation-summary" + }, + { + "code": "acme-operation-summary" + } + ] + } +} diff --git a/v1/conformance/linter/reject-invalid/document.yaml b/v1/conformance/linter/reject-invalid/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/linter/reject-invalid/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/linter/reject-invalid/ruleset.yaml b/v1/conformance/linter/reject-invalid/ruleset.yaml new file mode 100644 index 0000000..025932f --- /dev/null +++ b/v1/conformance/linter/reject-invalid/ruleset.yaml @@ -0,0 +1 @@ +- rules: {} diff --git a/v1/conformance/linter/reject-invalid/test.json b/v1/conformance/linter/reject-invalid/test.json new file mode 100644 index 0000000..c35bbaa --- /dev/null +++ b/v1/conformance/linter/reject-invalid/test.json @@ -0,0 +1,11 @@ +{ + "for": [ + "/req/linter/reject-invalid" + ], + "purpose": "Given an invalid ruleset, the linter rejects it and produces no results.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "rejected": true + } +} diff --git a/v1/conformance/linter/walkthrough/acme-base.yaml b/v1/conformance/linter/walkthrough/acme-base.yaml new file mode 100644 index 0000000..625fbb1 --- /dev/null +++ b/v1/conformance/linter/walkthrough/acme-base.yaml @@ -0,0 +1,16 @@ +rules: + operation-summary: + description: Every operation needs a summary. + severity: warn + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy + operation-id-camel-case: + description: operationId is written in camelCase. + recommended: false + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'].operationId + then: + function: casing + functionOptions: + type: camel diff --git a/v1/conformance/linter/walkthrough/legacy/petstore.yaml b/v1/conformance/linter/walkthrough/legacy/petstore.yaml new file mode 100644 index 0000000..0924c4b --- /dev/null +++ b/v1/conformance/linter/walkthrough/legacy/petstore.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + operationId: list_pets + responses: + "200": + description: OK + post: + summary: Add a pet + operationId: addPet + tags: [pets] + responses: + "201": + description: Created diff --git a/v1/conformance/linter/walkthrough/ruleset.yaml b/v1/conformance/linter/walkthrough/ruleset.yaml new file mode 100644 index 0000000..5f11592 --- /dev/null +++ b/v1/conformance/linter/walkthrough/ruleset.yaml @@ -0,0 +1,18 @@ +extends: ./acme-base.yaml +formats: [oas3] +aliases: + Operation: + - "$.paths[*]['get','put','post','delete']" +rules: + operation-id-camel-case: true + operation-has-tags: + description: Every operation is tagged. + severity: hint + given: "#Operation" + then: + field: tags + function: defined +overrides: + - files: ["legacy/**"] + rules: + operation-summary: error diff --git a/v1/conformance/linter/walkthrough/test.json b/v1/conformance/linter/walkthrough/test.json new file mode 100644 index 0000000..f62ca1b --- /dev/null +++ b/v1/conformance/linter/walkthrough/test.json @@ -0,0 +1,48 @@ +{ + "for": [ + "/req/extends/load", + "/req/overrides/scope", + "/req/shorthands/true", + "/req/formats/ruleset-default", + "/req/given/alias-expansion", + "/req/result/severity" + ], + "purpose": "The running example of the Linting chapter: a ruleset that extends a base, enables a rule by shorthand, declares an alias, and overrides a severity for legacy documents, linting one legacy document.", + "ruleset": "ruleset.yaml", + "document": "legacy/petstore.yaml", + "files": [ + "acme-base.yaml" + ], + "expect": { + "results": [ + { + "code": "operation-summary", + "path": [ + "paths", + "/pets", + "get" + ], + "severity": 0 + }, + { + "code": "operation-has-tags", + "path": [ + "paths", + "/pets", + "get" + ], + "severity": 3 + }, + { + "code": "operation-id-camel-case", + "path": [ + "paths", + "/pets", + "get", + "operationId" + ], + "severity": 1 + } + ] + } +} diff --git a/v1/conformance/message/default-description/document.yaml b/v1/conformance/message/default-description/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/message/default-description/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/message/default-description/ruleset.yaml b/v1/conformance/message/default-description/ruleset.yaml new file mode 100644 index 0000000..0f7bb57 --- /dev/null +++ b/v1/conformance/message/default-description/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/message/default-description/test.json b/v1/conformance/message/default-description/test.json new file mode 100644 index 0000000..8de765f --- /dev/null +++ b/v1/conformance/message/default-description/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/message/default" + ], + "purpose": "Without `message`, the result carries the rule's description.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "message": "Every operation needs a summary." + }, + { + "message": "Every operation needs a summary." + } + ] + } +} diff --git a/v1/conformance/message/override/document.yaml b/v1/conformance/message/override/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/message/override/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/message/override/ruleset.yaml b/v1/conformance/message/override/ruleset.yaml new file mode 100644 index 0000000..d920ef0 --- /dev/null +++ b/v1/conformance/message/override/ruleset.yaml @@ -0,0 +1,8 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + message: Summary missing here + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/message/override/test.json b/v1/conformance/message/override/test.json new file mode 100644 index 0000000..c658d6d --- /dev/null +++ b/v1/conformance/message/override/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/message/override" + ], + "purpose": "A rule's `message` replaces the reported message.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "message": "Summary missing here" + }, + { + "message": "Summary missing here" + } + ] + } +} diff --git a/v1/conformance/message/placeholder-description/document.yaml b/v1/conformance/message/placeholder-description/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/message/placeholder-description/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/message/placeholder-description/ruleset.yaml b/v1/conformance/message/placeholder-description/ruleset.yaml new file mode 100644 index 0000000..8db3aaf --- /dev/null +++ b/v1/conformance/message/placeholder-description/ruleset.yaml @@ -0,0 +1,8 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + message: "{{description}}" + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/message/placeholder-description/test.json b/v1/conformance/message/placeholder-description/test.json new file mode 100644 index 0000000..a4137d2 --- /dev/null +++ b/v1/conformance/message/placeholder-description/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/message/placeholders" + ], + "purpose": "`{{description}}` resolves to the rule's description.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "message": "Every operation needs a summary." + }, + { + "message": "Every operation needs a summary." + } + ] + } +} diff --git a/v1/conformance/message/placeholder-path/document.yaml b/v1/conformance/message/placeholder-path/document.yaml new file mode 100644 index 0000000..ddd57cf --- /dev/null +++ b/v1/conformance/message/placeholder-path/document.yaml @@ -0,0 +1,2 @@ +info: + title: x diff --git a/v1/conformance/message/placeholder-path/ruleset.yaml b/v1/conformance/message/placeholder-path/ruleset.yaml new file mode 100644 index 0000000..e32ffd9 --- /dev/null +++ b/v1/conformance/message/placeholder-path/ruleset.yaml @@ -0,0 +1,6 @@ +rules: + r: + message: "{{path}}" + given: $.info.title + then: + function: falsy diff --git a/v1/conformance/message/placeholder-path/test.json b/v1/conformance/message/placeholder-path/test.json new file mode 100644 index 0000000..77bd070 --- /dev/null +++ b/v1/conformance/message/placeholder-path/test.json @@ -0,0 +1,16 @@ +{ + "for": [ + "/req/message/placeholders" + ], + "purpose": "`{{path}}` is the JSON Pointer to the offending value, with a leading `#`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "message": "#/info/title" + } + ] + } +} diff --git a/v1/conformance/message/placeholder-property/document.yaml b/v1/conformance/message/placeholder-property/document.yaml new file mode 100644 index 0000000..2542544 --- /dev/null +++ b/v1/conformance/message/placeholder-property/document.yaml @@ -0,0 +1,2 @@ +info: + title: "" diff --git a/v1/conformance/message/placeholder-property/ruleset.yaml b/v1/conformance/message/placeholder-property/ruleset.yaml new file mode 100644 index 0000000..51128aa --- /dev/null +++ b/v1/conformance/message/placeholder-property/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + info-title: + message: "{{property}}" + given: $.info + then: + field: title + function: truthy diff --git a/v1/conformance/message/placeholder-property/test.json b/v1/conformance/message/placeholder-property/test.json new file mode 100644 index 0000000..bfcd914 --- /dev/null +++ b/v1/conformance/message/placeholder-property/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/message/placeholders" + ], + "purpose": "`{{property}}` resolves to the key of the offending value.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "message": "title" + } + ] + } +} diff --git a/v1/conformance/overrides/aliases-existing-rule/document.yaml b/v1/conformance/overrides/aliases-existing-rule/document.yaml new file mode 100644 index 0000000..f3370ab --- /dev/null +++ b/v1/conformance/overrides/aliases-existing-rule/document.yaml @@ -0,0 +1,3 @@ +info: + title: Petstore + description: "" diff --git a/v1/conformance/overrides/aliases-existing-rule/ruleset.yaml b/v1/conformance/overrides/aliases-existing-rule/ruleset.yaml new file mode 100644 index 0000000..b876e4b --- /dev/null +++ b/v1/conformance/overrides/aliases-existing-rule/ruleset.yaml @@ -0,0 +1,15 @@ +aliases: + Info: + - $..info +rules: + info-description: + given: "#Info.description" + then: + function: truthy +overrides: + - files: ["**/*.yaml"] + aliases: + Info: + - $.nothing + rules: + info-description: error diff --git a/v1/conformance/overrides/aliases-existing-rule/test.json b/v1/conformance/overrides/aliases-existing-rule/test.json new file mode 100644 index 0000000..97a5b6b --- /dev/null +++ b/v1/conformance/overrides/aliases-existing-rule/test.json @@ -0,0 +1,16 @@ +{ + "for": [ + "/req/overrides/aliases" + ], + "purpose": "An alias an override declares does not change a rule the ruleset defines: the rule keeps the ruleset's alias and only its severity changes.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "info-description", + "severity": 0 + } + ] + } +} diff --git a/v1/conformance/overrides/aliases-new-rule/document.yaml b/v1/conformance/overrides/aliases-new-rule/document.yaml new file mode 100644 index 0000000..f3370ab --- /dev/null +++ b/v1/conformance/overrides/aliases-new-rule/document.yaml @@ -0,0 +1,3 @@ +info: + title: Petstore + description: "" diff --git a/v1/conformance/overrides/aliases-new-rule/ruleset.yaml b/v1/conformance/overrides/aliases-new-rule/ruleset.yaml new file mode 100644 index 0000000..8cc1165 --- /dev/null +++ b/v1/conformance/overrides/aliases-new-rule/ruleset.yaml @@ -0,0 +1,18 @@ +aliases: + Info: + - $.nothing +rules: + base: + given: $.info + then: + function: truthy +overrides: + - files: ["**/*.yaml"] + aliases: + Info: + - $..info + rules: + override-rule: + given: "#Info.description" + then: + function: truthy diff --git a/v1/conformance/overrides/aliases-new-rule/test.json b/v1/conformance/overrides/aliases-new-rule/test.json new file mode 100644 index 0000000..4195140 --- /dev/null +++ b/v1/conformance/overrides/aliases-new-rule/test.json @@ -0,0 +1,19 @@ +{ + "for": [ + "/req/overrides/aliases" + ], + "purpose": "A rule an override defines resolves aliases against the override's, which take precedence over the ruleset's alias of the same name.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "override-rule", + "path": [ + "info", + "description" + ] + } + ] + } +} diff --git a/v1/conformance/overrides/aliases-outside-globs/document.yaml b/v1/conformance/overrides/aliases-outside-globs/document.yaml new file mode 100644 index 0000000..f3370ab --- /dev/null +++ b/v1/conformance/overrides/aliases-outside-globs/document.yaml @@ -0,0 +1,3 @@ +info: + title: Petstore + description: "" diff --git a/v1/conformance/overrides/aliases-outside-globs/ruleset.yaml b/v1/conformance/overrides/aliases-outside-globs/ruleset.yaml new file mode 100644 index 0000000..6e6d752 --- /dev/null +++ b/v1/conformance/overrides/aliases-outside-globs/ruleset.yaml @@ -0,0 +1,15 @@ +aliases: + Info: + - $..info +rules: + info-description: + given: "#Info.description" + then: + function: truthy +overrides: + - files: ["legacy/**"] + aliases: + Info: + - $.nothing + rules: + info-description: error diff --git a/v1/conformance/overrides/aliases-outside-globs/test.json b/v1/conformance/overrides/aliases-outside-globs/test.json new file mode 100644 index 0000000..7b89513 --- /dev/null +++ b/v1/conformance/overrides/aliases-outside-globs/test.json @@ -0,0 +1,16 @@ +{ + "for": [ + "/req/overrides/aliases" + ], + "purpose": "Outside the override's globs its aliases have no effect, and the rule keeps its default severity.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "info-description", + "severity": 1 + } + ] + } +} diff --git a/v1/conformance/overrides/extends-with-rules/a.yaml b/v1/conformance/overrides/extends-with-rules/a.yaml new file mode 100644 index 0000000..093e21f --- /dev/null +++ b/v1/conformance/overrides/extends-with-rules/a.yaml @@ -0,0 +1,6 @@ +rules: + a: + given: $.info + then: + field: title + function: truthy diff --git a/v1/conformance/overrides/extends-with-rules/b.yaml b/v1/conformance/overrides/extends-with-rules/b.yaml new file mode 100644 index 0000000..0e605c5 --- /dev/null +++ b/v1/conformance/overrides/extends-with-rules/b.yaml @@ -0,0 +1,6 @@ +rules: + b: + given: $.info + then: + field: title + function: truthy diff --git a/v1/conformance/overrides/extends-with-rules/document.yaml b/v1/conformance/overrides/extends-with-rules/document.yaml new file mode 100644 index 0000000..bf5f2ba --- /dev/null +++ b/v1/conformance/overrides/extends-with-rules/document.yaml @@ -0,0 +1,3 @@ +openapi: 3.1.0 +info: + version: "1" diff --git a/v1/conformance/overrides/extends-with-rules/ruleset.yaml b/v1/conformance/overrides/extends-with-rules/ruleset.yaml new file mode 100644 index 0000000..a409b44 --- /dev/null +++ b/v1/conformance/overrides/extends-with-rules/ruleset.yaml @@ -0,0 +1,12 @@ +extends: ./a.yaml +rules: + r: + given: $.info + then: + field: title + function: truthy +overrides: + - files: ["**/*.yaml"] + extends: ./b.yaml + rules: + r: error diff --git a/v1/conformance/overrides/extends-with-rules/test.json b/v1/conformance/overrides/extends-with-rules/test.json new file mode 100644 index 0000000..6a21943 --- /dev/null +++ b/v1/conformance/overrides/extends-with-rules/test.json @@ -0,0 +1,26 @@ +{ + "for": [ + "/req/overrides/extends" + ], + "purpose": "An applying override with `extends` and `rules` adds to the rulesets the ruleset extends: `a`, `b`, and `r` all run.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "a.yaml", + "b.yaml" + ], + "expect": { + "results": [ + { + "code": "a" + }, + { + "code": "b" + }, + { + "code": "r", + "severity": 0 + } + ] + } +} diff --git a/v1/conformance/overrides/extends-without-rules/a.yaml b/v1/conformance/overrides/extends-without-rules/a.yaml new file mode 100644 index 0000000..093e21f --- /dev/null +++ b/v1/conformance/overrides/extends-without-rules/a.yaml @@ -0,0 +1,6 @@ +rules: + a: + given: $.info + then: + field: title + function: truthy diff --git a/v1/conformance/overrides/extends-without-rules/b.yaml b/v1/conformance/overrides/extends-without-rules/b.yaml new file mode 100644 index 0000000..0e605c5 --- /dev/null +++ b/v1/conformance/overrides/extends-without-rules/b.yaml @@ -0,0 +1,6 @@ +rules: + b: + given: $.info + then: + field: title + function: truthy diff --git a/v1/conformance/overrides/extends-without-rules/document.yaml b/v1/conformance/overrides/extends-without-rules/document.yaml new file mode 100644 index 0000000..bf5f2ba --- /dev/null +++ b/v1/conformance/overrides/extends-without-rules/document.yaml @@ -0,0 +1,3 @@ +openapi: 3.1.0 +info: + version: "1" diff --git a/v1/conformance/overrides/extends-without-rules/ruleset.yaml b/v1/conformance/overrides/extends-without-rules/ruleset.yaml new file mode 100644 index 0000000..35e2950 --- /dev/null +++ b/v1/conformance/overrides/extends-without-rules/ruleset.yaml @@ -0,0 +1,10 @@ +extends: ./a.yaml +rules: + r: + given: $.info + then: + field: title + function: truthy +overrides: + - files: ["**/*.yaml"] + extends: ./b.yaml diff --git a/v1/conformance/overrides/extends-without-rules/test.json b/v1/conformance/overrides/extends-without-rules/test.json new file mode 100644 index 0000000..1fa0f47 --- /dev/null +++ b/v1/conformance/overrides/extends-without-rules/test.json @@ -0,0 +1,22 @@ +{ + "for": [ + "/req/overrides/extends" + ], + "purpose": "An applying override with `extends` and no `rules` replaces the rulesets the ruleset extends: `b` and the ruleset's own `r` run, `a` does not.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "a.yaml", + "b.yaml" + ], + "expect": { + "results": [ + { + "code": "b" + }, + { + "code": "r" + } + ] + } +} diff --git a/v1/conformance/overrides/extends/document.yaml b/v1/conformance/overrides/extends/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/overrides/extends/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/overrides/extends/extra.yaml b/v1/conformance/overrides/extends/extra.yaml new file mode 100644 index 0000000..e5c39d1 --- /dev/null +++ b/v1/conformance/overrides/extends/extra.yaml @@ -0,0 +1,5 @@ +rules: + extra-rule: + given: $ + then: + function: falsy diff --git a/v1/conformance/overrides/extends/ruleset.yaml b/v1/conformance/overrides/extends/ruleset.yaml new file mode 100644 index 0000000..eacc758 --- /dev/null +++ b/v1/conformance/overrides/extends/ruleset.yaml @@ -0,0 +1,8 @@ +rules: + base-rule: + given: $.nothing + then: + function: truthy +overrides: + - files: ["**/*.yaml"] + extends: ./extra.yaml diff --git a/v1/conformance/overrides/extends/test.json b/v1/conformance/overrides/extends/test.json new file mode 100644 index 0000000..07ed566 --- /dev/null +++ b/v1/conformance/overrides/extends/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/overrides/extends" + ], + "purpose": "An override's `extends` adds rules for the documents it matches.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "extra.yaml" + ], + "expect": { + "results": [ + { + "code": "extra-rule" + } + ] + } +} diff --git a/v1/conformance/overrides/file-scoped-empty/ruleset.yaml b/v1/conformance/overrides/file-scoped-empty/ruleset.yaml new file mode 100644 index 0000000..32b0654 --- /dev/null +++ b/v1/conformance/overrides/file-scoped-empty/ruleset.yaml @@ -0,0 +1,9 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy +overrides: + - files: ["**/*.yaml"] diff --git a/v1/conformance/overrides/file-scoped-empty/test.json b/v1/conformance/overrides/file-scoped-empty/test.json new file mode 100644 index 0000000..89eae6e --- /dev/null +++ b/v1/conformance/overrides/file-scoped-empty/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A file-scoped override with neither `extends` nor `rules` is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/overrides/file-scoped-valid/base.yaml b/v1/conformance/overrides/file-scoped-valid/base.yaml new file mode 100644 index 0000000..81db109 --- /dev/null +++ b/v1/conformance/overrides/file-scoped-valid/base.yaml @@ -0,0 +1,5 @@ +rules: + base-rule: + given: $ + then: + function: falsy diff --git a/v1/conformance/overrides/file-scoped-valid/ruleset.yaml b/v1/conformance/overrides/file-scoped-valid/ruleset.yaml new file mode 100644 index 0000000..6af2388 --- /dev/null +++ b/v1/conformance/overrides/file-scoped-valid/ruleset.yaml @@ -0,0 +1,18 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy +overrides: + - files: ["legacy/**"] + extends: ./base.yaml + formats: [oas3] + parserOptions: + duplicateKeys: warn + aliases: + Info: + - $.info + rules: + acme-operation-summary: warn diff --git a/v1/conformance/overrides/file-scoped-valid/test.json b/v1/conformance/overrides/file-scoped-valid/test.json new file mode 100644 index 0000000..3a7ee87 --- /dev/null +++ b/v1/conformance/overrides/file-scoped-valid/test.json @@ -0,0 +1,13 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A file-scoped override with `rules`, `extends`, `formats`, `parserOptions`, and `aliases` is valid.", + "ruleset": "ruleset.yaml", + "files": [ + "base.yaml" + ], + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/overrides/mixed-globs/ruleset.yaml b/v1/conformance/overrides/mixed-globs/ruleset.yaml new file mode 100644 index 0000000..8755aae --- /dev/null +++ b/v1/conformance/overrides/mixed-globs/ruleset.yaml @@ -0,0 +1,9 @@ +rules: + r: + given: $ + then: + function: falsy +overrides: + - files: ["api.yaml#/paths", "**/*.yaml"] + rules: + r: hint diff --git a/v1/conformance/overrides/mixed-globs/test.json b/v1/conformance/overrides/mixed-globs/test.json new file mode 100644 index 0000000..0e2416a --- /dev/null +++ b/v1/conformance/overrides/mixed-globs/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "An override whose `files` mix a pointer glob and a plain glob is file-scoped, so it can define `rules` with shorthands and is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/overrides/order/document.yaml b/v1/conformance/overrides/order/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/overrides/order/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/overrides/order/ruleset.yaml b/v1/conformance/overrides/order/ruleset.yaml new file mode 100644 index 0000000..0eb5d5d --- /dev/null +++ b/v1/conformance/overrides/order/ruleset.yaml @@ -0,0 +1,15 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + severity: warn + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy +overrides: + - files: ["**/*.yaml"] + rules: + acme-operation-summary: error + - files: ["**/*.yaml"] + rules: + acme-operation-summary: hint diff --git a/v1/conformance/overrides/order/test.json b/v1/conformance/overrides/order/test.json new file mode 100644 index 0000000..eed3070 --- /dev/null +++ b/v1/conformance/overrides/order/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/overrides/order" + ], + "purpose": "Of two overrides that both match, the later one wins.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "severity": 3 + }, + { + "severity": 3 + } + ] + } +} diff --git a/v1/conformance/overrides/pointer-scope-at/document.yaml b/v1/conformance/overrides/pointer-scope-at/document.yaml new file mode 100644 index 0000000..c51e360 --- /dev/null +++ b/v1/conformance/overrides/pointer-scope-at/document.yaml @@ -0,0 +1,5 @@ +paths: + /a: + get: {} + /b: + get: {} diff --git a/v1/conformance/overrides/pointer-scope-at/ruleset.yaml b/v1/conformance/overrides/pointer-scope-at/ruleset.yaml new file mode 100644 index 0000000..73f002d --- /dev/null +++ b/v1/conformance/overrides/pointer-scope-at/ruleset.yaml @@ -0,0 +1,10 @@ +rules: + r: + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy +overrides: + - files: ["document.yaml#/paths/~1a/get"] + rules: + r: "off" diff --git a/v1/conformance/overrides/pointer-scope-at/test.json b/v1/conformance/overrides/pointer-scope-at/test.json new file mode 100644 index 0000000..8a9957e --- /dev/null +++ b/v1/conformance/overrides/pointer-scope-at/test.json @@ -0,0 +1,20 @@ +{ + "for": [ + "/req/overrides/pointer-scope" + ], + "purpose": "A pointer that names the result's own location silences it.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "paths", + "/b", + "get" + ] + } + ] + } +} diff --git a/v1/conformance/overrides/pointer-scope/document.yaml b/v1/conformance/overrides/pointer-scope/document.yaml new file mode 100644 index 0000000..c75fa02 --- /dev/null +++ b/v1/conformance/overrides/pointer-scope/document.yaml @@ -0,0 +1,11 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: {} + /legacy: + get: + responses: {} diff --git a/v1/conformance/overrides/pointer-scope/ruleset.yaml b/v1/conformance/overrides/pointer-scope/ruleset.yaml new file mode 100644 index 0000000..6d656b6 --- /dev/null +++ b/v1/conformance/overrides/pointer-scope/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy +overrides: + - files: ["document.yaml#/paths/~1legacy"] + rules: + acme-operation-summary: "off" diff --git a/v1/conformance/overrides/pointer-scope/test.json b/v1/conformance/overrides/pointer-scope/test.json new file mode 100644 index 0000000..e536e71 --- /dev/null +++ b/v1/conformance/overrides/pointer-scope/test.json @@ -0,0 +1,19 @@ +{ + "for": [ + "/req/overrides/pointer-scope" + ], + "purpose": "A pointer-scoped override silences the rule under its pointer only.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "path": [ + "paths", + "/pets", + "get" + ] + } + ] + } +} diff --git a/v1/conformance/overrides/pointer-scoped-extends/base.yaml b/v1/conformance/overrides/pointer-scoped-extends/base.yaml new file mode 100644 index 0000000..0f7bb57 --- /dev/null +++ b/v1/conformance/overrides/pointer-scoped-extends/base.yaml @@ -0,0 +1,7 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/overrides/pointer-scoped-extends/ruleset.yaml b/v1/conformance/overrides/pointer-scoped-extends/ruleset.yaml new file mode 100644 index 0000000..b1bfb66 --- /dev/null +++ b/v1/conformance/overrides/pointer-scoped-extends/ruleset.yaml @@ -0,0 +1,12 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy +overrides: + - files: ["document.yaml#/paths"] + extends: ./base.yaml + rules: + acme-operation-summary: error diff --git a/v1/conformance/overrides/pointer-scoped-extends/test.json b/v1/conformance/overrides/pointer-scoped-extends/test.json new file mode 100644 index 0000000..ae5149e --- /dev/null +++ b/v1/conformance/overrides/pointer-scoped-extends/test.json @@ -0,0 +1,13 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A pointer-scoped override with `extends` is invalid.", + "ruleset": "ruleset.yaml", + "files": [ + "base.yaml" + ], + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/overrides/pointer-scoped-rule-object/ruleset.yaml b/v1/conformance/overrides/pointer-scoped-rule-object/ruleset.yaml new file mode 100644 index 0000000..524ee96 --- /dev/null +++ b/v1/conformance/overrides/pointer-scoped-rule-object/ruleset.yaml @@ -0,0 +1,14 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy +overrides: + - files: ["document.yaml#/paths"] + rules: + acme-operation-summary: + given: $ + then: + function: truthy diff --git a/v1/conformance/overrides/pointer-scoped-rule-object/test.json b/v1/conformance/overrides/pointer-scoped-rule-object/test.json new file mode 100644 index 0000000..285705e --- /dev/null +++ b/v1/conformance/overrides/pointer-scoped-rule-object/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A pointer-scoped override that defines a rule object is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/overrides/pointer-scoped-valid/ruleset.yaml b/v1/conformance/overrides/pointer-scoped-valid/ruleset.yaml new file mode 100644 index 0000000..6d656b6 --- /dev/null +++ b/v1/conformance/overrides/pointer-scoped-valid/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy +overrides: + - files: ["document.yaml#/paths/~1legacy"] + rules: + acme-operation-summary: "off" diff --git a/v1/conformance/overrides/pointer-scoped-valid/test.json b/v1/conformance/overrides/pointer-scoped-valid/test.json new file mode 100644 index 0000000..4dccf59 --- /dev/null +++ b/v1/conformance/overrides/pointer-scoped-valid/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A pointer-scoped override whose `rules` are severities only is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/overrides/redefined-rule-alias-applied/document.yaml b/v1/conformance/overrides/redefined-rule-alias-applied/document.yaml new file mode 100644 index 0000000..b5f2482 --- /dev/null +++ b/v1/conformance/overrides/redefined-rule-alias-applied/document.yaml @@ -0,0 +1,2 @@ +info: + version: "1" diff --git a/v1/conformance/overrides/redefined-rule-alias-applied/ruleset.yaml b/v1/conformance/overrides/redefined-rule-alias-applied/ruleset.yaml new file mode 100644 index 0000000..75b3910 --- /dev/null +++ b/v1/conformance/overrides/redefined-rule-alias-applied/ruleset.yaml @@ -0,0 +1,16 @@ +rules: + r: + given: $.info + then: + field: title + function: truthy +overrides: + - files: ["**/*.yaml"] + aliases: + B: + - $.info + rules: + r: + given: "#B" + then: + function: truthy diff --git a/v1/conformance/overrides/redefined-rule-alias-applied/test.json b/v1/conformance/overrides/redefined-rule-alias-applied/test.json new file mode 100644 index 0000000..07f9fe6 --- /dev/null +++ b/v1/conformance/overrides/redefined-rule-alias-applied/test.json @@ -0,0 +1,11 @@ +{ + "for": [ + "/req/overrides/reject-undeclared-alias" + ], + "purpose": "An override that redefines a rule with an alias only the override declares makes a linter reject the ruleset for a document it applies to.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "rejected": true + } +} diff --git a/v1/conformance/overrides/redefined-rule-alias-declared/document.yaml b/v1/conformance/overrides/redefined-rule-alias-declared/document.yaml new file mode 100644 index 0000000..d82468a --- /dev/null +++ b/v1/conformance/overrides/redefined-rule-alias-declared/document.yaml @@ -0,0 +1,2 @@ +info: + description: no title diff --git a/v1/conformance/overrides/redefined-rule-alias-declared/ruleset.yaml b/v1/conformance/overrides/redefined-rule-alias-declared/ruleset.yaml new file mode 100644 index 0000000..b668f30 --- /dev/null +++ b/v1/conformance/overrides/redefined-rule-alias-declared/ruleset.yaml @@ -0,0 +1,17 @@ +aliases: + B: + - $.info +rules: + r: + given: $.info + then: + field: title + function: truthy +overrides: + - files: ["**/*.yaml"] + rules: + r: + given: "#B" + then: + field: title + function: truthy diff --git a/v1/conformance/overrides/redefined-rule-alias-declared/test.json b/v1/conformance/overrides/redefined-rule-alias-declared/test.json new file mode 100644 index 0000000..54ccaa4 --- /dev/null +++ b/v1/conformance/overrides/redefined-rule-alias-declared/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/overrides/reject-undeclared-alias" + ], + "purpose": "An override that redefines a rule with an alias the ruleset declares is loaded, and the redefined rule runs.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "info" + ] + } + ] + } +} diff --git a/v1/conformance/overrides/scope-match/legacy/document.yaml b/v1/conformance/overrides/scope-match/legacy/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/overrides/scope-match/legacy/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/overrides/scope-match/ruleset.yaml b/v1/conformance/overrides/scope-match/ruleset.yaml new file mode 100644 index 0000000..5eb4151 --- /dev/null +++ b/v1/conformance/overrides/scope-match/ruleset.yaml @@ -0,0 +1,12 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + severity: warn + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy +overrides: + - files: ["legacy/**"] + rules: + acme-operation-summary: error diff --git a/v1/conformance/overrides/scope-match/test.json b/v1/conformance/overrides/scope-match/test.json new file mode 100644 index 0000000..a8c4d09 --- /dev/null +++ b/v1/conformance/overrides/scope-match/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/overrides/scope" + ], + "purpose": "A document inside the override's globs gets the override's severity.", + "ruleset": "ruleset.yaml", + "document": "legacy/document.yaml", + "expect": { + "results": [ + { + "severity": 0 + }, + { + "severity": 0 + } + ] + } +} diff --git a/v1/conformance/overrides/scope-no-match/document.yaml b/v1/conformance/overrides/scope-no-match/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/overrides/scope-no-match/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/overrides/scope-no-match/ruleset.yaml b/v1/conformance/overrides/scope-no-match/ruleset.yaml new file mode 100644 index 0000000..5eb4151 --- /dev/null +++ b/v1/conformance/overrides/scope-no-match/ruleset.yaml @@ -0,0 +1,12 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + severity: warn + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy +overrides: + - files: ["legacy/**"] + rules: + acme-operation-summary: error diff --git a/v1/conformance/overrides/scope-no-match/test.json b/v1/conformance/overrides/scope-no-match/test.json new file mode 100644 index 0000000..103b9f5 --- /dev/null +++ b/v1/conformance/overrides/scope-no-match/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/overrides/scope" + ], + "purpose": "A document outside the override's globs keeps the base severity.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "severity": 1 + }, + { + "severity": 1 + } + ] + } +} diff --git a/v1/conformance/overrides/scope-outside-ruleset-directory/legacy/a.yaml b/v1/conformance/overrides/scope-outside-ruleset-directory/legacy/a.yaml new file mode 100644 index 0000000..828a396 --- /dev/null +++ b/v1/conformance/overrides/scope-outside-ruleset-directory/legacy/a.yaml @@ -0,0 +1 @@ +info: {} diff --git a/v1/conformance/overrides/scope-outside-ruleset-directory/rules/ruleset.yaml b/v1/conformance/overrides/scope-outside-ruleset-directory/rules/ruleset.yaml new file mode 100644 index 0000000..478f96a --- /dev/null +++ b/v1/conformance/overrides/scope-outside-ruleset-directory/rules/ruleset.yaml @@ -0,0 +1,10 @@ +rules: + r: + given: $.info + then: + field: title + function: truthy +overrides: + - files: ['legacy/**'] + rules: + r: error diff --git a/v1/conformance/overrides/scope-outside-ruleset-directory/test.json b/v1/conformance/overrides/scope-outside-ruleset-directory/test.json new file mode 100644 index 0000000..17d2cf5 --- /dev/null +++ b/v1/conformance/overrides/scope-outside-ruleset-directory/test.json @@ -0,0 +1,16 @@ +{ + "for": [ + "/req/overrides/scope" + ], + "purpose": "The same glob does not match a document in a `legacy/` directory that is not next to the ruleset, whatever the working directory.", + "ruleset": "rules/ruleset.yaml", + "document": "legacy/a.yaml", + "expect": { + "results": [ + { + "code": "r", + "severity": 1 + } + ] + } +} diff --git a/v1/conformance/overrides/scope-ruleset-directory/rules/legacy/a.yaml b/v1/conformance/overrides/scope-ruleset-directory/rules/legacy/a.yaml new file mode 100644 index 0000000..828a396 --- /dev/null +++ b/v1/conformance/overrides/scope-ruleset-directory/rules/legacy/a.yaml @@ -0,0 +1 @@ +info: {} diff --git a/v1/conformance/overrides/scope-ruleset-directory/rules/ruleset.yaml b/v1/conformance/overrides/scope-ruleset-directory/rules/ruleset.yaml new file mode 100644 index 0000000..478f96a --- /dev/null +++ b/v1/conformance/overrides/scope-ruleset-directory/rules/ruleset.yaml @@ -0,0 +1,10 @@ +rules: + r: + given: $.info + then: + field: title + function: truthy +overrides: + - files: ['legacy/**'] + rules: + r: error diff --git a/v1/conformance/overrides/scope-ruleset-directory/test.json b/v1/conformance/overrides/scope-ruleset-directory/test.json new file mode 100644 index 0000000..b02ede0 --- /dev/null +++ b/v1/conformance/overrides/scope-ruleset-directory/test.json @@ -0,0 +1,16 @@ +{ + "for": [ + "/req/overrides/scope" + ], + "purpose": "A glob is matched against the document's path relative to the ruleset file's directory, so `legacy/**` matches a document in `legacy/` next to the ruleset.", + "ruleset": "rules/ruleset.yaml", + "document": "rules/legacy/a.yaml", + "expect": { + "results": [ + { + "code": "r", + "severity": 0 + } + ] + } +} diff --git a/v1/conformance/overrides/shape-empty/ruleset.yaml b/v1/conformance/overrides/shape-empty/ruleset.yaml new file mode 100644 index 0000000..87fe615 --- /dev/null +++ b/v1/conformance/overrides/shape-empty/ruleset.yaml @@ -0,0 +1,8 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy +overrides: [] diff --git a/v1/conformance/overrides/shape-empty/test.json b/v1/conformance/overrides/shape-empty/test.json new file mode 100644 index 0000000..d92e7a3 --- /dev/null +++ b/v1/conformance/overrides/shape-empty/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "An empty `overrides` array is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/overrides/shape-no-files/ruleset.yaml b/v1/conformance/overrides/shape-no-files/ruleset.yaml new file mode 100644 index 0000000..0dfa2f1 --- /dev/null +++ b/v1/conformance/overrides/shape-no-files/ruleset.yaml @@ -0,0 +1,10 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy +overrides: + - rules: + acme-operation-summary: error diff --git a/v1/conformance/overrides/shape-no-files/test.json b/v1/conformance/overrides/shape-no-files/test.json new file mode 100644 index 0000000..2467b3f --- /dev/null +++ b/v1/conformance/overrides/shape-no-files/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "An override without `files` is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/overrides/shape-valid/ruleset.yaml b/v1/conformance/overrides/shape-valid/ruleset.yaml new file mode 100644 index 0000000..d664dfc --- /dev/null +++ b/v1/conformance/overrides/shape-valid/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy +overrides: + - files: ["legacy/**"] + rules: + acme-operation-summary: warn diff --git a/v1/conformance/overrides/shape-valid/test.json b/v1/conformance/overrides/shape-valid/test.json new file mode 100644 index 0000000..5b9edec --- /dev/null +++ b/v1/conformance/overrides/shape-valid/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "An override with a non-empty `files` array is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/overrides/unknown-property-applied/document.yaml b/v1/conformance/overrides/unknown-property-applied/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/overrides/unknown-property-applied/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/overrides/unknown-property-applied/ruleset.yaml b/v1/conformance/overrides/unknown-property-applied/ruleset.yaml new file mode 100644 index 0000000..da03349 --- /dev/null +++ b/v1/conformance/overrides/unknown-property-applied/ruleset.yaml @@ -0,0 +1,10 @@ +rules: + r: + given: $ + then: + function: falsy +overrides: + - files: ["**/*.yaml"] + foo: bar + rules: + r: error diff --git a/v1/conformance/overrides/unknown-property-applied/test.json b/v1/conformance/overrides/unknown-property-applied/test.json new file mode 100644 index 0000000..9e959b2 --- /dev/null +++ b/v1/conformance/overrides/unknown-property-applied/test.json @@ -0,0 +1,11 @@ +{ + "for": [ + "/req/overrides/reject-foreign-property" + ], + "purpose": "An override with a property the Ruleset Object does not have makes a linter reject the ruleset for a document it applies to.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "rejected": true + } +} diff --git a/v1/conformance/overrides/unknown-property-not-applied/document.yaml b/v1/conformance/overrides/unknown-property-not-applied/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/overrides/unknown-property-not-applied/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/overrides/unknown-property-not-applied/ruleset.yaml b/v1/conformance/overrides/unknown-property-not-applied/ruleset.yaml new file mode 100644 index 0000000..b1c3ea8 --- /dev/null +++ b/v1/conformance/overrides/unknown-property-not-applied/ruleset.yaml @@ -0,0 +1,10 @@ +rules: + r: + given: $ + then: + function: falsy +overrides: + - files: ["legacy/**"] + foo: bar + rules: + r: error diff --git a/v1/conformance/overrides/unknown-property-not-applied/test.json b/v1/conformance/overrides/unknown-property-not-applied/test.json new file mode 100644 index 0000000..444f998 --- /dev/null +++ b/v1/conformance/overrides/unknown-property-not-applied/test.json @@ -0,0 +1,17 @@ +{ + "for": [ + "/req/overrides/reject-foreign-property", + "/req/overrides/checked-when-applied" + ], + "purpose": "The same override, applying to no document being linted, does not stop the ruleset from loading.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "severity": 1 + } + ] + } +} diff --git a/v1/conformance/parser-options/duplicate-keys-default/document.yaml b/v1/conformance/parser-options/duplicate-keys-default/document.yaml new file mode 100644 index 0000000..8232361 --- /dev/null +++ b/v1/conformance/parser-options/duplicate-keys-default/document.yaml @@ -0,0 +1,3 @@ +info: + title: One + title: Two diff --git a/v1/conformance/parser-options/duplicate-keys-default/ruleset.yaml b/v1/conformance/parser-options/duplicate-keys-default/ruleset.yaml new file mode 100644 index 0000000..597502e --- /dev/null +++ b/v1/conformance/parser-options/duplicate-keys-default/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + quiet: + given: $.nothing + then: + function: truthy diff --git a/v1/conformance/parser-options/duplicate-keys-default/test.json b/v1/conformance/parser-options/duplicate-keys-default/test.json new file mode 100644 index 0000000..0415050 --- /dev/null +++ b/v1/conformance/parser-options/duplicate-keys-default/test.json @@ -0,0 +1,16 @@ +{ + "for": [ + "/req/parser-options/duplicate-keys" + ], + "purpose": "A duplicate key is reported as an error by default.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "parser", + "severity": 0 + } + ] + } +} diff --git a/v1/conformance/parser-options/duplicate-keys-off/document.yaml b/v1/conformance/parser-options/duplicate-keys-off/document.yaml new file mode 100644 index 0000000..8232361 --- /dev/null +++ b/v1/conformance/parser-options/duplicate-keys-off/document.yaml @@ -0,0 +1,3 @@ +info: + title: One + title: Two diff --git a/v1/conformance/parser-options/duplicate-keys-off/ruleset.yaml b/v1/conformance/parser-options/duplicate-keys-off/ruleset.yaml new file mode 100644 index 0000000..e1a495a --- /dev/null +++ b/v1/conformance/parser-options/duplicate-keys-off/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + quiet: + given: $.nothing + then: + function: truthy +parserOptions: + duplicateKeys: off diff --git a/v1/conformance/parser-options/duplicate-keys-off/test.json b/v1/conformance/parser-options/duplicate-keys-off/test.json new file mode 100644 index 0000000..9ffd8c1 --- /dev/null +++ b/v1/conformance/parser-options/duplicate-keys-off/test.json @@ -0,0 +1,11 @@ +{ + "for": [ + "/req/parser-options/duplicate-keys" + ], + "purpose": "With `duplicateKeys: off`, a duplicate key is not reported.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [] + } +} diff --git a/v1/conformance/parser-options/duplicate-keys-warn/document.yaml b/v1/conformance/parser-options/duplicate-keys-warn/document.yaml new file mode 100644 index 0000000..8232361 --- /dev/null +++ b/v1/conformance/parser-options/duplicate-keys-warn/document.yaml @@ -0,0 +1,3 @@ +info: + title: One + title: Two diff --git a/v1/conformance/parser-options/duplicate-keys-warn/ruleset.yaml b/v1/conformance/parser-options/duplicate-keys-warn/ruleset.yaml new file mode 100644 index 0000000..89f2e39 --- /dev/null +++ b/v1/conformance/parser-options/duplicate-keys-warn/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + quiet: + given: $.nothing + then: + function: truthy +parserOptions: + duplicateKeys: warn diff --git a/v1/conformance/parser-options/duplicate-keys-warn/test.json b/v1/conformance/parser-options/duplicate-keys-warn/test.json new file mode 100644 index 0000000..e1ea541 --- /dev/null +++ b/v1/conformance/parser-options/duplicate-keys-warn/test.json @@ -0,0 +1,16 @@ +{ + "for": [ + "/req/parser-options/duplicate-keys" + ], + "purpose": "With `duplicateKeys: warn`, a duplicate key is reported as a warning.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "parser", + "severity": 1 + } + ] + } +} diff --git a/v1/conformance/parser-options/incompatible-values/document.yaml b/v1/conformance/parser-options/incompatible-values/document.yaml new file mode 100644 index 0000000..f7a4557 --- /dev/null +++ b/v1/conformance/parser-options/incompatible-values/document.yaml @@ -0,0 +1 @@ +1: x diff --git a/v1/conformance/parser-options/incompatible-values/ruleset.yaml b/v1/conformance/parser-options/incompatible-values/ruleset.yaml new file mode 100644 index 0000000..597502e --- /dev/null +++ b/v1/conformance/parser-options/incompatible-values/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + quiet: + given: $.nothing + then: + function: truthy diff --git a/v1/conformance/parser-options/incompatible-values/test.json b/v1/conformance/parser-options/incompatible-values/test.json new file mode 100644 index 0000000..5c5a9b7 --- /dev/null +++ b/v1/conformance/parser-options/incompatible-values/test.json @@ -0,0 +1,16 @@ +{ + "for": [ + "/req/parser-options/incompatible-values" + ], + "purpose": "A YAML mapping key that is a number, not a string, has no JSON representation and is reported as an error by default.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "parser", + "severity": 0 + } + ] + } +} diff --git a/v1/conformance/parser-options/shape-extension-property/ruleset.yaml b/v1/conformance/parser-options/shape-extension-property/ruleset.yaml new file mode 100644 index 0000000..ea8af5e --- /dev/null +++ b/v1/conformance/parser-options/shape-extension-property/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + r: + given: $ + then: + function: truthy +parserOptions: + x-note: not permitted here diff --git a/v1/conformance/parser-options/shape-extension-property/test.json b/v1/conformance/parser-options/shape-extension-property/test.json new file mode 100644 index 0000000..6912f00 --- /dev/null +++ b/v1/conformance/parser-options/shape-extension-property/test.json @@ -0,0 +1,11 @@ +{ + "for": [ + "/req/ruleset/schema", + "/per/extension-properties/anywhere" + ], + "purpose": "An extension property inside `parserOptions` is invalid: the permission covers the root and rules only, and `parserOptions` admits no other properties.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/parser-options/shape-unknown-option/ruleset.yaml b/v1/conformance/parser-options/shape-unknown-option/ruleset.yaml new file mode 100644 index 0000000..7ea5e59 --- /dev/null +++ b/v1/conformance/parser-options/shape-unknown-option/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + quiet: + given: $.nothing + then: + function: truthy +parserOptions: + trailingCommas: error diff --git a/v1/conformance/parser-options/shape-unknown-option/test.json b/v1/conformance/parser-options/shape-unknown-option/test.json new file mode 100644 index 0000000..a19ee28 --- /dev/null +++ b/v1/conformance/parser-options/shape-unknown-option/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "An unknown parser option is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/parser-options/shape-unknown-severity/ruleset.yaml b/v1/conformance/parser-options/shape-unknown-severity/ruleset.yaml new file mode 100644 index 0000000..0655051 --- /dev/null +++ b/v1/conformance/parser-options/shape-unknown-severity/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + quiet: + given: $.nothing + then: + function: truthy +parserOptions: + duplicateKeys: critical diff --git a/v1/conformance/parser-options/shape-unknown-severity/test.json b/v1/conformance/parser-options/shape-unknown-severity/test.json new file mode 100644 index 0000000..54773b8 --- /dev/null +++ b/v1/conformance/parser-options/shape-unknown-severity/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "`duplicateKeys: critical` is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/parser-options/shape-valid/ruleset.yaml b/v1/conformance/parser-options/shape-valid/ruleset.yaml new file mode 100644 index 0000000..d9ff1fe --- /dev/null +++ b/v1/conformance/parser-options/shape-valid/ruleset.yaml @@ -0,0 +1,10 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy +parserOptions: + duplicateKeys: warn + incompatibleValues: hint diff --git a/v1/conformance/parser-options/shape-valid/test.json b/v1/conformance/parser-options/shape-valid/test.json new file mode 100644 index 0000000..3a6ebf9 --- /dev/null +++ b/v1/conformance/parser-options/shape-valid/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "Both parser options with severity values are valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/parsing/several-documents/document.yaml b/v1/conformance/parsing/several-documents/document.yaml new file mode 100644 index 0000000..224d945 --- /dev/null +++ b/v1/conformance/parsing/several-documents/document.yaml @@ -0,0 +1,3 @@ +a: 1 +--- +b: 2 diff --git a/v1/conformance/parsing/several-documents/ruleset.yaml b/v1/conformance/parsing/several-documents/ruleset.yaml new file mode 100644 index 0000000..1c6955a --- /dev/null +++ b/v1/conformance/parsing/several-documents/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + r: + given: $ + then: + function: falsy diff --git a/v1/conformance/parsing/several-documents/test.json b/v1/conformance/parsing/several-documents/test.json new file mode 100644 index 0000000..5153f9f --- /dev/null +++ b/v1/conformance/parsing/several-documents/test.json @@ -0,0 +1,20 @@ +{ + "for": [ + "/req/parsing/several-documents" + ], + "purpose": "A YAML file with two documents gets a `parser` error, and the rules run on the first document.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "parser", + "severity": 0 + }, + { + "code": "r", + "path": [] + } + ] + } +} diff --git a/v1/conformance/parsing/syntax-error/document.yaml b/v1/conformance/parsing/syntax-error/document.yaml new file mode 100644 index 0000000..492cf48 --- /dev/null +++ b/v1/conformance/parsing/syntax-error/document.yaml @@ -0,0 +1 @@ +a: [unclosed diff --git a/v1/conformance/parsing/syntax-error/ruleset.yaml b/v1/conformance/parsing/syntax-error/ruleset.yaml new file mode 100644 index 0000000..edf9cad --- /dev/null +++ b/v1/conformance/parsing/syntax-error/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + r: + given: $.nothing + then: + function: truthy diff --git a/v1/conformance/parsing/syntax-error/test.json b/v1/conformance/parsing/syntax-error/test.json new file mode 100644 index 0000000..e706435 --- /dev/null +++ b/v1/conformance/parsing/syntax-error/test.json @@ -0,0 +1,16 @@ +{ + "for": [ + "/req/parsing/syntax-error" + ], + "purpose": "A YAML document with an unclosed flow sequence is reported as a `parser` error.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "parser", + "severity": 0 + } + ] + } +} diff --git a/v1/conformance/resolution/cycle/document.yaml b/v1/conformance/resolution/cycle/document.yaml new file mode 100644 index 0000000..a123494 --- /dev/null +++ b/v1/conformance/resolution/cycle/document.yaml @@ -0,0 +1,9 @@ +components: + schemas: + Node: + type: object + properties: + children: + type: array + items: + $ref: "#/components/schemas/Node" diff --git a/v1/conformance/resolution/cycle/ruleset.yaml b/v1/conformance/resolution/cycle/ruleset.yaml new file mode 100644 index 0000000..41c8a88 --- /dev/null +++ b/v1/conformance/resolution/cycle/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + node-is-string: + given: $.components.schemas.Node.type + then: + function: enumeration + functionOptions: + values: [string] diff --git a/v1/conformance/resolution/cycle/test.json b/v1/conformance/resolution/cycle/test.json new file mode 100644 index 0000000..63d951a --- /dev/null +++ b/v1/conformance/resolution/cycle/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/resolution/cycle" + ], + "purpose": "A schema that references itself resolves without error, and a rule sees its `type`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "node-is-string" + } + ] + } +} diff --git a/v1/conformance/resolution/external/document.yaml b/v1/conformance/resolution/external/document.yaml new file mode 100644 index 0000000..64227e6 --- /dev/null +++ b/v1/conformance/resolution/external/document.yaml @@ -0,0 +1,6 @@ +components: + schemas: + Pet: + properties: + id: + $ref: "./schemas.yaml#/Id" diff --git a/v1/conformance/resolution/external/ruleset.yaml b/v1/conformance/resolution/external/ruleset.yaml new file mode 100644 index 0000000..3df009f --- /dev/null +++ b/v1/conformance/resolution/external/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + id-is-string: + given: $.components.schemas.Pet.properties.id.type + then: + function: enumeration + functionOptions: + values: [string] diff --git a/v1/conformance/resolution/external/schemas.yaml b/v1/conformance/resolution/external/schemas.yaml new file mode 100644 index 0000000..861d71e --- /dev/null +++ b/v1/conformance/resolution/external/schemas.yaml @@ -0,0 +1,2 @@ +Id: + type: integer diff --git a/v1/conformance/resolution/external/test.json b/v1/conformance/resolution/external/test.json new file mode 100644 index 0000000..d803f12 --- /dev/null +++ b/v1/conformance/resolution/external/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/resolution/external" + ], + "purpose": "A `$ref` to another file, relative to the document, is replaced by the referenced schema.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "schemas.yaml" + ], + "expect": { + "results": [ + { + "code": "id-is-string" + } + ] + } +} diff --git a/v1/conformance/resolution/local/document.yaml b/v1/conformance/resolution/local/document.yaml new file mode 100644 index 0000000..ed5d0a0 --- /dev/null +++ b/v1/conformance/resolution/local/document.yaml @@ -0,0 +1,8 @@ +components: + schemas: + Id: + type: integer + Pet: + properties: + id: + $ref: "#/components/schemas/Id" diff --git a/v1/conformance/resolution/local/ruleset.yaml b/v1/conformance/resolution/local/ruleset.yaml new file mode 100644 index 0000000..3df009f --- /dev/null +++ b/v1/conformance/resolution/local/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + id-is-string: + given: $.components.schemas.Pet.properties.id.type + then: + function: enumeration + functionOptions: + values: [string] diff --git a/v1/conformance/resolution/local/test.json b/v1/conformance/resolution/local/test.json new file mode 100644 index 0000000..b881c08 --- /dev/null +++ b/v1/conformance/resolution/local/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/resolution/local" + ], + "purpose": "A `$ref` to a pointer in the same document is replaced by the referenced schema, whose `type` the rule then sees.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "id-is-string" + } + ] + } +} diff --git a/v1/conformance/resolution/shared-reference/document.yaml b/v1/conformance/resolution/shared-reference/document.yaml new file mode 100644 index 0000000..e47b087 --- /dev/null +++ b/v1/conformance/resolution/shared-reference/document.yaml @@ -0,0 +1,25 @@ +openapi: 3.1.0 +info: + title: Shared + version: 1.0.0 +paths: + /a: + get: + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Bad' + /b: + get: + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Bad' +components: + schemas: + Bad: + type: object diff --git a/v1/conformance/resolution/shared-reference/ruleset.yaml b/v1/conformance/resolution/shared-reference/ruleset.yaml new file mode 100644 index 0000000..4ec1176 --- /dev/null +++ b/v1/conformance/resolution/shared-reference/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + no-object: + given: $.paths..schema.type + then: + function: pattern + functionOptions: + notMatch: "object" diff --git a/v1/conformance/resolution/shared-reference/test.json b/v1/conformance/resolution/shared-reference/test.json new file mode 100644 index 0000000..b853824 --- /dev/null +++ b/v1/conformance/resolution/shared-reference/test.json @@ -0,0 +1,21 @@ +{ + "for": [ + "/req/result/reference-target" + ], + "purpose": "A schema referenced from two operations fails a rule once, at the place where the schema is written.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "no-object", + "path": [ + "components", + "schemas", + "Bad", + "type" + ] + } + ] + } +} diff --git a/v1/conformance/resolution/unresolvable-kept/document.yaml b/v1/conformance/resolution/unresolvable-kept/document.yaml new file mode 100644 index 0000000..f5dc09f --- /dev/null +++ b/v1/conformance/resolution/unresolvable-kept/document.yaml @@ -0,0 +1,4 @@ +components: + schemas: + Pet: + $ref: "./missing.yaml#/Pet" diff --git a/v1/conformance/resolution/unresolvable-kept/ruleset.yaml b/v1/conformance/resolution/unresolvable-kept/ruleset.yaml new file mode 100644 index 0000000..0a995f6 --- /dev/null +++ b/v1/conformance/resolution/unresolvable-kept/ruleset.yaml @@ -0,0 +1,6 @@ +rules: + ref-kept: + given: $.components.schemas.Pet + then: + field: $ref + function: undefined diff --git a/v1/conformance/resolution/unresolvable-kept/test.json b/v1/conformance/resolution/unresolvable-kept/test.json new file mode 100644 index 0000000..90e73e1 --- /dev/null +++ b/v1/conformance/resolution/unresolvable-kept/test.json @@ -0,0 +1,25 @@ +{ + "for": [ + "/req/resolution/unresolvable" + ], + "purpose": "After a `$ref` fails to resolve, a rule still sees the reference object.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "invalid-ref", + "severity": 0 + }, + { + "code": "ref-kept", + "path": [ + "components", + "schemas", + "Pet", + "$ref" + ] + } + ] + } +} diff --git a/v1/conformance/resolution/unresolvable/document.yaml b/v1/conformance/resolution/unresolvable/document.yaml new file mode 100644 index 0000000..f5dc09f --- /dev/null +++ b/v1/conformance/resolution/unresolvable/document.yaml @@ -0,0 +1,4 @@ +components: + schemas: + Pet: + $ref: "./missing.yaml#/Pet" diff --git a/v1/conformance/resolution/unresolvable/ruleset.yaml b/v1/conformance/resolution/unresolvable/ruleset.yaml new file mode 100644 index 0000000..597502e --- /dev/null +++ b/v1/conformance/resolution/unresolvable/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + quiet: + given: $.nothing + then: + function: truthy diff --git a/v1/conformance/resolution/unresolvable/test.json b/v1/conformance/resolution/unresolvable/test.json new file mode 100644 index 0000000..819bb94 --- /dev/null +++ b/v1/conformance/resolution/unresolvable/test.json @@ -0,0 +1,16 @@ +{ + "for": [ + "/req/resolution/unresolvable" + ], + "purpose": "A `$ref` to a file that does not exist is reported as `invalid-ref` at severity `error`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "invalid-ref", + "severity": 0 + } + ] + } +} diff --git a/v1/conformance/result/code/document.yaml b/v1/conformance/result/code/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/result/code/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/result/code/ruleset.yaml b/v1/conformance/result/code/ruleset.yaml new file mode 100644 index 0000000..0f7bb57 --- /dev/null +++ b/v1/conformance/result/code/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/result/code/test.json b/v1/conformance/result/code/test.json new file mode 100644 index 0000000..a4dbc85 --- /dev/null +++ b/v1/conformance/result/code/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/result/code" + ], + "purpose": "The `code` of a result is the rule name.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "acme-operation-summary" + }, + { + "code": "acme-operation-summary" + } + ] + } +} diff --git a/v1/conformance/result/fields/document.yaml b/v1/conformance/result/fields/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/result/fields/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/result/fields/ruleset.yaml b/v1/conformance/result/fields/ruleset.yaml new file mode 100644 index 0000000..0f7bb57 --- /dev/null +++ b/v1/conformance/result/fields/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/result/fields/test.json b/v1/conformance/result/fields/test.json new file mode 100644 index 0000000..100e019 --- /dev/null +++ b/v1/conformance/result/fields/test.json @@ -0,0 +1,30 @@ +{ + "for": [ + "/req/result/fields" + ], + "purpose": "Every result has the five required fields.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "has": [ + "code", + "path", + "severity", + "message", + "range" + ] + }, + { + "has": [ + "code", + "path", + "severity", + "message", + "range" + ] + } + ] + } +} diff --git a/v1/conformance/result/order/document.yaml b/v1/conformance/result/order/document.yaml new file mode 100644 index 0000000..0e44ae3 --- /dev/null +++ b/v1/conformance/result/order/document.yaml @@ -0,0 +1,4 @@ +a: 1 +b: 1 +c: 1 +d: 1 diff --git a/v1/conformance/result/order/ruleset.yaml b/v1/conformance/result/order/ruleset.yaml new file mode 100644 index 0000000..efb71c6 --- /dev/null +++ b/v1/conformance/result/order/ruleset.yaml @@ -0,0 +1,13 @@ +rules: + zzz: + given: [$.b, $.d] + then: + function: falsy + aaa: + given: [$.a, $.c] + then: + function: falsy + mmm: + given: $.a + then: + function: falsy diff --git a/v1/conformance/result/order/test.json b/v1/conformance/result/order/test.json new file mode 100644 index 0000000..371cf6c --- /dev/null +++ b/v1/conformance/result/order/test.json @@ -0,0 +1,43 @@ +{ + "for": [ + "/req/result/order" + ], + "purpose": "Results come in order of position, and by code at the same position, whatever the order of the rules.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "ordered": true, + "results": [ + { + "code": "aaa", + "path": [ + "a" + ] + }, + { + "code": "mmm", + "path": [ + "a" + ] + }, + { + "code": "zzz", + "path": [ + "b" + ] + }, + { + "code": "aaa", + "path": [ + "c" + ] + }, + { + "code": "zzz", + "path": [ + "d" + ] + } + ] + } +} diff --git a/v1/conformance/result/range-utf16/document.json b/v1/conformance/result/range-utf16/document.json new file mode 100644 index 0000000..219311a --- /dev/null +++ b/v1/conformance/result/range-utf16/document.json @@ -0,0 +1 @@ +{"a": "😀", "b": ""} diff --git a/v1/conformance/result/range-utf16/ruleset.yaml b/v1/conformance/result/range-utf16/ruleset.yaml new file mode 100644 index 0000000..4788b03 --- /dev/null +++ b/v1/conformance/result/range-utf16/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + b: + given: $.b + then: + function: truthy diff --git a/v1/conformance/result/range-utf16/test.json b/v1/conformance/result/range-utf16/test.json new file mode 100644 index 0000000..306fc0b --- /dev/null +++ b/v1/conformance/result/range-utf16/test.json @@ -0,0 +1,25 @@ +{ + "for": [ + "/req/result/range" + ], + "purpose": "A character before the value that needs two UTF-16 code units counts as two, and the end is just after the value.", + "ruleset": "ruleset.yaml", + "document": "document.json", + "expect": { + "results": [ + { + "code": "b", + "range": { + "start": { + "line": 0, + "character": 17 + }, + "end": { + "line": 0, + "character": 19 + } + } + } + ] + } +} diff --git a/v1/conformance/result/range/document.yaml b/v1/conformance/result/range/document.yaml new file mode 100644 index 0000000..8bd71e7 --- /dev/null +++ b/v1/conformance/result/range/document.yaml @@ -0,0 +1,2 @@ +info: + title: Petstore diff --git a/v1/conformance/result/range/ruleset.yaml b/v1/conformance/result/range/ruleset.yaml new file mode 100644 index 0000000..7a2a861 --- /dev/null +++ b/v1/conformance/result/range/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + root-falsy: + given: $ + then: + function: falsy diff --git a/v1/conformance/result/range/test.json b/v1/conformance/result/range/test.json new file mode 100644 index 0000000..8b0496a --- /dev/null +++ b/v1/conformance/result/range/test.json @@ -0,0 +1,21 @@ +{ + "for": [ + "/req/result/range" + ], + "purpose": "A result about the root value starts at line 0, character 0.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "root-falsy", + "range": { + "start": { + "line": 0, + "character": 0 + } + } + } + ] + } +} diff --git a/v1/conformance/result/severity/document.yaml b/v1/conformance/result/severity/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/result/severity/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/result/severity/ruleset.yaml b/v1/conformance/result/severity/ruleset.yaml new file mode 100644 index 0000000..6d56caf --- /dev/null +++ b/v1/conformance/result/severity/ruleset.yaml @@ -0,0 +1,8 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + severity: hint + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/result/severity/test.json b/v1/conformance/result/severity/test.json new file mode 100644 index 0000000..f56171c --- /dev/null +++ b/v1/conformance/result/severity/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/result/severity" + ], + "purpose": "A rule with `severity: hint` reports `severity: 3`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "severity": 3 + }, + { + "severity": 3 + } + ] + } +} diff --git a/v1/conformance/result/source/document.yaml b/v1/conformance/result/source/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/result/source/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/result/source/ruleset.yaml b/v1/conformance/result/source/ruleset.yaml new file mode 100644 index 0000000..0f7bb57 --- /dev/null +++ b/v1/conformance/result/source/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/result/source/test.json b/v1/conformance/result/source/test.json new file mode 100644 index 0000000..dd8b309 --- /dev/null +++ b/v1/conformance/result/source/test.json @@ -0,0 +1,20 @@ +{ + "for": [ + "/req/result/source" + ], + "purpose": "A result for a document read from a file carries that file's path in `source`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "acme-operation-summary", + "source": "${document}" + }, + { + "code": "acme-operation-summary", + "source": "${document}" + } + ] + } +} diff --git a/v1/conformance/rule/all-properties/ruleset.yaml b/v1/conformance/rule/all-properties/ruleset.yaml new file mode 100644 index 0000000..e9306ca --- /dev/null +++ b/v1/conformance/rule/all-properties/ruleset.yaml @@ -0,0 +1,18 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + message: "{{description}}" + severity: error + formats: [oas3] + documentationUrl: https://engineering.acme.com/api/governance#summary + recommended: true + resolved: true + tags: [style, docs] + type: style + extensions: + owner: platform-team + x-jira: GOV-142 + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/rule/all-properties/test.json b/v1/conformance/rule/all-properties/test.json new file mode 100644 index 0000000..cc7959f --- /dev/null +++ b/v1/conformance/rule/all-properties/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A rule using every property with its documented type is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/rule/recommended-not-boolean/ruleset.yaml b/v1/conformance/rule/recommended-not-boolean/ruleset.yaml new file mode 100644 index 0000000..fd1ffa8 --- /dev/null +++ b/v1/conformance/rule/recommended-not-boolean/ruleset.yaml @@ -0,0 +1,6 @@ +rules: + r: + recommended: "yes" + given: $ + then: + function: truthy diff --git a/v1/conformance/rule/recommended-not-boolean/test.json b/v1/conformance/rule/recommended-not-boolean/test.json new file mode 100644 index 0000000..aed3071 --- /dev/null +++ b/v1/conformance/rule/recommended-not-boolean/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A rule whose `recommended` is a string is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/rule/then-present/ruleset.yaml b/v1/conformance/rule/then-present/ruleset.yaml new file mode 100644 index 0000000..0f7bb57 --- /dev/null +++ b/v1/conformance/rule/then-present/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/rule/then-present/test.json b/v1/conformance/rule/then-present/test.json new file mode 100644 index 0000000..a51d068 --- /dev/null +++ b/v1/conformance/rule/then-present/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A rule with `then` and `given` is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/rule/then-required/ruleset.yaml b/v1/conformance/rule/then-required/ruleset.yaml new file mode 100644 index 0000000..40a38e1 --- /dev/null +++ b/v1/conformance/rule/then-required/ruleset.yaml @@ -0,0 +1,3 @@ +rules: + r: + given: $ diff --git a/v1/conformance/rule/then-required/test.json b/v1/conformance/rule/then-required/test.json new file mode 100644 index 0000000..76f0db9 --- /dev/null +++ b/v1/conformance/rule/then-required/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A rule with `given` but no `then` is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/rule/type-unknown/ruleset.yaml b/v1/conformance/rule/type-unknown/ruleset.yaml new file mode 100644 index 0000000..d1ea62a --- /dev/null +++ b/v1/conformance/rule/type-unknown/ruleset.yaml @@ -0,0 +1,6 @@ +rules: + r: + type: lint + given: $ + then: + function: truthy diff --git a/v1/conformance/rule/type-unknown/test.json b/v1/conformance/rule/type-unknown/test.json new file mode 100644 index 0000000..2812303 --- /dev/null +++ b/v1/conformance/rule/type-unknown/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A rule whose `type` is neither `style` nor `validation` is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/rule/unknown-property/ruleset.yaml b/v1/conformance/rule/unknown-property/ruleset.yaml new file mode 100644 index 0000000..9408a3c --- /dev/null +++ b/v1/conformance/rule/unknown-property/ruleset.yaml @@ -0,0 +1,6 @@ +rules: + r: + severty: error + given: $ + then: + function: truthy diff --git a/v1/conformance/rule/unknown-property/test.json b/v1/conformance/rule/unknown-property/test.json new file mode 100644 index 0000000..2aae775 --- /dev/null +++ b/v1/conformance/rule/unknown-property/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A rule with the unknown property `severty` is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/rules/recommended-default/document.yaml b/v1/conformance/rules/recommended-default/document.yaml new file mode 100644 index 0000000..a8926a5 --- /dev/null +++ b/v1/conformance/rules/recommended-default/document.yaml @@ -0,0 +1 @@ +a: 1 diff --git a/v1/conformance/rules/recommended-default/ruleset.yaml b/v1/conformance/rules/recommended-default/ruleset.yaml new file mode 100644 index 0000000..145f47c --- /dev/null +++ b/v1/conformance/rules/recommended-default/ruleset.yaml @@ -0,0 +1,10 @@ +rules: + r: + given: $ + then: + function: falsy + ctl: + given: $ + then: + function: falsy + recommended: true diff --git a/v1/conformance/rules/recommended-default/test.json b/v1/conformance/rules/recommended-default/test.json new file mode 100644 index 0000000..0eefb6f --- /dev/null +++ b/v1/conformance/rules/recommended-default/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/rules/recommended" + ], + "purpose": "A rule the ruleset defines runs whether `recommended` is `true` or absent.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r" + }, + { + "code": "ctl" + } + ] + } +} diff --git a/v1/conformance/rules/recommended-false/document.yaml b/v1/conformance/rules/recommended-false/document.yaml new file mode 100644 index 0000000..a8926a5 --- /dev/null +++ b/v1/conformance/rules/recommended-false/document.yaml @@ -0,0 +1 @@ +a: 1 diff --git a/v1/conformance/rules/recommended-false/ruleset.yaml b/v1/conformance/rules/recommended-false/ruleset.yaml new file mode 100644 index 0000000..b55de41 --- /dev/null +++ b/v1/conformance/rules/recommended-false/ruleset.yaml @@ -0,0 +1,10 @@ +rules: + r: + given: $ + then: + function: falsy + recommended: false + ctl: + given: $ + then: + function: falsy diff --git a/v1/conformance/rules/recommended-false/test.json b/v1/conformance/rules/recommended-false/test.json new file mode 100644 index 0000000..64e3107 --- /dev/null +++ b/v1/conformance/rules/recommended-false/test.json @@ -0,0 +1,16 @@ +{ + "for": [ + "/req/rules/recommended", + "/req/severity/off" + ], + "purpose": "A rule the ruleset defines with `recommended: false` does not run, while its other rules do.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "ctl" + } + ] + } +} diff --git a/v1/conformance/ruleset/all-properties/base.yaml b/v1/conformance/ruleset/all-properties/base.yaml new file mode 100644 index 0000000..81db109 --- /dev/null +++ b/v1/conformance/ruleset/all-properties/base.yaml @@ -0,0 +1,5 @@ +rules: + base-rule: + given: $ + then: + function: falsy diff --git a/v1/conformance/ruleset/all-properties/checks/isEmptyTitle.js b/v1/conformance/ruleset/all-properties/checks/isEmptyTitle.js new file mode 100644 index 0000000..07b7e67 --- /dev/null +++ b/v1/conformance/ruleset/all-properties/checks/isEmptyTitle.js @@ -0,0 +1 @@ +module.exports = (v) => (typeof v === 'string' && v.trim() !== '' ? [] : [{ message: 'Title is empty.' }]); diff --git a/v1/conformance/ruleset/all-properties/ruleset.yaml b/v1/conformance/ruleset/all-properties/ruleset.yaml new file mode 100644 index 0000000..5bd80f6 --- /dev/null +++ b/v1/conformance/ruleset/all-properties/ruleset.yaml @@ -0,0 +1,21 @@ +description: Acme API style guide +documentationUrl: https://engineering.acme.com/api/governance +formats: [oas3] +extends: ./base.yaml +aliases: + Info: + - $.info +overrides: + - files: ["legacy/**"] + rules: + base-rule: warn +parserOptions: + duplicateKeys: warn +functions: + - isEmptyTitle +functionsDir: ./checks +rules: + title-filled: + given: $.info.title + then: + function: isEmptyTitle diff --git a/v1/conformance/ruleset/all-properties/test.json b/v1/conformance/ruleset/all-properties/test.json new file mode 100644 index 0000000..6a5982a --- /dev/null +++ b/v1/conformance/ruleset/all-properties/test.json @@ -0,0 +1,14 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A ruleset using every root property with its documented type is valid.", + "ruleset": "ruleset.yaml", + "files": [ + "base.yaml", + "checks/isEmptyTitle.js" + ], + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/ruleset/contributes-empty/ruleset.yaml b/v1/conformance/ruleset/contributes-empty/ruleset.yaml new file mode 100644 index 0000000..0c766c5 --- /dev/null +++ b/v1/conformance/ruleset/contributes-empty/ruleset.yaml @@ -0,0 +1 @@ +description: Says nothing. diff --git a/v1/conformance/ruleset/contributes-empty/test.json b/v1/conformance/ruleset/contributes-empty/test.json new file mode 100644 index 0000000..75d2203 --- /dev/null +++ b/v1/conformance/ruleset/contributes-empty/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A ruleset with only `description` is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/ruleset/contributes-extends-only/base.yaml b/v1/conformance/ruleset/contributes-extends-only/base.yaml new file mode 100644 index 0000000..81db109 --- /dev/null +++ b/v1/conformance/ruleset/contributes-extends-only/base.yaml @@ -0,0 +1,5 @@ +rules: + base-rule: + given: $ + then: + function: falsy diff --git a/v1/conformance/ruleset/contributes-extends-only/ruleset.yaml b/v1/conformance/ruleset/contributes-extends-only/ruleset.yaml new file mode 100644 index 0000000..ea42faa --- /dev/null +++ b/v1/conformance/ruleset/contributes-extends-only/ruleset.yaml @@ -0,0 +1 @@ +extends: ./base.yaml diff --git a/v1/conformance/ruleset/contributes-extends-only/test.json b/v1/conformance/ruleset/contributes-extends-only/test.json new file mode 100644 index 0000000..11b45d1 --- /dev/null +++ b/v1/conformance/ruleset/contributes-extends-only/test.json @@ -0,0 +1,13 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A ruleset with only `extends` is valid.", + "ruleset": "ruleset.yaml", + "files": [ + "base.yaml" + ], + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/ruleset/description-not-string/ruleset.yaml b/v1/conformance/ruleset/description-not-string/ruleset.yaml new file mode 100644 index 0000000..3843b72 --- /dev/null +++ b/v1/conformance/ruleset/description-not-string/ruleset.yaml @@ -0,0 +1,8 @@ +description: 1 +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/ruleset/description-not-string/test.json b/v1/conformance/ruleset/description-not-string/test.json new file mode 100644 index 0000000..9077716 --- /dev/null +++ b/v1/conformance/ruleset/description-not-string/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A ruleset whose `description` is a number is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/ruleset/document-json/ruleset.json b/v1/conformance/ruleset/document-json/ruleset.json new file mode 100644 index 0000000..7228254 --- /dev/null +++ b/v1/conformance/ruleset/document-json/ruleset.json @@ -0,0 +1,11 @@ +{ + "rules": { + "acme-operation-summary": { + "given": "$.paths[*][*]", + "then": { + "field": "summary", + "function": "truthy" + } + } + } +} diff --git a/v1/conformance/ruleset/document-json/test.json b/v1/conformance/ruleset/document-json/test.json new file mode 100644 index 0000000..065bd3c --- /dev/null +++ b/v1/conformance/ruleset/document-json/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/document" + ], + "purpose": "A JSON text whose root is an object is valid.", + "ruleset": "ruleset.json", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/ruleset/document-object/ruleset.yaml b/v1/conformance/ruleset/document-object/ruleset.yaml new file mode 100644 index 0000000..0f7bb57 --- /dev/null +++ b/v1/conformance/ruleset/document-object/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/ruleset/document-object/test.json b/v1/conformance/ruleset/document-object/test.json new file mode 100644 index 0000000..8926515 --- /dev/null +++ b/v1/conformance/ruleset/document-object/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/document" + ], + "purpose": "A YAML document whose root is an object is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/ruleset/document-root-array/ruleset.yaml b/v1/conformance/ruleset/document-root-array/ruleset.yaml new file mode 100644 index 0000000..025932f --- /dev/null +++ b/v1/conformance/ruleset/document-root-array/ruleset.yaml @@ -0,0 +1 @@ +- rules: {} diff --git a/v1/conformance/ruleset/document-root-array/test.json b/v1/conformance/ruleset/document-root-array/test.json new file mode 100644 index 0000000..7bd7691 --- /dev/null +++ b/v1/conformance/ruleset/document-root-array/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A ruleset whose root value is an array is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/ruleset/document-single-yaml-document/ruleset.yaml b/v1/conformance/ruleset/document-single-yaml-document/ruleset.yaml new file mode 100644 index 0000000..fb7753d --- /dev/null +++ b/v1/conformance/ruleset/document-single-yaml-document/ruleset.yaml @@ -0,0 +1,7 @@ +--- +rules: + r: + given: $.info + then: + field: title + function: truthy diff --git a/v1/conformance/ruleset/document-single-yaml-document/test.json b/v1/conformance/ruleset/document-single-yaml-document/test.json new file mode 100644 index 0000000..fadae7f --- /dev/null +++ b/v1/conformance/ruleset/document-single-yaml-document/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/single-document" + ], + "purpose": "A YAML stream with one document, with an explicit document marker, is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/ruleset/document-syntax-error/ruleset.yaml b/v1/conformance/ruleset/document-syntax-error/ruleset.yaml new file mode 100644 index 0000000..b8d9f12 --- /dev/null +++ b/v1/conformance/ruleset/document-syntax-error/ruleset.yaml @@ -0,0 +1,2 @@ +rules: + r: [ diff --git a/v1/conformance/ruleset/document-syntax-error/test.json b/v1/conformance/ruleset/document-syntax-error/test.json new file mode 100644 index 0000000..d77658a --- /dev/null +++ b/v1/conformance/ruleset/document-syntax-error/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/document" + ], + "purpose": "A ruleset that is neither JSON nor YAML, here an unclosed flow sequence, is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/ruleset/document-two-yaml-documents/ruleset.yaml b/v1/conformance/ruleset/document-two-yaml-documents/ruleset.yaml new file mode 100644 index 0000000..50a7ee2 --- /dev/null +++ b/v1/conformance/ruleset/document-two-yaml-documents/ruleset.yaml @@ -0,0 +1,3 @@ +rules: {} +--- +rules: {} diff --git a/v1/conformance/ruleset/document-two-yaml-documents/test.json b/v1/conformance/ruleset/document-two-yaml-documents/test.json new file mode 100644 index 0000000..30cc9f5 --- /dev/null +++ b/v1/conformance/ruleset/document-two-yaml-documents/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/single-document" + ], + "purpose": "A YAML stream with two documents is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/ruleset/documentation-url-intranet/ruleset.yaml b/v1/conformance/ruleset/documentation-url-intranet/ruleset.yaml new file mode 100644 index 0000000..1e036c3 --- /dev/null +++ b/v1/conformance/ruleset/documentation-url-intranet/ruleset.yaml @@ -0,0 +1,8 @@ +documentationUrl: http://wiki/docs +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/ruleset/documentation-url-intranet/test.json b/v1/conformance/ruleset/documentation-url-intranet/test.json new file mode 100644 index 0000000..f7b9629 --- /dev/null +++ b/v1/conformance/ruleset/documentation-url-intranet/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A ruleset whose `documentationUrl` names a single-label intranet host, `http://wiki/docs`, is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/ruleset/documentation-url-invalid/ruleset.yaml b/v1/conformance/ruleset/documentation-url-invalid/ruleset.yaml new file mode 100644 index 0000000..f4c798a --- /dev/null +++ b/v1/conformance/ruleset/documentation-url-invalid/ruleset.yaml @@ -0,0 +1,8 @@ +documentationUrl: "governance docs" +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/ruleset/documentation-url-invalid/test.json b/v1/conformance/ruleset/documentation-url-invalid/test.json new file mode 100644 index 0000000..52e2897 --- /dev/null +++ b/v1/conformance/ruleset/documentation-url-invalid/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A ruleset whose `documentationUrl` is not a URI is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/ruleset/documentation-url-mailto/ruleset.yaml b/v1/conformance/ruleset/documentation-url-mailto/ruleset.yaml new file mode 100644 index 0000000..0f737ec --- /dev/null +++ b/v1/conformance/ruleset/documentation-url-mailto/ruleset.yaml @@ -0,0 +1,8 @@ +documentationUrl: "mailto:api-governance@acme.example" +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/ruleset/documentation-url-mailto/test.json b/v1/conformance/ruleset/documentation-url-mailto/test.json new file mode 100644 index 0000000..d944413 --- /dev/null +++ b/v1/conformance/ruleset/documentation-url-mailto/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A ruleset whose `documentationUrl` is a `mailto:` URI is invalid: only http, https, and ftp URLs are accepted.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/ruleset/extension-root-property/ruleset.yaml b/v1/conformance/ruleset/extension-root-property/ruleset.yaml new file mode 100644 index 0000000..8d0e231 --- /dev/null +++ b/v1/conformance/ruleset/extension-root-property/ruleset.yaml @@ -0,0 +1,8 @@ +x-owner: platform-team +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/ruleset/extension-root-property/test.json b/v1/conformance/ruleset/extension-root-property/test.json new file mode 100644 index 0000000..4b179d6 --- /dev/null +++ b/v1/conformance/ruleset/extension-root-property/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A ruleset with the extension property `x-owner` at the root is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/ruleset/rules-not-object/ruleset.yaml b/v1/conformance/ruleset/rules-not-object/ruleset.yaml new file mode 100644 index 0000000..5a28518 --- /dev/null +++ b/v1/conformance/ruleset/rules-not-object/ruleset.yaml @@ -0,0 +1,2 @@ +rules: + - r diff --git a/v1/conformance/ruleset/rules-not-object/test.json b/v1/conformance/ruleset/rules-not-object/test.json new file mode 100644 index 0000000..7e89602 --- /dev/null +++ b/v1/conformance/ruleset/rules-not-object/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A ruleset whose `rules` is an array is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/ruleset/unknown-root-property/ruleset.yaml b/v1/conformance/ruleset/unknown-root-property/ruleset.yaml new file mode 100644 index 0000000..49ff953 --- /dev/null +++ b/v1/conformance/ruleset/unknown-root-property/ruleset.yaml @@ -0,0 +1,10 @@ +rules: + r: + given: $ + then: + function: truthy +rule: + s: + given: $ + then: + function: truthy diff --git a/v1/conformance/ruleset/unknown-root-property/test.json b/v1/conformance/ruleset/unknown-root-property/test.json new file mode 100644 index 0000000..e14cb10 --- /dev/null +++ b/v1/conformance/ruleset/unknown-root-property/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A ruleset with `rules` and the unknown root property `rule` is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/severity/default/document.yaml b/v1/conformance/severity/default/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/severity/default/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/severity/default/ruleset.yaml b/v1/conformance/severity/default/ruleset.yaml new file mode 100644 index 0000000..0f7bb57 --- /dev/null +++ b/v1/conformance/severity/default/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/severity/default/test.json b/v1/conformance/severity/default/test.json new file mode 100644 index 0000000..52437c6 --- /dev/null +++ b/v1/conformance/severity/default/test.json @@ -0,0 +1,20 @@ +{ + "for": [ + "/req/severity/default" + ], + "purpose": "A rule without `severity` reports with the severity of `warn`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "acme-operation-summary", + "severity": 1 + }, + { + "code": "acme-operation-summary", + "severity": 1 + } + ] + } +} diff --git a/v1/conformance/severity/numeric-equivalence/document.yaml b/v1/conformance/severity/numeric-equivalence/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/severity/numeric-equivalence/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/severity/numeric-equivalence/ruleset.yaml b/v1/conformance/severity/numeric-equivalence/ruleset.yaml new file mode 100644 index 0000000..73d0e6e --- /dev/null +++ b/v1/conformance/severity/numeric-equivalence/ruleset.yaml @@ -0,0 +1,8 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + severity: 0 + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/severity/numeric-equivalence/test.json b/v1/conformance/severity/numeric-equivalence/test.json new file mode 100644 index 0000000..82296a4 --- /dev/null +++ b/v1/conformance/severity/numeric-equivalence/test.json @@ -0,0 +1,20 @@ +{ + "for": [ + "/req/severity/numeric-equivalence" + ], + "purpose": "A rule with `severity: 0` reports with the severity of `error`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "acme-operation-summary", + "severity": 0 + }, + { + "code": "acme-operation-summary", + "severity": 0 + } + ] + } +} diff --git a/v1/conformance/severity/off-control/document.yaml b/v1/conformance/severity/off-control/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/severity/off-control/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/severity/off-control/ruleset.yaml b/v1/conformance/severity/off-control/ruleset.yaml new file mode 100644 index 0000000..0267a15 --- /dev/null +++ b/v1/conformance/severity/off-control/ruleset.yaml @@ -0,0 +1,8 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + severity: warn + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/severity/off-control/test.json b/v1/conformance/severity/off-control/test.json new file mode 100644 index 0000000..a484f55 --- /dev/null +++ b/v1/conformance/severity/off-control/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/severity/off" + ], + "purpose": "The same rule at `warn` produces the results that `off` suppresses.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "acme-operation-summary" + }, + { + "code": "acme-operation-summary" + } + ] + } +} diff --git a/v1/conformance/severity/off/document.yaml b/v1/conformance/severity/off/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/severity/off/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/severity/off/ruleset.yaml b/v1/conformance/severity/off/ruleset.yaml new file mode 100644 index 0000000..8233d0e --- /dev/null +++ b/v1/conformance/severity/off/ruleset.yaml @@ -0,0 +1,8 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + severity: off + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/severity/off/test.json b/v1/conformance/severity/off/test.json new file mode 100644 index 0000000..0c72356 --- /dev/null +++ b/v1/conformance/severity/off/test.json @@ -0,0 +1,11 @@ +{ + "for": [ + "/req/severity/off" + ], + "purpose": "A rule with `severity: off` produces no results although it would fail.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [] + } +} diff --git a/v1/conformance/severity/result/base.yaml b/v1/conformance/severity/result/base.yaml new file mode 100644 index 0000000..0267a15 --- /dev/null +++ b/v1/conformance/severity/result/base.yaml @@ -0,0 +1,8 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + severity: warn + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/severity/result/document.yaml b/v1/conformance/severity/result/document.yaml new file mode 100644 index 0000000..f6c5ec1 --- /dev/null +++ b/v1/conformance/severity/result/document.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + responses: + "200": + description: OK + post: + responses: + "201": + description: Created diff --git a/v1/conformance/severity/result/ruleset.yaml b/v1/conformance/severity/result/ruleset.yaml new file mode 100644 index 0000000..237a8ed --- /dev/null +++ b/v1/conformance/severity/result/ruleset.yaml @@ -0,0 +1,3 @@ +extends: ./base.yaml +rules: + acme-operation-summary: error diff --git a/v1/conformance/severity/result/test.json b/v1/conformance/severity/result/test.json new file mode 100644 index 0000000..e23ad3e --- /dev/null +++ b/v1/conformance/severity/result/test.json @@ -0,0 +1,23 @@ +{ + "for": [ + "/req/result/severity" + ], + "purpose": "A rule inherited at `warn` and raised to `error` reports with the severity of `error`.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "base.yaml" + ], + "expect": { + "results": [ + { + "code": "acme-operation-summary", + "severity": 0 + }, + { + "code": "acme-operation-summary", + "severity": 0 + } + ] + } +} diff --git a/v1/conformance/severity/values-invalid/ruleset.yaml b/v1/conformance/severity/values-invalid/ruleset.yaml new file mode 100644 index 0000000..0d2803f --- /dev/null +++ b/v1/conformance/severity/values-invalid/ruleset.yaml @@ -0,0 +1,6 @@ +rules: + r: + severity: critical + given: $ + then: + function: truthy diff --git a/v1/conformance/severity/values-invalid/test.json b/v1/conformance/severity/values-invalid/test.json new file mode 100644 index 0000000..75517eb --- /dev/null +++ b/v1/conformance/severity/values-invalid/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A rule with `severity: critical` is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/severity/values-name/ruleset.yaml b/v1/conformance/severity/values-name/ruleset.yaml new file mode 100644 index 0000000..9c0cb79 --- /dev/null +++ b/v1/conformance/severity/values-name/ruleset.yaml @@ -0,0 +1,6 @@ +rules: + r: + severity: hint + given: $ + then: + function: truthy diff --git a/v1/conformance/severity/values-name/test.json b/v1/conformance/severity/values-name/test.json new file mode 100644 index 0000000..baad93d --- /dev/null +++ b/v1/conformance/severity/values-name/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A rule with `severity: hint` is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/severity/values-number/ruleset.yaml b/v1/conformance/severity/values-number/ruleset.yaml new file mode 100644 index 0000000..f428dc1 --- /dev/null +++ b/v1/conformance/severity/values-number/ruleset.yaml @@ -0,0 +1,6 @@ +rules: + r: + severity: 3 + given: $ + then: + function: truthy diff --git a/v1/conformance/severity/values-number/test.json b/v1/conformance/severity/values-number/test.json new file mode 100644 index 0000000..e5a4212 --- /dev/null +++ b/v1/conformance/severity/values-number/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A rule with `severity: 3` is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/shorthands/false-control/a.yaml b/v1/conformance/shorthands/false-control/a.yaml new file mode 100644 index 0000000..2f61acb --- /dev/null +++ b/v1/conformance/shorthands/false-control/a.yaml @@ -0,0 +1,6 @@ +rules: + r: + severity: error + given: $ + then: + function: falsy diff --git a/v1/conformance/shorthands/false-control/document.yaml b/v1/conformance/shorthands/false-control/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/shorthands/false-control/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/shorthands/false-control/ruleset.yaml b/v1/conformance/shorthands/false-control/ruleset.yaml new file mode 100644 index 0000000..3a3b7f0 --- /dev/null +++ b/v1/conformance/shorthands/false-control/ruleset.yaml @@ -0,0 +1 @@ +extends: ./a.yaml diff --git a/v1/conformance/shorthands/false-control/test.json b/v1/conformance/shorthands/false-control/test.json new file mode 100644 index 0000000..a88fc56 --- /dev/null +++ b/v1/conformance/shorthands/false-control/test.json @@ -0,0 +1,19 @@ +{ + "for": [ + "/req/shorthands/false", + "/req/severity/off" + ], + "purpose": "Without the `false` shorthand, the inherited rule runs.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "a.yaml" + ], + "expect": { + "results": [ + { + "code": "r" + } + ] + } +} diff --git a/v1/conformance/shorthands/false/a.yaml b/v1/conformance/shorthands/false/a.yaml new file mode 100644 index 0000000..09fc576 --- /dev/null +++ b/v1/conformance/shorthands/false/a.yaml @@ -0,0 +1,7 @@ +rules: + r: + recommended: true + severity: error + given: $ + then: + function: falsy diff --git a/v1/conformance/shorthands/false/document.yaml b/v1/conformance/shorthands/false/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/shorthands/false/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/shorthands/false/ruleset.yaml b/v1/conformance/shorthands/false/ruleset.yaml new file mode 100644 index 0000000..26b13fe --- /dev/null +++ b/v1/conformance/shorthands/false/ruleset.yaml @@ -0,0 +1,3 @@ +extends: ./a.yaml +rules: + r: false diff --git a/v1/conformance/shorthands/false/test.json b/v1/conformance/shorthands/false/test.json new file mode 100644 index 0000000..7fe6701 --- /dev/null +++ b/v1/conformance/shorthands/false/test.json @@ -0,0 +1,15 @@ +{ + "for": [ + "/req/shorthands/false", + "/req/severity/off" + ], + "purpose": "`false` disables an enabled rule.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "a.yaml" + ], + "expect": { + "results": [] + } +} diff --git a/v1/conformance/shorthands/inherited/a.yaml b/v1/conformance/shorthands/inherited/a.yaml new file mode 100644 index 0000000..2c988dd --- /dev/null +++ b/v1/conformance/shorthands/inherited/a.yaml @@ -0,0 +1,9 @@ +rules: + r: + given: $ + then: + function: falsy + s: + given: $ + then: + function: falsy diff --git a/v1/conformance/shorthands/inherited/ruleset.yaml b/v1/conformance/shorthands/inherited/ruleset.yaml new file mode 100644 index 0000000..a8bb0a7 --- /dev/null +++ b/v1/conformance/shorthands/inherited/ruleset.yaml @@ -0,0 +1,3 @@ +extends: ./a.yaml +rules: + r: error diff --git a/v1/conformance/shorthands/inherited/test.json b/v1/conformance/shorthands/inherited/test.json new file mode 100644 index 0000000..03f7e1c --- /dev/null +++ b/v1/conformance/shorthands/inherited/test.json @@ -0,0 +1,13 @@ +{ + "for": [ + "/req/shorthands/inherited-only" + ], + "purpose": "A shorthand for a rule inherited through `extends` is valid.", + "ruleset": "ruleset.yaml", + "files": [ + "a.yaml" + ], + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/shorthands/not-inherited/ruleset.yaml b/v1/conformance/shorthands/not-inherited/ruleset.yaml new file mode 100644 index 0000000..a7689d8 --- /dev/null +++ b/v1/conformance/shorthands/not-inherited/ruleset.yaml @@ -0,0 +1,2 @@ +rules: + r: error diff --git a/v1/conformance/shorthands/not-inherited/test.json b/v1/conformance/shorthands/not-inherited/test.json new file mode 100644 index 0000000..5c518d9 --- /dev/null +++ b/v1/conformance/shorthands/not-inherited/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/shorthands/inherited-only" + ], + "purpose": "A shorthand for a rule the ruleset does not inherit is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/shorthands/numeric/a.yaml b/v1/conformance/shorthands/numeric/a.yaml new file mode 100644 index 0000000..e4ebc24 --- /dev/null +++ b/v1/conformance/shorthands/numeric/a.yaml @@ -0,0 +1,7 @@ +rules: + r: + recommended: false + severity: warn + given: $ + then: + function: falsy diff --git a/v1/conformance/shorthands/numeric/ruleset.yaml b/v1/conformance/shorthands/numeric/ruleset.yaml new file mode 100644 index 0000000..d377ec0 --- /dev/null +++ b/v1/conformance/shorthands/numeric/ruleset.yaml @@ -0,0 +1,3 @@ +extends: ./a.yaml +rules: + r: 1 diff --git a/v1/conformance/shorthands/numeric/test.json b/v1/conformance/shorthands/numeric/test.json new file mode 100644 index 0000000..e2951c2 --- /dev/null +++ b/v1/conformance/shorthands/numeric/test.json @@ -0,0 +1,13 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A numeric severity in the shorthand position is invalid.", + "ruleset": "ruleset.yaml", + "files": [ + "a.yaml" + ], + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/shorthands/override-own-rule/document.yaml b/v1/conformance/shorthands/override-own-rule/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/shorthands/override-own-rule/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/shorthands/override-own-rule/ruleset.yaml b/v1/conformance/shorthands/override-own-rule/ruleset.yaml new file mode 100644 index 0000000..6a4fda9 --- /dev/null +++ b/v1/conformance/shorthands/override-own-rule/ruleset.yaml @@ -0,0 +1,9 @@ +rules: + r: + given: $ + then: + function: falsy +overrides: + - files: ["**/*.yaml"] + rules: + r: error diff --git a/v1/conformance/shorthands/override-own-rule/test.json b/v1/conformance/shorthands/override-own-rule/test.json new file mode 100644 index 0000000..d62362f --- /dev/null +++ b/v1/conformance/shorthands/override-own-rule/test.json @@ -0,0 +1,16 @@ +{ + "for": [ + "/req/overrides/reject-unknown-shorthand" + ], + "purpose": "A shorthand in an applying override for a rule the ruleset defines itself sets its severity.", + "ruleset": "ruleset.yaml", + "expect": { + "results": [ + { + "code": "r", + "severity": 0 + } + ] + }, + "document": "document.yaml" +} diff --git a/v1/conformance/shorthands/override-unknown-not-applied/document.yaml b/v1/conformance/shorthands/override-unknown-not-applied/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/shorthands/override-unknown-not-applied/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/shorthands/override-unknown-not-applied/ruleset.yaml b/v1/conformance/shorthands/override-unknown-not-applied/ruleset.yaml new file mode 100644 index 0000000..d64ada8 --- /dev/null +++ b/v1/conformance/shorthands/override-unknown-not-applied/ruleset.yaml @@ -0,0 +1,9 @@ +rules: + r: + given: $ + then: + function: falsy +overrides: + - files: ["legacy/**"] + rules: + nope: error diff --git a/v1/conformance/shorthands/override-unknown-not-applied/test.json b/v1/conformance/shorthands/override-unknown-not-applied/test.json new file mode 100644 index 0000000..6758720 --- /dev/null +++ b/v1/conformance/shorthands/override-unknown-not-applied/test.json @@ -0,0 +1,17 @@ +{ + "for": [ + "/req/overrides/reject-unknown-shorthand", + "/req/overrides/checked-when-applied" + ], + "purpose": "A shorthand for an unknown rule in an override that applies to no document being linted does not stop the ruleset from loading.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "severity": 1 + } + ] + } +} diff --git a/v1/conformance/shorthands/override-unknown/document.yaml b/v1/conformance/shorthands/override-unknown/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/shorthands/override-unknown/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/shorthands/override-unknown/ruleset.yaml b/v1/conformance/shorthands/override-unknown/ruleset.yaml new file mode 100644 index 0000000..7874d04 --- /dev/null +++ b/v1/conformance/shorthands/override-unknown/ruleset.yaml @@ -0,0 +1,9 @@ +rules: + r: + given: $ + then: + function: falsy +overrides: + - files: ["**/*.yaml"] + rules: + nope: error diff --git a/v1/conformance/shorthands/override-unknown/test.json b/v1/conformance/shorthands/override-unknown/test.json new file mode 100644 index 0000000..dec5e91 --- /dev/null +++ b/v1/conformance/shorthands/override-unknown/test.json @@ -0,0 +1,11 @@ +{ + "for": [ + "/req/overrides/reject-unknown-shorthand" + ], + "purpose": "A shorthand in an override for a rule the ruleset neither defines nor inherits makes a linter reject the ruleset for a document the override applies to.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "rejected": true + } +} diff --git a/v1/conformance/shorthands/severity/a.yaml b/v1/conformance/shorthands/severity/a.yaml new file mode 100644 index 0000000..e4ebc24 --- /dev/null +++ b/v1/conformance/shorthands/severity/a.yaml @@ -0,0 +1,7 @@ +rules: + r: + recommended: false + severity: warn + given: $ + then: + function: falsy diff --git a/v1/conformance/shorthands/severity/document.yaml b/v1/conformance/shorthands/severity/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/shorthands/severity/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/shorthands/severity/ruleset.yaml b/v1/conformance/shorthands/severity/ruleset.yaml new file mode 100644 index 0000000..a8bb0a7 --- /dev/null +++ b/v1/conformance/shorthands/severity/ruleset.yaml @@ -0,0 +1,3 @@ +extends: ./a.yaml +rules: + r: error diff --git a/v1/conformance/shorthands/severity/test.json b/v1/conformance/shorthands/severity/test.json new file mode 100644 index 0000000..8201857 --- /dev/null +++ b/v1/conformance/shorthands/severity/test.json @@ -0,0 +1,19 @@ +{ + "for": [ + "/req/shorthands/severity" + ], + "purpose": "A severity name enables the rule and sets its severity.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "a.yaml" + ], + "expect": { + "results": [ + { + "code": "r", + "severity": 0 + } + ] + } +} diff --git a/v1/conformance/shorthands/true-enables/a.yaml b/v1/conformance/shorthands/true-enables/a.yaml new file mode 100644 index 0000000..6246f6f --- /dev/null +++ b/v1/conformance/shorthands/true-enables/a.yaml @@ -0,0 +1,7 @@ +rules: + r: + recommended: false + severity: error + given: $ + then: + function: falsy diff --git a/v1/conformance/shorthands/true-enables/document.yaml b/v1/conformance/shorthands/true-enables/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/shorthands/true-enables/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/shorthands/true-enables/ruleset.yaml b/v1/conformance/shorthands/true-enables/ruleset.yaml new file mode 100644 index 0000000..51db7ec --- /dev/null +++ b/v1/conformance/shorthands/true-enables/ruleset.yaml @@ -0,0 +1,3 @@ +extends: ./a.yaml +rules: + r: true diff --git a/v1/conformance/shorthands/true-enables/test.json b/v1/conformance/shorthands/true-enables/test.json new file mode 100644 index 0000000..6d8424d --- /dev/null +++ b/v1/conformance/shorthands/true-enables/test.json @@ -0,0 +1,19 @@ +{ + "for": [ + "/req/shorthands/true" + ], + "purpose": "`true` enables a rule that is off by default, at the severity it was defined with.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "a.yaml" + ], + "expect": { + "results": [ + { + "code": "r", + "severity": 0 + } + ] + } +} diff --git a/v1/conformance/shorthands/true-keeps-severity/a.yaml b/v1/conformance/shorthands/true-keeps-severity/a.yaml new file mode 100644 index 0000000..09fc576 --- /dev/null +++ b/v1/conformance/shorthands/true-keeps-severity/a.yaml @@ -0,0 +1,7 @@ +rules: + r: + recommended: true + severity: error + given: $ + then: + function: falsy diff --git a/v1/conformance/shorthands/true-keeps-severity/b.yaml b/v1/conformance/shorthands/true-keeps-severity/b.yaml new file mode 100644 index 0000000..26b13fe --- /dev/null +++ b/v1/conformance/shorthands/true-keeps-severity/b.yaml @@ -0,0 +1,3 @@ +extends: ./a.yaml +rules: + r: false diff --git a/v1/conformance/shorthands/true-keeps-severity/document.yaml b/v1/conformance/shorthands/true-keeps-severity/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/shorthands/true-keeps-severity/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/shorthands/true-keeps-severity/ruleset.yaml b/v1/conformance/shorthands/true-keeps-severity/ruleset.yaml new file mode 100644 index 0000000..47d5a4b --- /dev/null +++ b/v1/conformance/shorthands/true-keeps-severity/ruleset.yaml @@ -0,0 +1,3 @@ +extends: ./b.yaml +rules: + r: true diff --git a/v1/conformance/shorthands/true-keeps-severity/test.json b/v1/conformance/shorthands/true-keeps-severity/test.json new file mode 100644 index 0000000..d517d3b --- /dev/null +++ b/v1/conformance/shorthands/true-keeps-severity/test.json @@ -0,0 +1,20 @@ +{ + "for": [ + "/req/shorthands/true" + ], + "purpose": "A rule disabled with `false` in one ruleset and re-enabled with `true` in the next keeps the severity it was defined with.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "a.yaml", + "b.yaml" + ], + "expect": { + "results": [ + { + "code": "r", + "severity": 0 + } + ] + } +} diff --git a/v1/conformance/shorthands/true-off/a.yaml b/v1/conformance/shorthands/true-off/a.yaml new file mode 100644 index 0000000..56528b0 --- /dev/null +++ b/v1/conformance/shorthands/true-off/a.yaml @@ -0,0 +1,6 @@ +rules: + r: + severity: "off" + given: $ + then: + function: falsy diff --git a/v1/conformance/shorthands/true-off/document.yaml b/v1/conformance/shorthands/true-off/document.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/v1/conformance/shorthands/true-off/document.yaml @@ -0,0 +1 @@ +{} diff --git a/v1/conformance/shorthands/true-off/ruleset.yaml b/v1/conformance/shorthands/true-off/ruleset.yaml new file mode 100644 index 0000000..51db7ec --- /dev/null +++ b/v1/conformance/shorthands/true-off/ruleset.yaml @@ -0,0 +1,3 @@ +extends: ./a.yaml +rules: + r: true diff --git a/v1/conformance/shorthands/true-off/test.json b/v1/conformance/shorthands/true-off/test.json new file mode 100644 index 0000000..4f75329 --- /dev/null +++ b/v1/conformance/shorthands/true-off/test.json @@ -0,0 +1,14 @@ +{ + "for": [ + "/req/shorthands/true" + ], + "purpose": "`true` on a rule whose severity is `off` does not enable it.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "files": [ + "a.yaml" + ], + "expect": { + "results": [] + } +} diff --git a/v1/conformance/shorthands/unknown-name/a.yaml b/v1/conformance/shorthands/unknown-name/a.yaml new file mode 100644 index 0000000..e4ebc24 --- /dev/null +++ b/v1/conformance/shorthands/unknown-name/a.yaml @@ -0,0 +1,7 @@ +rules: + r: + recommended: false + severity: warn + given: $ + then: + function: falsy diff --git a/v1/conformance/shorthands/unknown-name/ruleset.yaml b/v1/conformance/shorthands/unknown-name/ruleset.yaml new file mode 100644 index 0000000..2f786e6 --- /dev/null +++ b/v1/conformance/shorthands/unknown-name/ruleset.yaml @@ -0,0 +1,3 @@ +extends: ./a.yaml +rules: + r: critical diff --git a/v1/conformance/shorthands/unknown-name/test.json b/v1/conformance/shorthands/unknown-name/test.json new file mode 100644 index 0000000..4784048 --- /dev/null +++ b/v1/conformance/shorthands/unknown-name/test.json @@ -0,0 +1,13 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "An unknown name in the shorthand position is invalid.", + "ruleset": "ruleset.yaml", + "files": [ + "a.yaml" + ], + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/shorthands/values-valid/a.yaml b/v1/conformance/shorthands/values-valid/a.yaml new file mode 100644 index 0000000..2c988dd --- /dev/null +++ b/v1/conformance/shorthands/values-valid/a.yaml @@ -0,0 +1,9 @@ +rules: + r: + given: $ + then: + function: falsy + s: + given: $ + then: + function: falsy diff --git a/v1/conformance/shorthands/values-valid/ruleset.yaml b/v1/conformance/shorthands/values-valid/ruleset.yaml new file mode 100644 index 0000000..a800167 --- /dev/null +++ b/v1/conformance/shorthands/values-valid/ruleset.yaml @@ -0,0 +1,4 @@ +extends: ./a.yaml +rules: + r: hint + s: false diff --git a/v1/conformance/shorthands/values-valid/test.json b/v1/conformance/shorthands/values-valid/test.json new file mode 100644 index 0000000..77c204c --- /dev/null +++ b/v1/conformance/shorthands/values-valid/test.json @@ -0,0 +1,13 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "Every severity name and both booleans are valid in the shorthand position.", + "ruleset": "ruleset.yaml", + "files": [ + "a.yaml" + ], + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/then/apply-each-problem/document.yaml b/v1/conformance/then/apply-each-problem/document.yaml new file mode 100644 index 0000000..aa3aafb --- /dev/null +++ b/v1/conformance/then/apply-each-problem/document.yaml @@ -0,0 +1 @@ +o: {} diff --git a/v1/conformance/then/apply-each-problem/ruleset.yaml b/v1/conformance/then/apply-each-problem/ruleset.yaml new file mode 100644 index 0000000..f842a29 --- /dev/null +++ b/v1/conformance/then/apply-each-problem/ruleset.yaml @@ -0,0 +1,10 @@ +rules: + r: + given: $.o + then: + function: schema + functionOptions: + allErrors: true + schema: + type: object + required: [a, b] diff --git a/v1/conformance/then/apply-each-problem/test.json b/v1/conformance/then/apply-each-problem/test.json new file mode 100644 index 0000000..385a33f --- /dev/null +++ b/v1/conformance/then/apply-each-problem/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/then/apply-each" + ], + "purpose": "One invocation whose function returns two problems produces two results.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r" + }, + { + "code": "r" + } + ] + } +} diff --git a/v1/conformance/then/apply-each/document.yaml b/v1/conformance/then/apply-each/document.yaml new file mode 100644 index 0000000..2542544 --- /dev/null +++ b/v1/conformance/then/apply-each/document.yaml @@ -0,0 +1,2 @@ +info: + title: "" diff --git a/v1/conformance/then/apply-each/ruleset.yaml b/v1/conformance/then/apply-each/ruleset.yaml new file mode 100644 index 0000000..f99d165 --- /dev/null +++ b/v1/conformance/then/apply-each/ruleset.yaml @@ -0,0 +1,8 @@ +rules: + title-checks: + given: $.info.title + then: + - function: truthy + - function: length + functionOptions: + min: 1 diff --git a/v1/conformance/then/apply-each/test.json b/v1/conformance/then/apply-each/test.json new file mode 100644 index 0000000..c442184 --- /dev/null +++ b/v1/conformance/then/apply-each/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/then/apply-each" + ], + "purpose": "Two invocations that both fail on the same node produce two results.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "title-checks" + }, + { + "code": "title-checks" + } + ] + } +} diff --git a/v1/conformance/then/field-jsonpath/document.yaml b/v1/conformance/then/field-jsonpath/document.yaml new file mode 100644 index 0000000..433ef95 --- /dev/null +++ b/v1/conformance/then/field-jsonpath/document.yaml @@ -0,0 +1,4 @@ +items: + - name: "" + - name: ok + - name: "" diff --git a/v1/conformance/then/field-jsonpath/ruleset.yaml b/v1/conformance/then/field-jsonpath/ruleset.yaml new file mode 100644 index 0000000..5b00585 --- /dev/null +++ b/v1/conformance/then/field-jsonpath/ruleset.yaml @@ -0,0 +1,6 @@ +rules: + r: + given: $ + then: + field: "$.items[*].name" + function: truthy diff --git a/v1/conformance/then/field-jsonpath/test.json b/v1/conformance/then/field-jsonpath/test.json new file mode 100644 index 0000000..10808de --- /dev/null +++ b/v1/conformance/then/field-jsonpath/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/then/field" + ], + "purpose": "A `field` that is a JSONPath expression applies the function to every value it selects.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r" + }, + { + "code": "r" + } + ] + } +} diff --git a/v1/conformance/then/field-key/document.yaml b/v1/conformance/then/field-key/document.yaml new file mode 100644 index 0000000..8f8ba53 --- /dev/null +++ b/v1/conformance/then/field-key/document.yaml @@ -0,0 +1,3 @@ +paths: + /pets: {} + /Pets_old: {} diff --git a/v1/conformance/then/field-key/ruleset.yaml b/v1/conformance/then/field-key/ruleset.yaml new file mode 100644 index 0000000..f6049f3 --- /dev/null +++ b/v1/conformance/then/field-key/ruleset.yaml @@ -0,0 +1,8 @@ +rules: + path-kebab: + given: $.paths + then: + field: "@key" + function: pattern + functionOptions: + match: "^/[a-z-]+$" diff --git a/v1/conformance/then/field-key/test.json b/v1/conformance/then/field-key/test.json new file mode 100644 index 0000000..c4f3206 --- /dev/null +++ b/v1/conformance/then/field-key/test.json @@ -0,0 +1,19 @@ +{ + "for": [ + "/req/then/field-key" + ], + "purpose": "With `field: \"@key\"` on a matched object, the function sees each of its property keys; the key that does not match the pattern is reported.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "path-kebab", + "path": [ + "paths", + "/Pets_old" + ] + } + ] + } +} diff --git a/v1/conformance/then/field-missing/document.yaml b/v1/conformance/then/field-missing/document.yaml new file mode 100644 index 0000000..8bd71e7 --- /dev/null +++ b/v1/conformance/then/field-missing/document.yaml @@ -0,0 +1,2 @@ +info: + title: Petstore diff --git a/v1/conformance/then/field-missing/ruleset.yaml b/v1/conformance/then/field-missing/ruleset.yaml new file mode 100644 index 0000000..feebb9e --- /dev/null +++ b/v1/conformance/then/field-missing/ruleset.yaml @@ -0,0 +1,6 @@ +rules: + info-description: + given: $.info + then: + field: description + function: defined diff --git a/v1/conformance/then/field-missing/test.json b/v1/conformance/then/field-missing/test.json new file mode 100644 index 0000000..cbf05c2 --- /dev/null +++ b/v1/conformance/then/field-missing/test.json @@ -0,0 +1,18 @@ +{ + "for": [ + "/req/then/field" + ], + "purpose": "With `field` naming a property the node lacks, the function sees no value, so `defined` fails.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "info-description", + "path": [ + "info" + ] + } + ] + } +} diff --git a/v1/conformance/then/field-path/document.yaml b/v1/conformance/then/field-path/document.yaml new file mode 100644 index 0000000..106c0ff --- /dev/null +++ b/v1/conformance/then/field-path/document.yaml @@ -0,0 +1,3 @@ +info: + contact: + email: "" diff --git a/v1/conformance/then/field-path/ruleset.yaml b/v1/conformance/then/field-path/ruleset.yaml new file mode 100644 index 0000000..77dfa34 --- /dev/null +++ b/v1/conformance/then/field-path/ruleset.yaml @@ -0,0 +1,6 @@ +rules: + r: + given: $ + then: + field: info.contact.email + function: truthy diff --git a/v1/conformance/then/field-path/test.json b/v1/conformance/then/field-path/test.json new file mode 100644 index 0000000..17ef688 --- /dev/null +++ b/v1/conformance/then/field-path/test.json @@ -0,0 +1,20 @@ +{ + "for": [ + "/req/then/field" + ], + "purpose": "A `field` that is a path of property names reaches a nested value.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "r", + "path": [ + "info", + "contact", + "email" + ] + } + ] + } +} diff --git a/v1/conformance/then/field/document.yaml b/v1/conformance/then/field/document.yaml new file mode 100644 index 0000000..332d129 --- /dev/null +++ b/v1/conformance/then/field/document.yaml @@ -0,0 +1,3 @@ +info: + title: "" + version: 1.0.0 diff --git a/v1/conformance/then/field/ruleset.yaml b/v1/conformance/then/field/ruleset.yaml new file mode 100644 index 0000000..924d24e --- /dev/null +++ b/v1/conformance/then/field/ruleset.yaml @@ -0,0 +1,6 @@ +rules: + info-title: + given: $.info + then: + field: title + function: truthy diff --git a/v1/conformance/then/field/test.json b/v1/conformance/then/field/test.json new file mode 100644 index 0000000..9d29598 --- /dev/null +++ b/v1/conformance/then/field/test.json @@ -0,0 +1,19 @@ +{ + "for": [ + "/req/then/field" + ], + "purpose": "With `field`, the function sees the named property of the matched node.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "info-title", + "path": [ + "info", + "title" + ] + } + ] + } +} diff --git a/v1/conformance/then/function-core/ruleset.yaml b/v1/conformance/then/function-core/ruleset.yaml new file mode 100644 index 0000000..0f7bb57 --- /dev/null +++ b/v1/conformance/then/function-core/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + acme-operation-summary: + description: Every operation needs a summary. + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy diff --git a/v1/conformance/then/function-core/test.json b/v1/conformance/then/function-core/test.json new file mode 100644 index 0000000..723a617 --- /dev/null +++ b/v1/conformance/then/function-core/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/then/function-known" + ], + "purpose": "A rule naming a core function is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/then/function-missing/ruleset.yaml b/v1/conformance/then/function-missing/ruleset.yaml new file mode 100644 index 0000000..1a8c94d --- /dev/null +++ b/v1/conformance/then/function-missing/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + r: + given: $ + then: + field: title diff --git a/v1/conformance/then/function-missing/test.json b/v1/conformance/then/function-missing/test.json new file mode 100644 index 0000000..731a12c --- /dev/null +++ b/v1/conformance/then/function-missing/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "An invocation without `function` is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/then/function-options/document.yaml b/v1/conformance/then/function-options/document.yaml new file mode 100644 index 0000000..8bd71e7 --- /dev/null +++ b/v1/conformance/then/function-options/document.yaml @@ -0,0 +1,2 @@ +info: + title: Petstore diff --git a/v1/conformance/then/function-options/ruleset.yaml b/v1/conformance/then/function-options/ruleset.yaml new file mode 100644 index 0000000..4b3ecad --- /dev/null +++ b/v1/conformance/then/function-options/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + short-title: + given: $.info.title + then: + function: length + functionOptions: + max: 3 diff --git a/v1/conformance/then/function-options/test.json b/v1/conformance/then/function-options/test.json new file mode 100644 index 0000000..0334ec2 --- /dev/null +++ b/v1/conformance/then/function-options/test.json @@ -0,0 +1,19 @@ +{ + "for": [ + "/req/then/function-options" + ], + "purpose": "The `max` option reaches the `length` function.", + "ruleset": "ruleset.yaml", + "document": "document.yaml", + "expect": { + "results": [ + { + "code": "short-title", + "path": [ + "info", + "title" + ] + } + ] + } +} diff --git a/v1/conformance/then/function-unknown/ruleset.yaml b/v1/conformance/then/function-unknown/ruleset.yaml new file mode 100644 index 0000000..242f680 --- /dev/null +++ b/v1/conformance/then/function-unknown/ruleset.yaml @@ -0,0 +1,5 @@ +rules: + r: + given: $ + then: + function: nonexistent diff --git a/v1/conformance/then/function-unknown/test.json b/v1/conformance/then/function-unknown/test.json new file mode 100644 index 0000000..6be6392 --- /dev/null +++ b/v1/conformance/then/function-unknown/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/then/function-known" + ], + "purpose": "A rule naming a function that is neither core nor declared is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/then/invocation-form-valid/ruleset.yaml b/v1/conformance/then/invocation-form-valid/ruleset.yaml new file mode 100644 index 0000000..36a5f94 --- /dev/null +++ b/v1/conformance/then/invocation-form-valid/ruleset.yaml @@ -0,0 +1,8 @@ +rules: + r: + given: $.info + then: + field: title + function: length + functionOptions: + max: 120 diff --git a/v1/conformance/then/invocation-form-valid/test.json b/v1/conformance/then/invocation-form-valid/test.json new file mode 100644 index 0000000..8373e7f --- /dev/null +++ b/v1/conformance/then/invocation-form-valid/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "An invocation with `function`, `field`, and `functionOptions` of the right types is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/then/options-not-object/ruleset.yaml b/v1/conformance/then/options-not-object/ruleset.yaml new file mode 100644 index 0000000..3754380 --- /dev/null +++ b/v1/conformance/then/options-not-object/ruleset.yaml @@ -0,0 +1,6 @@ +rules: + r: + given: $ + then: + function: pattern + functionOptions: "^a" diff --git a/v1/conformance/then/options-not-object/test.json b/v1/conformance/then/options-not-object/test.json new file mode 100644 index 0000000..d2880bb --- /dev/null +++ b/v1/conformance/then/options-not-object/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "An invocation whose `functionOptions` is a string is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/then/options-on-no-option-function/ruleset.yaml b/v1/conformance/then/options-on-no-option-function/ruleset.yaml new file mode 100644 index 0000000..f41ae87 --- /dev/null +++ b/v1/conformance/then/options-on-no-option-function/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + r: + given: $.name + then: + function: truthy + functionOptions: + strict: true diff --git a/v1/conformance/then/options-on-no-option-function/test.json b/v1/conformance/then/options-on-no-option-function/test.json new file mode 100644 index 0000000..a4fdc8c --- /dev/null +++ b/v1/conformance/then/options-on-no-option-function/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/then/function-options-valid" + ], + "purpose": "`truthy` with options is invalid, because it takes none.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/then/options-required-missing/ruleset.yaml b/v1/conformance/then/options-required-missing/ruleset.yaml new file mode 100644 index 0000000..d93addc --- /dev/null +++ b/v1/conformance/then/options-required-missing/ruleset.yaml @@ -0,0 +1,6 @@ +rules: + r: + given: $.name + then: + function: length + functionOptions: {} diff --git a/v1/conformance/then/options-required-missing/test.json b/v1/conformance/then/options-required-missing/test.json new file mode 100644 index 0000000..09d38cc --- /dev/null +++ b/v1/conformance/then/options-required-missing/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/then/function-options-valid" + ], + "purpose": "`length` with empty options is invalid, because it needs `min` or `max`.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/then/options-unknown-value/ruleset.yaml b/v1/conformance/then/options-unknown-value/ruleset.yaml new file mode 100644 index 0000000..bcdecda --- /dev/null +++ b/v1/conformance/then/options-unknown-value/ruleset.yaml @@ -0,0 +1,7 @@ +rules: + r: + given: $.name + then: + function: casing + functionOptions: + type: lower diff --git a/v1/conformance/then/options-unknown-value/test.json b/v1/conformance/then/options-unknown-value/test.json new file mode 100644 index 0000000..0bc1997 --- /dev/null +++ b/v1/conformance/then/options-unknown-value/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/then/function-options-valid" + ], + "purpose": "`casing` with `type: lower`, which is not a case the function knows, is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/then/options-valid/ruleset.yaml b/v1/conformance/then/options-valid/ruleset.yaml new file mode 100644 index 0000000..a23a421 --- /dev/null +++ b/v1/conformance/then/options-valid/ruleset.yaml @@ -0,0 +1,18 @@ +rules: + a: + given: $.name + then: + function: casing + functionOptions: + type: camel + disallowDigits: true + b: + given: $.tags + then: + function: alphabetical + c: + given: $.name + then: + function: length + functionOptions: + min: 1 diff --git a/v1/conformance/then/options-valid/test.json b/v1/conformance/then/options-valid/test.json new file mode 100644 index 0000000..e1dd24e --- /dev/null +++ b/v1/conformance/then/options-valid/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/then/function-options-valid" + ], + "purpose": "Core functions with options that match their tables are valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/then/shape-empty-array/ruleset.yaml b/v1/conformance/then/shape-empty-array/ruleset.yaml new file mode 100644 index 0000000..0127839 --- /dev/null +++ b/v1/conformance/then/shape-empty-array/ruleset.yaml @@ -0,0 +1,4 @@ +rules: + r: + given: $ + then: [] diff --git a/v1/conformance/then/shape-empty-array/test.json b/v1/conformance/then/shape-empty-array/test.json new file mode 100644 index 0000000..14a8850 --- /dev/null +++ b/v1/conformance/then/shape-empty-array/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A rule with `then: []` is valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/conformance/then/shape-string/ruleset.yaml b/v1/conformance/then/shape-string/ruleset.yaml new file mode 100644 index 0000000..4d4ab51 --- /dev/null +++ b/v1/conformance/then/shape-string/ruleset.yaml @@ -0,0 +1,4 @@ +rules: + r: + given: $ + then: truthy diff --git a/v1/conformance/then/shape-string/test.json b/v1/conformance/then/shape-string/test.json new file mode 100644 index 0000000..a8f5ef0 --- /dev/null +++ b/v1/conformance/then/shape-string/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "A rule whose `then` is a string is invalid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "invalid" + } +} diff --git a/v1/conformance/then/shape-valid/ruleset.yaml b/v1/conformance/then/shape-valid/ruleset.yaml new file mode 100644 index 0000000..36ac0f6 --- /dev/null +++ b/v1/conformance/then/shape-valid/ruleset.yaml @@ -0,0 +1,11 @@ +rules: + one: + given: $.info + then: + function: truthy + many: + given: $.info + then: + - function: truthy + - field: title + function: truthy diff --git a/v1/conformance/then/shape-valid/test.json b/v1/conformance/then/shape-valid/test.json new file mode 100644 index 0000000..d626070 --- /dev/null +++ b/v1/conformance/then/shape-valid/test.json @@ -0,0 +1,10 @@ +{ + "for": [ + "/req/ruleset/schema" + ], + "purpose": "`then` as a single invocation and as an array of them are both valid.", + "ruleset": "ruleset.yaml", + "expect": { + "ruleset": "valid" + } +} diff --git a/v1/copyright.include b/v1/copyright.include new file mode 100644 index 0000000..14167d7 --- /dev/null +++ b/v1/copyright.include @@ -0,0 +1 @@ +Copyright © [YEAR] the OpenLint contributors. Licensed under the Apache License, Version 2.0. The ruleset format originates in Spectral by Stoplight, also licensed Apache-2.0. diff --git a/v1/formats.json b/v1/formats.json new file mode 100644 index 0000000..ee96965 --- /dev/null +++ b/v1/formats.json @@ -0,0 +1,35 @@ +{ + "OpenAPI": { + "oas2": "`swagger` is present and its string form starts with `2` followed by a non-digit or the end.", + "oas3": "`openapi` is present and its string form starts with `3` followed by a non-digit or the end.", + "oas3_0": "`oas3`, and the string form of `openapi` matches `^3\\.0(\\.[0-9]*)?$`.", + "oas3_1": "`oas3`, and the string form of `openapi` matches `^3\\.1(\\.[0-9]*)?$`.", + "oas3_2": "`oas3`, and the string form of `openapi` matches `^3\\.2(\\.[0-9]*)?$`." + }, + "AsyncAPI": { + "asyncapi2": "The same as `aas2`.", + "aas2": "`asyncapi` is present and its string form matches `^2\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$`.", + "aas2_0": "`aas2`, and the string form of `asyncapi` matches `^2\\.0(\\.[0-9]*)?$`.", + "aas2_1": "`aas2`, and the string form of `asyncapi` matches `^2\\.1(\\.[0-9]*)?$`.", + "aas2_2": "`aas2`, and the string form of `asyncapi` matches `^2\\.2(\\.[0-9]*)?$`.", + "aas2_3": "`aas2`, and the string form of `asyncapi` matches `^2\\.3(\\.[0-9]*)?$`.", + "aas2_4": "`aas2`, and the string form of `asyncapi` matches `^2\\.4(\\.[0-9]*)?$`.", + "aas2_5": "`aas2`, and the string form of `asyncapi` matches `^2\\.5(\\.[0-9]*)?$`.", + "aas2_6": "`aas2`, and the string form of `asyncapi` matches `^2\\.6(\\.[0-9]*)?$`.", + "aas3": "`asyncapi` is present and its string form matches `^3\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$`.", + "aas3_0": "`aas3`, and the string form of `asyncapi` matches `^3\\.0(\\.[0-9]*)?$`.", + "aas3_1": "`aas3`, and the string form of `asyncapi` matches `^3\\.1(\\.[0-9]*)?$`." + }, + "Arazzo": { + "arazzo1_0": "`arazzo` is present and its string form matches `^1\\.0(\\.[0-9]*)?$`." + }, + "JSON Schema": { + "jsonSchema": "`$schema` is a string that contains `//json-schema.org/`.", + "jsonSchemaLoose": "`jsonSchema`, or `type` is a JSON Schema type name or an array of them, or `enum` is an array, or one of `allOf`, `anyOf`, `oneOf`, `not`, and `if` is an object or an array.", + "jsonSchemaDraft4": "`$schema` matches `^https?://json-schema.org/draft-04/(hyper-)?schema#?$`.", + "jsonSchemaDraft6": "`$schema` matches `^https?://json-schema.org/draft-06/(hyper-)?schema#?$`.", + "jsonSchemaDraft7": "`$schema` matches `^https?://json-schema.org/draft-07/(hyper-)?schema#?$`.", + "jsonSchemaDraft2019_09": "`$schema` matches `^https?://json-schema.org/draft/2019-09/(hyper-)?schema#?$`.", + "jsonSchemaDraft2020_12": "`$schema` matches `^https?://json-schema.org/draft/2020-12/(hyper-)?schema#?$`." + } +} diff --git a/v1/header.include b/v1/header.include new file mode 100644 index 0000000..86dad59 --- /dev/null +++ b/v1/header.include @@ -0,0 +1,21 @@ + + + + + [TITLE] + + + + +
    +

    +

    [TITLE]

    +

    [VERSIONLABEL],

    +
    +
    + +
    +
    +
    + +
    diff --git a/v1/index.bs b/v1/index.bs new file mode 100644 index 0000000..7e1a937 --- /dev/null +++ b/v1/index.bs @@ -0,0 +1,1656 @@ + + +
    +{
    +  "yaml-1.2.2": {
    +    "title": "YAML Ain't Markup Language (YAML) Version 1.2, Revision 1.2.2",
    +    "href": "https://yaml.org/spec/1.2.2/",
    +    "authors": ["Oren Ben-Kiki", "Clark Evans", "Ingy döt Net", "Tina Müller", "Pantelis Antoniou", "Eemeli Aro", "Thomas Smith"],
    +    "publisher": "YAML Language Development Team",
    +    "date": "1 October 2021"
    +  },
    +  "json-schema-core-2020-12": {
    +    "title": "JSON Schema: A Media Type for Describing JSON Documents, draft-bhutton-json-schema-01 (2020-12)",
    +    "href": "https://json-schema.org/draft/2020-12/json-schema-core",
    +    "authors": ["Austin Wright", "Henry Andrews", "Ben Hutton", "Greg Dennis"],
    +    "publisher": "IETF",
    +    "date": "16 June 2022"
    +  },
    +  "json-schema-validation-2020-12": {
    +    "title": "JSON Schema Validation: A Vocabulary for Structural Validation of JSON, draft-bhutton-json-schema-validation-01 (2020-12)",
    +    "href": "https://json-schema.org/draft/2020-12/json-schema-validation",
    +    "authors": ["Austin Wright", "Henry Andrews", "Ben Hutton"],
    +    "publisher": "IETF",
    +    "date": "16 June 2022"
    +  },
    +  "json-schema-draft-04": {
    +    "title": "JSON Schema: core definitions and terminology, draft-zyp-json-schema-04",
    +    "href": "https://json-schema.org/draft-04/draft-zyp-json-schema-04",
    +    "authors": ["Francis Galiegue", "Kris Zyp", "Gary Court"]
    +  },
    +  "json-schema-draft-06": {
    +    "title": "JSON Schema: A Media Type for Describing JSON Documents, draft-wright-json-schema-01 (draft 6)",
    +    "href": "https://json-schema.org/draft-06/draft-wright-json-schema-01",
    +    "authors": ["Austin Wright"]
    +  },
    +  "json-schema-draft-2019-09": {
    +    "title": "JSON Schema: A Media Type for Describing JSON Documents, draft-handrews-json-schema-02 (2019-09)",
    +    "href": "https://json-schema.org/draft/2019-09/draft-handrews-json-schema-02",
    +    "authors": ["Austin Wright", "Henry Andrews", "Ben Hutton", "Greg Dennis"]
    +  },
    +  "json-schema-draft-07": {
    +    "title": "JSON Schema: A Media Type for Describing JSON Documents, draft-handrews-json-schema-01 (draft 7)",
    +    "href": "https://json-schema.org/draft-07/draft-handrews-json-schema-01",
    +    "authors": ["Austin Wright", "Henry Andrews"],
    +    "publisher": "IETF",
    +    "date": "19 March 2018"
    +  },
    +  "ecma-262-2025": {
    +    "title": "ECMAScript 2025 Language Specification, 16th edition (ECMA-262)",
    +    "href": "https://262.ecma-international.org/16.0/",
    +    "publisher": "Ecma International",
    +    "date": "June 2025"
    +  },
    +  "spectral": {
    +    "title": "Spectral: a flexible JSON/YAML linter",
    +    "href": "https://github.com/stoplightio/spectral",
    +    "publisher": "Stoplight"
    +  },
    +  "semver": {
    +    "title": "Semantic Versioning 2.0.0",
    +    "href": "https://semver.org/spec/v2.0.0.html",
    +    "authors": ["Tom Preston-Werner"]
    +  },
    +  "ecma-402": {
    +    "title": "ECMAScript 2025 Internationalization API Specification, 12th edition (ECMA-402)",
    +    "href": "https://262.ecma-international.org/",
    +    "publisher": "Ecma International"
    +  },
    +  "jsonpath-plus": {
    +    "title": "JSONPath-Plus 10.4.0",
    +    "href": "https://github.com/JSONPath-Plus/JSONPath/tree/v10.4.0",
    +    "publisher": "JSONPath-Plus contributors"
    +  },
    +  "minimatch": {
    +    "title": "minimatch 3.1.5",
    +    "href": "https://github.com/isaacs/minimatch/tree/v3.1.5",
    +    "publisher": "Isaac Z. Schlueter"
    +  }
    +}
    +
    + +# Introduction # {#intro} + +A [=ruleset=] is a set of [=rules=] for checking JSON and YAML documents. Each rule selects nodes in a [=document=] and checks each selected node with a [=function=]. A ruleset records the conventions a group of documents has to follow, so that the documents are checked the same way wherever they are written or reviewed. Nothing in the format depends on what the documents describe. + +This specification defines the format apart from any one [=linter=], so that several [=linters=] can implement it and agree on what a ruleset means. + +## How to read this document ## {#reading} + +The normative content of this document is its [=requirements=], together with the definitions in [[#terminology]], the conformance classes in [[#classes]], the tables that requirements refer to, and the ruleset schema that /req/ruleset/schema refers to. Everything else, including this introduction, examples, notes, and issues, is informative. + +A requirement looks like this: + +
    +A [=requirement=] MUST be written so that a reader can tell, from the requirement alone, what conforms and what does not. +
    + +Each requirement carries: + +- a requirement identifier, such as `/req/given/no-match`. It is permanent (see [[#identifiers]]), and `#/req/given/no-match` links to it in every version of this document that contains it; +- the [=conformance class=] it belongs to, which fixes whether it applies to a ruleset or to a linter; +- the version it first appeared in; +- the [=conformance tests=] that check it, listed below it. + +A sentence without a keyword inside a requirement defines a term the keyword sentence uses, and binds with it. + +The example above only shows the shape. Its status, `example`, marks that it is not a requirement of this specification. Recommendations (`/rec/`, written with should) and permissions (`/per/`, written with may) look the same and belong to a class too. + +Every requirement is listed by class in [[#requirements-index]], and every test with its fixtures in [[#conformance-tests]]. Open questions appear as issue blocks where they arise, and the [[#issues-index]] at the end of the document collects them. A release carries no issues. + +## Versions of this document ## {#versions} + +Releases are numbered as Semantic Versioning 2.0.0 [[SEMVER]] prescribes. A patch release corrects the text or the tests without changing what conforms, a minor release adds requirements that every ruleset conforming to the previous release still satisfies, which can include new requirements for linters, and a major release is anything else. Each release has its own URL and never changes once published. + +Issue: What counts as a change that breaks conformance, and what is promised to whom, is discussed in discussion #12. + + + + + + + + + +
    URLServes
    `https://spec.openlint.org//`A release, such as `1.0.0`.
    `https://spec.openlint.org/v/draft/`The working draft of a major version, such as `v1` for 1.x. It changes without notice.
    `https://spec.openlint.org/v/`The latest release of a major version. Until the major version has a release, only the working draft's `ruleset.schema.json` is served there.
    `https://spec.openlint.org/`An index of every version.
    + +Each version publishes its own copy of the ruleset schema next to the document, as `ruleset.schema.json`. That copy is the one /req/ruleset/schema refers to, and its `$id` is its own URL. Editors and validators that follow the newest release can use `https://spec.openlint.org/v/ruleset.schema.json`. + +# Conformance # {#conformance} + +## Requirement keywords ## {#keywords} + +
    +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [[!RFC2119]] [[!RFC8174]] when, and only when, they appear in all capitals, as shown here. +
    + +These keywords appear only inside identified [=requirements=]. Text outside a requirement carries no normative force, whatever words it uses. + +## Conformance classes ## {#classes} + +A conformance target is the kind of thing a requirement applies to, either a ruleset or a linter. + +: ruleset +:: A [=ruleset=] document. A ruleset conforms when it satisfies every requirement whose target is `ruleset`. +: linter +:: A [=linter=]. A linter conforms when it satisfies every requirement whose target is `linter`. + +A conformance class is a set of requirements for one conformance target. An implementation claims conformance to a class as a whole. Each class below names the classes it depends on. When it depends on a class of the same target, conforming to it includes conforming to that class. + +A conformance claim names a release of this specification and the classes claimed, as `https://spec.openlint.org//#/conf/`. A later release can add requirements to a class, so a claim holds for the release it names. + +
    +The ruleset format: what a [=ruleset=] document has to look like to be loaded by any conforming linter. Most of it is one requirement, validity against the ruleset schema. The other requirements are constraints a schema cannot express. +
    + +
    +Everything a [=linter=] does with a ruleset and a document: loading, parsing and resolution, rule state, selecting documents and nodes, applying functions, and the results and messages it reports. It depends on `ruleset-core` because a linter rejects a ruleset that does not conform to that class. [[#loading]] says how. +
    + +
    +The contract of [=custom function|custom functions=], in any language: how a [=linter=] resolves a [=function=] a [=ruleset=] names, calls it, and reports what it returns. A linter claims it together with a binding. +
    + +
    +The JavaScript binding of [=custom function|custom functions=]: how a [=custom function=] is written as a JavaScript module, and where a [=linter=] finds it. +
    + +## Requirement identifiers ## {#identifiers} + +A [=requirement identifier=] has the form `/req//`. The topic is what the requirement is about, such as a ruleset key (`given`, `overrides`) or a step of linting (`resolution`, `result`), not a section of this document, so a requirement keeps its identifier when the document is reorganized. The name describes the requirement. Both are lowercase words joined by hyphens. + +Identifiers are permanent: + +- An identifier is never reused for a different requirement. +- When a requirement's text changes in a way that changes what conforms, it gets a new identifier and the old one is marked removed. +- A removed requirement stays listed in [[#requirements-index]] as removed, with the version in which it was removed. + +Issue: Whether path-style identifiers work for everyone is an open question in discussion #13. Alternatives such as numeric identifiers (`REQ-042`) or dotted names (`given.no-match`) have not been raised there yet. + +## Conformance tests ## {#tests-intro} + +Every requirement has at least one conformance test, identified as `/conf//` in the same way. A test is data: a ruleset fixture, for linter tests a document fixture, and the expected outcome. [[#conformance-tests]] renders every test from that data, and a linter is checked against the same data. A test covers one or more requirements of one class. + +A test for a ruleset requirement expects a linter to load or to reject the fixture. A test for a linter requirement expects the results of linting the document fixture with the ruleset fixture, compared on the fields it names. + +The tests follow these rules: + +- A ruleset requirement has an invalid test, whose fixture violates it and is rejected, and a valid test, whose fixture uses the same property and is loaded. The second shows that a linter does not reject more than the requirement says. +- A linter requirement has a test that expects results, because a linter that does nothing passes a test that expects none. +- A permission has a test in which what is permitted is accepted. +- An invalid fixture violates one requirement only, so that its rejection proves that requirement and not another. +- A recommendation has no test, because ignoring it still conforms. + +Issue: Whether these tests become a published conformance suite, where its runner would live, and how a linter would publish its results against it, is not decided. + +# Terminology # {#terminology} + +: document +:: A JSON [[!RFC8259]] or YAML [[!YAML-1.2.2]] document that a [=linter=] lints. After parsing, both are the same tree of objects, arrays, strings, numbers, booleans, and null. +: ruleset +:: A JSON or YAML document in the format defined by this specification: a set of [=rules=], the [=rulesets=] it extends, and adjustments to both. +: rule +:: A named unit of a [=ruleset=] that selects nodes in a [=document=] with `given` and asserts something about each of them with `then`. +: rule name +:: The key under which a [=rule=] appears in `rules`. The name identifies the rule. It appears in [=results=], and an extending ruleset uses it to adjust the rule. +: path expression +:: A string in `given` that selects nodes: a JSONPath expression beginning with `$`, or an [=alias reference=] beginning with `#`. +: alias +:: A named, reusable set of [=path expressions=], declared under `aliases`. +: alias reference +:: A [=path expression=] of the form `#` followed by an optional JSONPath remainder, such as `#Info.description`. +: matched node +:: A node of the [=document=] selected by one of a rule's [=path expressions=]. +: function +:: A named assertion applied to a [=matched node=] by `then`. A function is either one of the core functions or a [=custom function=]. +: custom function +:: A [=function=] whose implementation a [=ruleset=] supplies as code, named under `functions`. +: result +:: What a [=linter=] reports about a [=document=]: a problem a [=function=] found in a [=matched node=], or a problem the linter found itself, such as a syntax error. A result has a code, a message, a [=severity=], and a location in the document. See [[#results]]. +: severity +:: How a rule's [=results=] are reported: `error`, `warn`, `info`, or `hint`. The severity `off` disables the rule. +: effective severity +:: The [=severity=] a [=rule=] has after every [=ruleset=] in the chain of `extends` and every [=override=] has adjusted it. +: enabled +:: The state of a [=rule=] that a [=linter=] evaluates. A rule is either enabled or not, independently of its [=severity=]. A rule with [=effective severity=] `off` is not enabled. +: format +:: A named kind of [=document=], such as `oas3_1` for an OpenAPI 3.1 description. A rule scoped to formats runs only against documents of those kinds. +: override +:: An entry of `overrides`: adjustments that apply to the subset of documents matched by its `files` globs. +: resolved document +:: The [=document=] after every `$ref` has been replaced by the value it points to, including references into other files. See [[#resolution]]. +: extension property +:: A property whose name begins with `x-`, carried by the format for the author's own tooling and ignored by a [=linter=]. +: linter +:: Software that takes a [=ruleset=] and one or more [=documents=] and produces [=results=]. +: shorthand +:: A value in `rules` that is not a Rule Object: a severity name or a boolean, which adjusts a rule the [=ruleset=] inherits instead of defining one. +: mode +:: The second element of an `extends` entry, `recommended`, `all`, or `off`, which decides which of the inherited rules start out [=enabled=]. +: file-scoped override +:: An [=override=] whose `files` globs do not all carry a JSON Pointer fragment. It can change anything a ruleset declares, for the documents it matches. +: pointer-scoped override +:: An [=override=] whose `files` globs all carry a JSON Pointer fragment. It can only change severities, at the locations the pointers name. +: core function +:: A [=function=] this specification defines, which every conforming [=linter=] provides under its name. +: function invocation +:: One entry of a rule's `then`: the [=function=] to apply, the `field` to apply it to, and its `functionOptions`. +: problem +:: What a [=function=] returns for a value that fails: a message, and optionally the path of the offending value. A [=linter=] reports each problem as a [=result=]. +: requirement +:: An identified statement of this specification that one [=conformance target=] has to satisfy to conform. Recommendations and permissions are identified the same way but are not requirements. + +# The ruleset format # {#ruleset-format} + +This part defines what a [=ruleset=] document looks like, in the order of the document itself: the document, the Ruleset Object at its root and each of its properties, the Rule Object and each of its properties. + +## The ruleset document ## {#ruleset-document} + +A ruleset is one file, written in JSON or YAML. + +
    +A [=ruleset=] MUST be a JSON [[!RFC8259]] or YAML [[!YAML-1.2.2]] document. +
    + +
    +A [=ruleset=] written in YAML MUST contain exactly one YAML document. +
    + +Issue: /req/ruleset/single-document is proposed, because it changes current behavior. Spectral and OpenLint accept a YAML file with several documents separated by `---`, read the first, and drop the others without reporting them; neither documents this. A ruleset split by a stray `---` therefore loses rules silently, and rejecting the file catches that. Until both linters change, /conf/ruleset/document-two-yaml-documents fails against them. Whether this counts as a breaking change for 1.x is part of discussion #12. + +The ruleset schema is written in JSON Schema 2020-12 and published with each version of this specification, next to this document as ruleset.schema.json. It validates the value at the root of the document. + +
    +The root value of a [=ruleset=] MUST be valid against the ruleset schema of this version of this specification, as JSON Schema 2020-12 [[!JSON-SCHEMA-CORE-2020-12]] [[!JSON-SCHEMA-VALIDATION-2020-12]] defines validity. +
    + +Issue: `documentationUrl` takes only an absolute `http`, `https`, or `ftp` URL with a public host, which is how the `url` format of OpenLint's validator reads a URL. The schema spells that out as a pattern. It rejects `mailto:` and `urn:` URIs, `localhost`, single-label intranet hosts such as `http://wiki/docs`, private and loopback addresses, and punycode host names; see /conf/ruleset/documentation-url-intranet. Accepting any absolute URI [[RFC3986]] would let a ruleset link to internal documentation. + +## The Ruleset Object ## {#ruleset-object} + +The Ruleset Object is the root of a ruleset. It holds the rules the ruleset defines, the rulesets it extends, and the adjustments it makes to them, and it can have a `description` and a `documentationUrl`. + +
    + +
    +```yaml +description: Acme API style guide +documentationUrl: https://engineering.acme.com/api/governance +extends: [[spectral:oas, recommended]] +formats: [oas3] +rules: + acme-operation-summary: + description: Every operation needs a summary. + severity: error + given: "$.paths[*]['get','put','post','delete','options','head','patch','trace']" + then: + field: summary + function: truthy +``` +
    + +### `extends` ### {#extends} + +`extends` inherits from one or more other rulesets. Its value is a string that identifies a ruleset, or an array. Each element of the array is such a string, or an array of the string and a mode. Elements after the mode are ignored. + +Issue: Elements after the mode are accepted and ignored, so `[./base.yaml, all, extra]` reads as `[./base.yaml, all]`. OpenLint accepts them although its own validation schema forbids them for this array. Rejecting them would catch a misplaced bracket. + +The string identifies a ruleset as a built-in identifier (`spectral:oas`, `spectral:asyncapi`, `spectral:arazzo`), a file path, an npm module specifier, or a URL. The [=mode=] decides which of the inherited rules start out [=enabled=]: + + + + + + + + +
    ModeEnables
    `recommended`Only rules with `recommended: true`, which is the default for a rule. The default mode when a bare string is used.
    `all`Every rule in the ruleset.
    `off`No rules. Inherit the definitions without turning any of them on, then enable the ones you want by name.
    + +
    +```yaml +extends: + - [spectral:oas, all] + - [./team-baseline.yaml, "off"] + - https://example.com/rulesets/security.yaml +``` +
    + +With `off`, a ruleset can extend a large ruleset and enable only the rules it names. [[#loading]] defines loading, and [[#rule-state]] the effect of the modes. + +### `rules` ### {#rules} + +`rules` maps a [=rule name=] to either a [[#rule-object|Rule Object]], which defines a rule or redefines an inherited one, or a shorthand, which adjusts a rule inherited through [[#extends]]. Other rulesets refer to a rule by its name to disable it or change its severity, so renaming a rule breaks them. + +A linter also reports problems of its own, such as a syntax error in the document, under fixed codes listed in [[#results]]. A rule with one of those names is accepted, but its results cannot be told apart from the linter's own by their code. + +
    +A [=rule name=] SHOULD NOT be one of the codes a [=linter=] uses for the results it produces itself: `parser`, `unrecognized-format`, and `invalid-ref`. +
    + +#### Shorthands #### {#shorthands} + +A value in `rules` that is not an object is a [=shorthand=]: one of the severity names `error`, `warn`, `info`, `hint`, `off`, or one of the booleans `true` and `false`. It adjusts a rule instead of defining one. + +
    +A shorthand in the root `rules` MUST name a [=rule=] the [=ruleset=] inherits through `extends`. +
    + +A shorthand in the `rules` of a file-scoped [=override=] names a rule the ruleset defines or inherits. That is checked when the override applies to a document; see /req/overrides/checked-when-applied. + +Note: Severities are not written the same way everywhere. A rule's `severity` takes a name or a number. A shorthand, in the root `rules` or in a file-scoped override, takes a name or a boolean but not a number. The `rules` of a pointer-scoped override take a name or a number but not a boolean. + +Issue: A numeric shorthand such as `r: 0` is rejected only by OpenLint's validation schema. Its type definitions and its merge code accept a number and would apply it as the severity. Accepting numbers in shorthands would remove the difference with `severity` without making any ruleset invalid. + +
    +```yaml +extends: spectral:oas +rules: + operation-tags: error # raise it + info-contact: false # switch it off + oas3-schema: true # switch it back on, at its current severity +``` +
    + +What each shorthand does is defined in [[#rule-state]]. + +### `aliases` ### {#aliases} + +An alias names a list of path expressions, so that several rules can share it. A rule references an alias from `given` with a leading `#`. Aliases are also under discussion in discussion #2. + +An alias name starts with a letter, followed by letters, digits, `_`, and `-`. A simple alias is a non-empty array of [=path expressions=]. + +
    +An [=alias=] MUST NOT refer to itself, directly or through other aliases. +
    + +
    +```yaml +aliases: + Info: + - $..info + InfoDescription: + - "#Info.description" + PathItemObject: + - $.paths[*] + OperationObject: + - "#PathItemObject['get','put','post','delete','options','head','patch','trace']" +``` +
    + +A scoped alias resolves to different expressions depending on the format of the document being linted. One rule can then select, for example, the parameters of both OpenAPI 2 and OpenAPI 3 documents. A scoped alias is an object: + +
    + +Each of its targets is an object: + +
    + +
    +```yaml +aliases: + ParameterObject: + description: Every Parameter Object, wherever this spec version keeps them. + targets: + - formats: [oas2] + given: + - $.parameters[*] + - formats: [oas3] + given: + - $.components.parameters[*] +``` +
    + +Aliases are declared at the root of the ruleset or within a file-scoped [=override=]. How they expand is defined in [[#node-selection]], and which rules an override's aliases reach in [[#applying-overrides]]. + +### `overrides` ### {#overrides} + +Overrides apply adjustments to a subset of the documents being linted, matched by glob. `overrides` is a non-empty array of objects, each with a non-empty array `files` of glob patterns. + +`files` entries are glob patterns, matched as minimatch 3.1.5 [[!MINIMATCH]] matches them, against the path of the document relative to the directory of the ruleset file, with `/` as the separator. minimatch has no specification of its own, so the glob dialect is what that version accepts. + +Issue: A glob dialect with a specification of its own, or a written subset of minimatch's, would let a linter in another language match `files` without reproducing minimatch. + +There are two forms, distinguished by whether every glob in `files` carries a JSON Pointer [[!RFC6901]] fragment. An override that mixes the two kinds of glob is file-scoped. + +An override whose `files` do not all carry a `#` fragment is a [=file-scoped override=]. It has at least one of `extends` and `rules`, and it can also have `formats`, `parserOptions`, and `aliases`. + +Note: The schema accepts other properties on a file-scoped override. When the override applies to a document, a property the Ruleset Object does not have makes a linter reject the ruleset, while one it does have, such as `description`, is accepted; see /req/overrides/checked-when-applied. + +
    +```yaml +overrides: + - files: ["legacy/**/*.yaml"] + rules: + acme-operation-summary: warn + - files: ["v3/**/*.yaml"] + formats: [oas3_1] + extends: [[./strict.yaml, all]] +``` +
    + +An override whose `files` all carry a `#` fragment is a [=pointer-scoped override=]. It has `rules`, whose every value is a severity name or number, and no other property than `files`. + +
    +```yaml +overrides: + - files: ["api.yaml#/paths/~1legacy~1users"] + rules: + acme-operation-summary: "off" +``` +
    + +A pointer-scoped override can change a rule's severity at one location in one file, or switch the rule off there. It cannot change what the rule checks. [[#applying-overrides]] defines how overrides are applied. + +### `parserOptions` ### {#parser-options} + +Severities for problems found while parsing the document, before any rule runs; see [[#parsing]]. + +
    + +### `functions` and `functionsDir` ### {#functions-declaration} + +A serialized ruleset names the [=custom function|custom functions=] it uses in `functions`, and can name the directory they load from in `functionsDir`; see [[#custom-functions]]. + +
    +```yaml +functions: + - dictionary +functionsDir: ./my-functions # optional; defaults to "functions" +rules: + no-evil-words: + message: "{{error}}" + given: + - $.info.title + - $.info.description + then: + function: dictionary +``` +
    + +
    +A [=ruleset=] SHOULD use the core functions where they express the assertion, and name a [=custom function=] only for an assertion they cannot express. +
    + +Issue: OpenLint runs [=custom function|custom functions=] without isolation. Whether a conforming linter has to sandbox them, or refuse functions that come from a remote ruleset, is open. + +## The Rule Object ## {#rule-object} + +A rule selects nodes in a document with `given` and checks each selected node with `then`. + +
    + +What `message` and `description` contribute to a result is defined in [[#results]], how `recommended` combines with `extends` in [[#rule-state]], and what `resolved` changes in [[#node-selection]]. + +Note: `tags`, `type`, and `extensions` carry no behavior in this specification. They exist for tooling built around rulesets. + +### `given` ### {#rule-given} + +`given` says where in the document a rule looks. It is a JSONPath expression, such as `$.paths[*]['get','put','post','delete','options','head','patch','trace']` for every operation of an OpenAPI description, or a reference to an [=alias=] that names one, such as `#Operation`. JSONPath-Plus has no grammar document, so the dialect is what that implementation accepts; [[#node-selection]] pins it. `then` checks each node the expression selects on its own. [[#node-selection]] defines how the expression is evaluated. + +The value of `given` is a [=path expression=] or a non-empty array of them. A path expression that begins with `$` is a JSONPath expression, and one that begins with `#` is an [=alias reference=]. + +
    +An [=alias reference=] in a [=rule=] MUST name an [=alias=] declared under `aliases` in the [=ruleset=] whose `rules` contain the rule or, for a rule in a file-scoped [=override=], in that override or that ruleset. When the rule redefines a rule inherited through `extends`, the ruleset that defines the inherited rule MUST declare that alias too. +
    + +Issue: A redefined rule keeps resolving its alias references against the ruleset that first defined it, so redefining an inherited rule with an alias reference works only when both rulesets declare the alias, and then uses the inherited ruleset's. Resolving against the redefining ruleset's aliases would let an extending ruleset retarget a rule with aliases of its own. + +A bare property name such as `paths` is not a path expression. It begins with neither `$` nor `#`, so the ruleset schema rejects it, and so does a conforming linter. + +
    +```yaml +given: + - $.info + - "#OperationObject" +``` +
    + +### `then` ### {#rule-then} + +`then` is one [=function invocation=], or an array of them. A linter applies each of them to every node `given` matched, as [[#applying-functions]] defines. A function invocation is an object: + +
    + +Issue: `then: []` is accepted, and the rule then asserts nothing and never reports a result. Rejecting an empty array would catch a rule left unfinished. + +
    +`function` MUST name a [[#core-functions|core function]] or a [=custom function=] declared in `functions`. +
    + +
    +When `function` names a core function, `functionOptions` MUST satisfy that function's options in [[#core-functions]]: only the options its table lists, with the types listed, every required option present, and no `functionOptions` at all for a function that takes no options. +
    + +Issue: An unknown property inside an invocation, such as `functionOption` without the s, is ignored. When the function has no required option it runs with its defaults; when it has one, the ruleset is rejected for the missing `functionOptions` with a message that does not point at the misspelling. Rejecting the unknown property would catch the misspelling in both cases. + +
    +```yaml +then: + - field: summary + function: truthy + - field: summary + function: length + functionOptions: + max: 120 +``` +
    + +### `severity` ### {#rule-severity} + +`severity` says how serious a result from this rule is, and whether the rule runs at all. A ruleset can write either; results carry the numbers. + + + + + + + + + + +
    NameNumber
    `error``0`
    `warn``1`
    `info``2`
    `hint``3`
    `off``-1`
    + +A rule's `severity` is one of the names or one of the numbers. + +What a severity means for a rule, and how it combines with `extends`, shorthands, and overrides, is defined in [[#rule-state]]. + +### `formats` ### {#rule-formats} + +`formats` limits a rule to documents of certain kinds, so that a rule about OpenAPI paths does not run on an AsyncAPI document linted with the same ruleset. + +`formats` is an array of format identifiers. It can appear on a ruleset, a rule, a file-scoped override, and an alias target. + +The identifiers are listed in [[#formats]]; which rules run against which documents is defined in [[#document-selection]]. + +## Extension properties ## {#extension-properties} + +
    +A [=ruleset=] MAY have [=extension properties=] at its root and on any [=rule=], with any value. +
    + +Use them to carry ownership, tickets, review dates, or anything else your own tooling needs. Rules also have an `extensions` object for the same purpose. A linter ignores both. Elsewhere they are not part of the format. The ruleset schema rejects them in `parserOptions` and in a pointer-scoped override. A file-scoped override, a scoped alias, and an invocation under `then` accept them, and a linter ignores them there too. + +
    +```yaml +x-owner: platform-team +rules: + acme-operation-summary: + x-jira: GOV-142 + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy +``` +
    + +# Documents # {#documents} + +A [=linter=] lints [=documents=] with the rulesets of [[#ruleset-format]]. This section says what a document is to a linter, what happens to it before any rule runs, and how its formats are found. + +## The document model ## {#document-model} + +A [=document=] is written in JSON [[!RFC8259]] or YAML [[!YAML-1.2.2]]. After parsing, both are the same tree of objects, arrays, strings, numbers, booleans, and null, and that tree is what [=path expressions=] select from and what [=functions=] see. A linter keeps the mapping from every node back to its position in the source text, because a [=result=] reports that position. + +Note: Rulesets authored in JavaScript can inline function implementations and whole ruleset objects, which have no JSON representation and are out of scope. Only serialized documents and rulesets are specified. + +The root of a document can be any value, including an array, a scalar, or null, which is what an empty file parses to. `$` selects it whatever it is. + +
    +A [=linter=] MUST lint a [=document=] whose root value is not an object, and `$` MUST select that root value. +
    + +
    +A [=linter=] MUST read a [=document=] and a [=ruleset=] as UTF-8. +
    + +Issue: A UTF-8 byte order mark is accepted, but OpenLint then reports `range` columns on every line after the first one too far to the left. A UTF-16 document is not rejected; it parses as garbage and produces `parser` results. Ignoring a byte order mark and rejecting other encodings would make both deterministic. + +## Parsing ## {#parsing} + +Problems in a document's syntax are reported as [=results=] with the code `parser`. A problem that leaves the document with a JSON reading, such as a key that appears twice, still lets the document be linted; [[#parser-options|`parserOptions`]] sets its severity. + +
    +When a [=document=] has a syntax error, a [=linter=] MUST produce a [=result=] with the code `parser` and the severity `error`. +
    + +
    +When a YAML [=document=] contains several documents, a [=linter=] MUST produce a [=result=] with the code `parser` and the severity `error`, and MUST lint the first document. +
    + +Issue: Whether rules run against the part of a document before a syntax error is not stated. OpenLint runs them against whatever tree its parser recovers, which for an unterminated flow collection can be nothing. + +
    +When a [=document=] has a duplicate key, a [=linter=] MUST produce a [=result=] with the code `parser` and the severity of `duplicateKeys`, `error` when it is not set, and MUST NOT produce one when it is `off`. +
    + +
    +When a YAML [=document=] has a mapping key that is not a string scalar, such as `1`, `true`, or `null`, a [=linter=] MUST produce a [=result=] with the code `parser` and the severity of `incompatibleValues`, `error` when it is not set, and MUST NOT produce one when it is `off`. +
    + +Issue: Non-string scalar keys are the one construct OpenLint reports under `incompatibleValues`. A mapping key that is itself a sequence or a mapping is reported as a warning by the YAML parser, with no option controlling it, and a `!!binary` or `!!set` tag ends the run with an error before any result. Whether those constructs belong under `incompatibleValues`, and whether a tag the linter cannot represent is a parser result, is open. + +## Reference resolution ## {#resolution} + +The [=resolved document=] is the document after every `$ref` has been replaced by the value it points to, including references into other files. By default a rule sees the resolved document; a rule that sets `resolved: false` sees the document as written. + +A reference is an object with a `$ref` property whose value is a URI reference [[!RFC3986]]: a fragment that is a JSON Pointer [[!RFC6901]] into the same document, or a relative or absolute reference to another document, with an optional fragment into it. + +
    +A [=linter=] MUST replace a reference whose `$ref` is a fragment with the value the JSON Pointer names in the same [=document=]. +
    + +
    +A [=linter=] MUST replace a reference whose `$ref` names another document with the value its fragment names in that document, after resolving the `$ref` against the location of the referencing document. +
    + +
    +A [=linter=] MUST resolve a [=document=] whose references form a cycle without failing, and MUST evaluate rules against it. +
    + +
    +When a `$ref` cannot be resolved, a [=linter=] MUST produce a [=result=] with the code `invalid-ref` and the severity `error`, and MUST leave the reference object in place for the rules. +
    + +Issue: What a rule sees at the position of a reference in a cycle, and whether resolution follows `$ref` in every position or only where the document's format expects one, is not stated. + +## Formats ## {#formats} + +A [=format=] names a kind of document, and a linter decides from the document whether it is of that kind, by the condition the table gives. A condition reads the document's root object; a document whose root is not an object has no format. The string form of a value is the value as JSON writes it, without quotes for a string, so an unquoted `3.0` in YAML is the number `3`. A rule scoped to a format runs only against documents of that format, so one ruleset can hold rules for OpenAPI 3.1 and for AsyncAPI 2.6 without either set running on the other kind of document. The table lists the identifiers a ruleset can use, which are the ones the ruleset schema accepts, and how each is detected. + +
    +path: formats-table.include
    +
    + +Any other identifier, such as the typo `oas31`, makes the ruleset invalid, wherever `formats` appears. How a rule names formats is defined in [[#rule-formats]]; how a linter detects them and which rules run is defined in [[#document-selection]]. + +Issue: The set of identifiers is closed, because OpenLint rejects an identifier it does not register. The previous document and schema accepted any string, so that formats added later, or registered by a host application, would stay valid. Which of the two this specification wants is open. + +# Linting # {#linting} + +This part defines what a [=linter=] does with a [=ruleset=] and a [=document=], in the order it happens: + +1. [[#loading|Load the ruleset]]: check it and load what it extends. +2. [[#documents|Read the document]]: parse it, resolve its references, and detect its formats. +3. [[#applying-overrides|Apply the overrides]] whose globs match the document. +4. [[#rule-state|Work out the rule state]]: which rules are enabled, and at what severity. +5. [[#document-selection|Select documents]]: decide, by format, which rules apply to the document. +6. [[#node-selection|Select nodes]]: evaluate each rule's `given`. +7. [[#applying-functions|Apply functions]]: run each rule's `then` on every node it matched. +8. [[#results|Report]] the results. + +## A running example ## {#walkthrough} + +The sections of this part follow one scenario through the pipeline. A team keeps its shared rules in a base ruleset: + +
    +```yaml +rules: + operation-summary: + description: Every operation needs a summary. + severity: warn + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy + operation-id-camel-case: + description: operationId is written in camelCase. + recommended: false + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'].operationId + then: + function: casing + functionOptions: + type: camel +``` +
    + +A project extends it. It switches on the rule the base leaves off, adds a rule of its own through an alias, scopes its own rules to OpenAPI 3, and raises one severity for the documents under `legacy/`: + +
    +```yaml +extends: ./acme-base.yaml +formats: [oas3] +aliases: + Operation: + - "$.paths[*]['get','put','post','delete']" +rules: + operation-id-camel-case: true + operation-has-tags: + description: Every operation is tagged. + severity: hint + given: "#Operation" + then: + field: tags + function: defined +overrides: + - files: ["legacy/**"] + rules: + operation-summary: error +``` +
    + +The document being linted is `legacy/petstore.yaml`. Its `get` operation has no summary, no tags, and a snake_case `operationId`; its `post` operation has all three: + +
    +```yaml +openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets: + get: + operationId: list_pets + responses: + "200": + description: OK + post: + summary: Add a pet + operationId: addPet + tags: [pets] + responses: + "201": + description: Created +``` +
    + +The scenario is also the conformance test /conf/linter/walkthrough, which checks the outcome this part describes. + +## Loading a ruleset ## {#loading} + +Loading turns the ruleset file into the set of rules that will run. A linter first checks the ruleset against [[#ruleset-format]]. It then loads the rulesets it extends, recursively, so that their rules are available under their names. Overrides are applied next, per document; see [[#applying-overrides]]. + + +
    +In the [[#walkthrough|running example]], the linter loads `acme-base.yaml` and makes `operation-summary` and `operation-id-camel-case` available to the project ruleset, which adds `operation-has-tags`. The document's path, `legacy/petstore.yaml`, matches the glob `legacy/**`, so the override applies to this document and sets `operation-summary` to `error`. For a document outside `legacy/` the override would be ignored. +
    + +
    +A [=linter=] MUST reject a [=ruleset=] that violates any requirement whose [=conformance target=] is `ruleset`, or that extends, directly or through another ruleset's `extends`, a ruleset that violates one, and MUST NOT produce [=results=] from it. +
    + +
    +A [=linter=] MUST load a [=ruleset=] that satisfies every requirement whose [=conformance target=] is `ruleset`, when every ruleset it extends and every [=custom function=] it names can be loaded, unless /req/overrides/checked-when-applied has it rejected for the document being linted. +
    + +
    +A [=linter=] MUST load every [=ruleset=] identified in `extends` and make each of its [=rules=] available to the extending ruleset under its [=rule name=]. +
    + +
    +A [=linter=] MUST reject a [=ruleset=] whose `extends` identifies a ruleset it cannot load. +
    + +Issue: How a linter resolves file paths, npm module specifiers, and URLs, and what each is relative to, is implementation-defined and has to be specified. So is whether every conforming linter has to recognize the built-in identifiers, which name rulesets shipped with OpenLint; discussion #6 uses `openlint:openapi` as the name of a new core OpenAPI ruleset. Resolving an npm module specifier also ties `linter-core` to the JavaScript package ecosystem, so it could move to a class of its own, as custom functions have. + +Issue: In OpenLint, a cycle through `extends` between ruleset files is neither detected nor reported: the loader follows it until the process runs out of memory, so the ruleset never loads. Its core skips a definition it is already loading, but only for definitions held in memory. A conforming linter has to reject cycles, or the ruleset has to be invalid; which is open. + +
    +A [=linter=] MUST NOT let an [=extension property=] change which [=results=] it produces. +
    + +## Applying overrides ## {#applying-overrides} + +An [=override=] changes the ruleset for the documents its `files` globs match, so a linter applies overrides per document, after loading and before working out the rule state. A [=file-scoped override=] can add rulesets, rules, aliases, and formats; a [=pointer-scoped override=] only changes severities at the locations its pointers name. + +
    +A [=linter=] MUST apply an [=override=] to a [=document=] only when the document's path, relative to the directory of the ruleset file and with `/` as the separator, matches one of the override's `files` globs. +
    + +
    +When several [=overrides=] match a [=document=], a [=linter=] MUST apply them in array order, each on top of the previous, and all of them on top of the ruleset itself. +
    + +
    +For a [=document=] a file-scoped [=override=] applies to, a [=linter=] MUST load the rulesets the override's `extends` identifies and make their rules available in addition to those of the rulesets the ruleset extends, or, when the override has no `rules`, in place of them. +
    + +Issue: An override with `extends` and no `rules` drops, for the documents it matches, every rule the ruleset inherits through its own `extends`, while its own `rules` stay. With `rules`, it adds to them. Adding in both cases would make the two forms consistent. + +
    +For a [=document=] a file-scoped [=override=] applies to, a [=linter=] MUST resolve the [=alias references=] of each rule the override defines and the ruleset does not against the override's `aliases` and the ruleset's, the override's alias taking precedence for a name both declare, and MUST resolve the alias references of every other rule as if the override declared no aliases. +
    + +Issue: An alias declared in an override does not reach the rules the ruleset defines, so overriding an alias by name changes nothing for them. The previous document said an override's aliases merge by name with the ruleset's. Applying them to every rule, for the documents the override matches, would make that true. + +
    +For a [=document=] a [=file-scoped override=] applies to, a [=linter=] MUST reject the [=ruleset=] when the override has a property the Ruleset Object does not have. +
    + +
    +For a [=document=] a [=file-scoped override=] applies to, a [=linter=] MUST reject the [=ruleset=] when a [=shorthand=] in the override's `rules` names a rule the ruleset neither defines nor inherits. +
    + +
    +For a [=document=] a [=file-scoped override=] applies to, a [=linter=] MUST reject the [=ruleset=] when a rule the override redefines has an [=alias reference=] to an alias that the ruleset defining the rule does not declare. +
    + +
    +A [=linter=] MUST NOT reject a [=ruleset=] for the reasons in the three requirements above when the override applies to no [=document=] being linted. +
    + +Issue: These three checks run only when the override applies to a document, as they do in OpenLint. Whether a Rule Object in an override's `rules` merges with the rule it redefines, as /req/rules/redefinition-merge says for `extends`, or replaces it, is not tested. A ruleset with such an error loads without error until the first document the override matches. Everything else in an override is checked when the ruleset loads. Checking these three at load time too would report the error before any document matches. + +
    +For a pointer-scoped [=override=], a [=linter=] MUST apply the severities only to [=results=] whose path is at or below the location the pointer names. +
    + +## Rule state: enabled and severity ## {#rule-state} + +Every [=rule=] in the loaded ruleset ends up with two things: whether it is [=enabled=], and its [=effective severity=]. Four mechanisms set them, in this order: the rule's own definition, the mode its ruleset was extended with, the shorthands of each ruleset in the chain, and the overrides that match the document. + +
    +For the [[#walkthrough|running example]] and the legacy document, each rule ends up as follows. The project extends the base with a bare string, so the mode is `recommended`. + + + + + + + + +
    RuleDefined asAfter the modeAfter the shorthandAfter the overrideEnabledEffective severity
    `operation-summary``warn`, recommendedenabled`error`yes`error`
    `operation-id-camel-case``warn` by default, not recommendednot enabled`true`: enabled, severity keptyes`warn`
    `operation-has-tags``hint`, own ruleyes`hint`
    +
    + +
    +A [=linter=] MUST treat each numeric severity as equivalent to the name it is paired with in the table in [[#rule-severity]]. +
    + +
    +When neither a [=rule=] nor any definition it redefines has a `severity`, a [=linter=] MUST use `warn`. +
    + +
    +A [=linter=] MUST [=enable=] every [=rule=] a [=ruleset=] defines in its own `rules` whose `recommended` is `true` or absent, and MUST NOT enable one whose `recommended` is `false` unless a [=shorthand=] in an extending ruleset enables it. +
    + + + +
    +When a [=ruleset=] is extended with the mode `all`, a [=linter=] MUST [=enable=] every [=rule=] inherited from it. +
    + +
    +When a [=ruleset=] is extended with the mode `off`, a [=linter=] MUST make every [=rule=] inherited from it available and MUST NOT [=enable=] any of them. +
    + +
    +A [=linter=] MUST apply definitions and adjustments of a [=rule=] in this order: the rulesets identified by `extends`, in array order, then the extending [=ruleset=]'s own `rules`. +
    + +
    +When a Rule Object redefines a [=rule=] defined earlier in that order, a [=linter=] MUST replace each property the Rule Object has and MUST keep every property it does not have. +
    + +Issue: A redefinition keeps every property it does not set, so the base's `message` or `severity` survives a redefinition that changes `given` and `then`, and can stop matching what the rule now checks. Replacing the whole rule would make a redefinition independent of the rule it replaces. + +
    +For a shorthand that is a severity name, a [=linter=] MUST set the rule's [=effective severity=] to it and MUST [=enable=] the rule, or not enable it when the name is `off`. +
    + +
    +For a shorthand that is `false`, a [=linter=] MUST NOT [=enable=] the rule and MUST leave its [=effective severity=] unchanged. +
    + +
    +For a shorthand that is `true`, a [=linter=] MUST [=enable=] the rule unless its [=effective severity=] is `off`, and MUST leave the effective severity unchanged, including a severity set by an earlier ruleset in the chain. +
    + +
    +A [=linter=] MUST NOT evaluate a [=rule=] that is not [=enabled=], whether its [=effective severity=] is `off`, a [=shorthand=] is `false`, or its `recommended` is `false` and nothing enables it, and MUST NOT produce [=results=] for it. +
    + +## Selecting documents ## {#document-selection} + +Not every rule applies to every document. A linter detects the formats of the document and keeps the rules that have no `formats` or whose `formats` include one of them, so that one ruleset can hold rules for several kinds of document. [[#formats]] says what a format is and which identifiers exist. + +
    +The [[#walkthrough|example document]] starts with `openapi: 3.1.0`, so it is an OpenAPI 3.1 description. The only identifier the rulesets name is `oas3`, which the project ruleset sets with `formats: [oas3]` and its own rule `operation-has-tags` inherits, so the document's formats are `oas3`. The base rules have no formats. All three rules apply. Linting an AsyncAPI document with the same ruleset produces one `unrecognized-format` warning, skips `operation-has-tags`, and still runs the two base rules. +
    + +
    +A [=linter=] MUST detect a [=format=] for a [=document=] when the document's root is an object and the condition the table in [[#formats]] gives for the identifier holds, and MUST NOT detect it otherwise, among the identifiers /req/formats/detection-scope lets it detect. +
    + +
    +A [=linter=] MUST detect a [=document=]'s formats only among the identifiers named in the `formats` of the [=ruleset=], of its [=rules=], and of the rulesets it extends and their rules. An identifier named only by a target of a scoped [=alias=] is never detected. +
    + +Issue: Because detection is limited to the identifiers a ruleset names, a scoped alias whose target names a format no rule names never uses that target, even for a document of that format. Detecting every identifier in the table would make scoped aliases independent of the rules that use them. + +Issue: The conditions in the table are the ones OpenLint applies. They read the string form of a version value, so `openapi: 3.0` written as a YAML number is OpenAPI 3 but not 3.0, and they accept `openapi: 3abc`. Conditions on the typed value would be stricter. Which document types the format supports is discussed in discussion #5. + +
    +A [=linter=] MUST evaluate a [=rule=] that has `formats` only against [=documents=] that have at least one of those formats. +
    + +
    +A [=linter=] MUST treat a [=rule=] without `formats`, defined in a [=ruleset=] with `formats`, as having the formats of the ruleset that defines it, also when another ruleset extends it. +
    + +
    +When both a [=rule=] and its [=ruleset=] have `formats`, a [=linter=] MUST use the rule's and ignore the ruleset's for that rule. +
    + +
    +A [=linter=] MUST evaluate a [=rule=] that has no `formats`, in a [=ruleset=] that has none, against every [=document=], whatever its formats. +
    + +
    +When a [=ruleset=] has `formats`, directly or through its rules, and a [=document=] has none of them, a [=linter=] MUST produce one [=result=] with the code `unrecognized-format` and the severity `warn`, and MUST still run every rule that has no formats of its own and inherits none from the ruleset. +
    + +## Selecting nodes ## {#node-selection} + +For each rule that applies, the linter evaluates every path expression in `given` against the document and collects the nodes they select, the [=matched nodes=]. An alias reference is expanded first. A rule looks through `$ref` as if the referenced content were inline, unless it sets `resolved: false`. + +
    +In the [[#walkthrough|running example]], `operation-has-tags` has `given: "#Operation"`. The alias expands to `$.paths[*]['get','put','post','delete']`, which matches two nodes in the document: the `get` and the `post` operation under `/pets`. `operation-summary` matches the same two nodes through `$.paths[*]['get','put','post','delete','options','head','patch','trace']`, and `operation-id-camel-case` matches the two `operationId` strings. +
    + +
    +For every [=rule=] that is [=enabled=] for a [=document=], a [=linter=] MUST evaluate each [=path expression=] in `given` against the document and apply the rule's `then` to every [=matched node=]. +
    + +
    +A [=linter=] MUST NOT produce a [=result=] for a [=rule=] whose `given` matches no node in the [=document=]. +
    + +
    +A [=linter=] MUST evaluate `given` against the [=resolved document=], unless the rule sets `resolved` to `false`, in which case it MUST evaluate `given` against the document as written. +
    + +
    +A [=linter=] MUST evaluate a JSONPath expression as JSONPath-Plus 10.4.0 [[!JSONPATH-PLUS]] evaluates it, including its filter expressions in parentheses, such as `$..parameters[?(@.in === "header")]`. +
    + +Issue: JSONPath-Plus has no specification of its own, and its filter expressions are JavaScript. OpenLint evaluates most expressions with Nimma and falls back to JSONPath-Plus for the rest; a filter in the syntax of JSONPath [[RFC9535]], `?@.in == 'header'`, matches nothing in it. Making RFC 9535 normative would give the dialect a specification, at the cost of the rulesets that use JavaScript filters. + +
    +A [=linter=] MUST apply a rule's `then` to a node once, even when several of the rule's [=path expressions=] match it. +
    + +
    +A [=linter=] MUST evaluate an [=alias reference=] as the set of the named [=alias=]'s [=path expressions=], each with the remainder of the reference after the alias name appended to it. +
    + +
    +With `aliases: { Info: ["$..info"] }`, the alias reference `#Info.description` is evaluated as `$..info.description`. +
    + +
    +When an [=alias=]'s [=path expressions=] contain [=alias references=], a [=linter=] MUST expand those references in turn, until no reference remains. +
    + +
    +For a scoped [=alias=], a [=linter=] MUST use the `given` of the last target whose `formats` include a format of the [=document=], and MUST match no node with it when no target does. +
    + +Note: Aliases are resolved against the ruleset that defines the rule, not the ruleset that extends it. A rule inherited through `extends` keeps the aliases of the ruleset it came from. + +Issue: Only the last matching target counts, so a document with two formats, such as `oas3` and `oas3_1`, uses one target even when two match. Using every matching target, or the first, would read more naturally as a list. + +## Applying functions ## {#applying-functions} + +A [=function=] is applied to one value: the [=matched node=], or each value `field` selects in it, or each of its keys for `@key`. It receives that value and the invocation's `functionOptions`, and it returns the [=problems=] it found, each with a message. Every problem becomes a [=result=] of the rule. A function that returns no problems passes. [[#core-functions]] specifies each core function by what it asserts. A [=custom function=] has only this contract; see [[#custom-functions]]. + +
    +For the `get` operation of the [[#walkthrough|running example]], `operation-summary` applies `truthy` to the `summary` property, which is absent, so the function returns one problem. `operation-has-tags` applies `defined` to `tags`, which is also absent, and gets one problem. `operation-id-camel-case` applies `casing` with `type: camel` to `list_pets`, which is not in camel case, and gets one problem. For the `post` operation all three functions pass. The three problems become three results. +
    + +
    +For each [=matched node=], a [=linter=] MUST apply every function invocation in `then`, and MUST report each problem a function returns as a [=result=]. +
    + +
    +When an invocation has `field`, a [=linter=] MUST apply the function to each value `field` selects in the [=matched node=]: the property it names, the value at the path of property names and indices it names, or every value its JSONPath expression selects when it starts with `$`. When `field` selects nothing, the linter MUST apply the function once, to no value, and a [=result=] MUST have the path and range of the [=matched node=]. A [=result=] for a selected value MUST have the path of that value. +
    + +
    +When `field` is `@key`, a [=linter=] MUST apply the function once to each property key of the [=matched node=], and a [=result=] for a key MUST have the path of that property. +
    + +Note: `@key` goes with a `given` that selects the parent. With `given: $.paths` and `field: "@key"`, the function checks every path. With `given: $.paths[*]`, it would check the keys inside each path item. + +
    +A [=linter=] MUST pass the invocation's `functionOptions` to the [=function=]. +
    + +## Results ## {#results} + +A [=linter=] reports what it found as [=results=]. This section defines the result model that discussion #13 asked for. + + + + + + + + + + + + +
    FieldTypeDescription
    `code`stringThe [=rule name=], or one of the linter's own codes below.
    `path`array of stringsThe keys and indices from the document root to the value the result is about. An index is written as a string, such as `"0"`. An empty array stands for the document as a whole, as for `unrecognized-format`.
    `severity`integerThe numeric [=effective severity=]: `0` to `3`.
    `message`stringThe message, after placeholder substitution.
    `range`object`start` and `end`, each with a zero-based `line` and `character`, locating the value in the source text. Characters are counted in UTF-16 code units, and `end` is just after the value.
    `source`stringThe location of the document, when it has one: a file path or URL.
    `documentationUrl`stringWhere to read about the rule; see /rec/result/documentation-url.
    + +
    +Every [=result=] a [=linter=] produces MUST have `code`, `path`, `severity`, `message`, and `range`. +
    + +
    +The `code` of a [=result=] a [=rule=] produces MUST be its [=rule name=]. The `code` of a result the linter produces itself MUST be one of those in the table below. +
    + + + + + + + + +
    CodeReported for
    `parser`A problem in the document's syntax; see [[#parsing]].
    `unrecognized-format`A document that has none of the ruleset's formats; see [[#document-selection]].
    `invalid-ref`A `$ref` that cannot be resolved; see [[#resolution]].
    + +
    +The `severity` of a [=result=] MUST be the numeric form of the rule's [=effective severity=]. +
    + +
    +The `range` of a [=result=] MUST locate the value the result is about in the source text of the [=document=]: `start` at its first character and `end` just after its last, each with a zero-based `line` and a zero-based `character` counted in UTF-16 code units. +
    + +
    +When the [=document=] was read from a file path or URL, every [=result=] for it MUST carry that location in `source`, as an absolute path or the URL. +
    + +
    +A [=linter=] MUST list [=results=] in the order of the documents they belong to, as the documents were given, and within a document by the `line` and then the `character` of `range.start`, and then by `code`. +
    + +
    +When a [=rule=] ran against the [=resolved document=] and the value a [=result=] is about was reached through a reference, the result MUST carry the `path`, `range`, and `source` of the value where it is written, and a value reached through several references MUST produce one result. +
    + +
    +When the [=rule=] that produced a [=result=] has a `documentationUrl`, a [=linter=] SHOULD carry it in the result's `documentationUrl`, and when the rule has none but the [=ruleset=] that defines it has one, that URL followed by `#` and the [=rule name=]. +
    + +Issue: Indices in `path` are strings, `["names", "1"]`, as OpenLint reports them. Numbers would tell an index apart from a key that happens to be numeric. + +### The message ### {#message} + +`message` takes five placeholders: + + + + + + + + + + +
    PlaceholderResolves to
    `{{error}}`The message the function produced.
    `{{description}}`The rule's `description`.
    `{{path}}`The full path to the offending value.
    `{{property}}`The key or index of the offending value.
    `{{value}}`The offending value itself.
    + +
    +When a [=rule=] has `message`, the `message` of every [=result=] it produces MUST be the rule's `message` with each placeholder substituted. +
    + +
    +A [=linter=] MUST substitute `{{error}}` with the message the [=function=] produced, `{{description}}` with the rule's `description`, `{{path}}` with the path to the offending value as a JSON Pointer [[!RFC6901]] with a leading `#`, `{{property}}` with the last key or index of that path, and `{{value}}` with the offending value. +
    + +Issue: `{{value}}` substitutes a string as it is and an object as `[object Object]`, which no ruleset wants. A JSON serialization of the value would be useful, and is a change in output. + +
    +When a [=rule=] has no `message`, the `message` of every [=result=] it produces MUST be the rule's `description`, or the message the [=function=] produced when the rule has no `description`. +
    + +
    +```yaml +message: "{{description}}: `{{property}}` was {{value}}" +``` +
    + +
    +The results of the [[#walkthrough|running example]] in JSON, with `source` shortened. Severities are the effective ones from [[#rule-state]], and every result points into the `get` operation. + +```json +[ + { + "code": "operation-has-tags", + "path": ["paths", "/pets", "get"], + "message": "Every operation is tagged.", + "severity": 3, + "range": { + "start": { "line": 6, "character": 8 }, + "end": { "line": 10, "character": 25 } + }, + "source": "legacy/petstore.yaml" + }, + { + "code": "operation-summary", + "path": ["paths", "/pets", "get"], + "message": "Every operation needs a summary.", + "severity": 0, + "range": { + "start": { "line": 6, "character": 8 }, + "end": { "line": 10, "character": 25 } + }, + "source": "legacy/petstore.yaml" + }, + { + "code": "operation-id-camel-case", + "path": ["paths", "/pets", "get", "operationId"], + "message": "operationId is written in camelCase.", + "severity": 1, + "range": { + "start": { "line": 7, "character": 19 }, + "end": { "line": 7, "character": 28 } + }, + "source": "legacy/petstore.yaml" + } +] +``` + +
    + +# Functions # {#functions} + +A rule names a function in `then`. It is either a [=core function=], which every conforming [=linter=] provides, or a [=custom function=], which a ruleset supplies as code. [[#applying-functions]] defines how a function is applied. + +## Core functions ## {#core-functions} + +A conforming [=linter=] provides these functions under these names. Each function has a purpose, the values it applies to, its options, and one requirement that states what it asserts. The options tables are normative through /req/then/function-options-valid. + +
    +A core function MUST pass when the value it is applied to is absent or of a type the function does not apply to, unless its requirement below says otherwise. +
    + +### `truthy` ### {#fn-truthy} + +The value is present and is not `false`, `""`, `0`, or `null`. Use it for required fields such as a summary or a contact. + +It applies to any value, and to an absent one. + +This function takes no options. + +
    +`truthy` MUST fail when the value is absent, `false`, `""`, `0`, or `null`, and MUST pass otherwise, including for an empty array and an empty object. +
    + +
    +```yaml +operation-summary: + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: summary + function: truthy +``` +
    + +### `falsy` ### {#fn-falsy} + +The value is absent, `false`, `""`, `0`, or `null`, so `falsy` passes where `truthy` fails. Use it to forbid a field, or to require that a flag is off. + +It applies to any value, and to an absent one. + +This function takes no options. + +
    +`falsy` MUST fail when the value is present and not one of `false`, `""`, `0`, or `null`, and MUST pass otherwise. +
    + +Note: `truthy` and `falsy` follow ECMAScript truthiness [[ECMA-262-2025]]. Their requirements list the values in full because languages differ on whether an empty array or object is truthy. + +
    +```yaml +no-deprecated-operations: + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + field: deprecated + function: falsy +``` +
    + +### `defined` ### {#fn-defined} + +The value is present, whatever it is. Unlike `truthy`, an empty string or `false` passes. + +It applies to any value, and to an absent one. + +This function takes no options. + +
    +`defined` MUST fail when the value is absent and MUST pass when it is present, whatever it is. +
    + +
    +```yaml +servers-declared: + given: $ + then: + field: servers + function: defined +``` +
    + +### `undefined` ### {#fn-undefined} + +The value is absent. Use it to forbid a property. + +It applies to any value, and to an absent one. + +This function takes no options. + +
    +`undefined` MUST fail when the value is present and MUST pass when it is absent. +
    + +
    +```yaml +no-host: + given: $ + then: + field: host + function: undefined +``` +
    + +### `pattern` ### {#fn-pattern} + +The value is a string that matches, or does not match, a regular expression. + +It applies to strings. + +
    OptionTypeRequiredDefaultMeaning
    `match`stringone of the twoA regular expression the value has to match.
    `notMatch`stringone of the twoA regular expression; the value fails when it matches it.
    + +A regular expression is written bare, `^[a-z-]+$`, or between slashes with flags, `/^[a-z-]+$/i`, with the flags of ECMAScript. + +
    +`pattern` MUST fail when the option `match` is given and the value does not match it, or when the option `notMatch` is given and the value matches it. Both options are regular expressions as defined by ECMAScript [[!ECMA-262-2025]]. +
    + +Issue: `pattern` takes the regular expressions and flags of ECMAScript, so a linter written in another language has to reproduce ECMAScript's semantics. I-Regexp [[RFC9485]] is a dialect meant to behave the same in every language, but it has no lookaround or backreferences, so rulesets that use them would break. Which dialect a later version adopts is open. + +
    +```yaml +paths-kebab-case: + given: $.paths + then: + field: "@key" + function: pattern + functionOptions: + match: "^/[a-z-]+(/[a-z-{}]+)*$" +``` +
    + +### `casing` ### {#fn-casing} + +The value is a string written in a named case convention. + +It applies to non-empty strings. + +
    OptionTypeRequiredDefaultMeaning
    `type`stringyesOne of `flat`, `camel`, `pascal`, `kebab`, `cobol`, `snake`, `macro`.
    `disallowDigits`boolean`false`When `true`, digits are not allowed anywhere in the value.
    `separator`object`char`, a single character that separates groups of words and is required in the object, and `allowLeading`, whether the value can start with it.
    + +
    +`casing` MUST fail when the value is a non-empty string that is not written in the case named by the option `type`: one of `flat`, `camel`, `pascal`, `kebab`, `cobol`, `snake`, and `macro`, where only ASCII letters and digits count as written in a case. +
    + +Issue: The exact character classes of each case have to be stated before this requirement is complete. + +
    +```yaml +operation-id-camel-case: + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'].operationId + then: + function: casing + functionOptions: + type: camel +``` +
    + +### `length` ### {#fn-length} + +The size of the value is within bounds: the number of characters of a string, of elements of an array, of properties of an object, or the value of a number. + +It applies to strings, arrays, objects, and numbers. + +
    OptionTypeRequiredDefaultMeaning
    `min`numberone of the twoThe smallest length that passes.
    `max`numberone of the twoThe largest length that passes.
    + +
    +`length` MUST fail when the length of the value is below the option `min` or above the option `max`, where the length of a string is its number of UTF-16 code units, of an array its number of elements, of an object its number of properties, and of a number the number itself. +
    + +
    +```yaml +short-summary: + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'].summary + then: + function: length + functionOptions: + max: 120 +``` +
    + +### `alphabetical` ### {#fn-alphabetical} + +The elements of an array, or the keys of an object, are in ascending order. + +It applies to arrays and objects. + +
    OptionTypeRequiredDefaultMeaning
    `keyedBy`stringFor an array of objects, the property to compare the elements by.
    + +
    +`alphabetical` MUST fail when the elements of an array, or the keys of an object, are not in ascending order, where numbers are compared by value, strings as `localeCompare` of ECMAScript [[!ECMA-402]] compares them with no locale given, and two values of different types count as in order. With the option `keyedBy`, the elements of an array of objects are compared by that property. +
    + +Issue: `localeCompare` without a locale uses the locale of the environment, so the order of strings such as `ä` and `z` can differ between machines. A fixed collation, such as code point order, would make the function portable at the cost of changing the order of mixed-case names. + +
    +```yaml +tags-alphabetical: + given: $.tags + then: + function: alphabetical + functionOptions: + keyedBy: name +``` +
    + +### `enumeration` ### {#fn-enumeration} + +The value is one of a fixed set. + +It applies to strings, numbers, booleans, and null. + +
    OptionTypeRequiredDefaultMeaning
    `values`array of string, number, boolean, or nullyesThe values that pass.
    + +
    +`enumeration` MUST fail when the value is not one of the elements of the option `values`, compared by JSON type and value, so that `"1"` is not `1` while `1.0` is. +
    + +
    +```yaml +audience-known: + given: $.info.x-audience + then: + function: enumeration + functionOptions: + values: [internal, partner, public] +``` +
    + +### `schema` ### {#fn-schema} + +The value validates against a JSON Schema. Use it when the shape to assert is more than one property deep. + +It applies to any present value, and fails on an absent one. + +
    OptionTypeRequiredDefaultMeaning
    `schema`objectyesThe JSON Schema to validate against.
    `dialect`string`auto`The draft to read the schema in: one of `draft4`, `draft6`, `draft7`, `draft2019-09`, `draft2020-12`, or `auto`, which takes it from `$schema` and falls back to `draft7`.
    `allErrors`boolean`false`Report every validation error as a problem, not only the first.
    + +The drafts `dialect` accepts, and the specification of each: + +
    DialectSpecification
    `draft4`[[!JSON-SCHEMA-DRAFT-04]]
    `draft6`[[!JSON-SCHEMA-DRAFT-06]]
    `draft7`[[!JSON-SCHEMA-DRAFT-07]]
    `draft2019-09`[[!JSON-SCHEMA-DRAFT-2019-09]]
    `draft2020-12`[[!JSON-SCHEMA-CORE-2020-12]] and [[!JSON-SCHEMA-VALIDATION-2020-12]]
    + +
    +`schema` MUST fail when the value is absent, or when it does not validate against the JSON Schema given as the option `schema`, asserting the `format` keyword and resolving a `$ref` within the option against the option itself, with one problem for the first failed keyword, or one per failed keyword when the option `allErrors` is `true`. The schema is read in the draft the option `dialect` names, as the table above defines each draft; with `dialect` absent or `auto`, in the draft its `$schema` names, and in draft 7 when it names none. +
    + +
    +```yaml +contact-shape: + given: $.info.contact + then: + function: schema + functionOptions: + schema: + type: object + required: [name, email] + properties: + email: + type: string + format: email +``` +
    + +### `xor` ### {#fn-xor} + +Exactly one of a set of properties is present. + +It applies to objects. + +
    OptionTypeRequiredDefaultMeaning
    `properties`array of at least two stringsyesThe property names, of which exactly one has to be present.
    + +
    +`xor` MUST fail unless exactly one of the properties named by the option `properties` is present on the value, where a property is present when the object has the key, whatever its value. +
    + +
    +```yaml +example-or-examples: + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'].responses[*].content[*] + then: + function: xor + functionOptions: + properties: [example, examples] +``` +
    + +### `or` ### {#fn-or} + +At least one of a set of properties is present. + +It applies to objects. + +
    OptionTypeRequiredDefaultMeaning
    `properties`array of at least two stringsyesThe property names, of which at least one has to be present.
    + +
    +`or` MUST fail unless at least one of the properties named by the option `properties` is present on the value, where a property is present when the object has the key, whatever its value. +
    + +
    +```yaml +operation-documented: + given: $.paths[*]['get','put','post','delete','options','head','patch','trace'] + then: + function: or + functionOptions: + properties: [summary, description] +``` +
    + +### `unreferencedReusableObject` ### {#fn-unreferencedreusableobject} + +Every member of a container of reusable definitions is referenced somewhere. Use it to find unused schemas, parameters, or responses. + +It applies to objects. + +
    OptionTypeRequiredDefaultMeaning
    `reusableObjectsLocation`stringyesA JSON Pointer fragment to the container, such as `#/components/schemas` or `#/definitions`.
    + +
    +`unreferencedReusableObject`, applied to an object of reusable definitions, MUST fail once for each member that no `$ref` in the documents being linted points to, where the option `reusableObjectsLocation` is the JSON Pointer [[!RFC6901]] of that object in the document. +
    + +Note: The function counts references, which no longer exist in the [=resolved document=], so the rule in the example sets `resolved: false`. Members referenced only by documents outside the lint run count as unreferenced. + +
    +```yaml +no-unused-schemas: + resolved: false + given: $.components.schemas + then: + function: unreferencedReusableObject + functionOptions: + reusableObjectsLocation: "#/components/schemas" +``` +
    + +## Custom functions ## {#custom-functions} + +A ruleset declares the [=custom function|custom functions=] it uses as described in [[#functions-declaration]]. + +Custom functions are specified in two parts. The contract, the class `/conf/linter-custom-functions`, says how a [=linter=] resolves a function a ruleset names, calls it, and reports what it returns, whatever language the function is written in. A binding says how a function is written and found in one language. This version defines one binding, for JavaScript, the class `/conf/linter-custom-functions-javascript`. A linter claims the contract together with a binding, and can decline both. + +Issue: A [=linter=] written in Go needs a Go binding, and this version defines none. vacuum, a linter written in Go, loads custom functions as Go plugins from a directory named on its command line, registered under the name each function reports, and does not read `functions` or `functionsDir`. A Go binding would have to say where a linter finds a function, which symbol it loads, and the function's signature in types this specification owns. Go plugins also load only into a binary built with the same Go version and package versions, so a plugin built for one linter does not load into another. + +Issue: A binding ties a function to one language, so a ruleset whose functions are written in that language runs only on linters that bind it. A binding to WebAssembly, or to a function that runs as a separate process, would let any linter run functions written in any language that targets it. Whether a later version defines such a binding is open. + +### The contract ### {#custom-functions-contract} + +
    +A [=linter=] MUST reject a [=ruleset=] that names a function it cannot load. +
    + +
    +A [=linter=] MUST call a [=custom function=] with the value the invocation targets and the invocation's `functionOptions`, and MUST report each problem the function returns as a [=result=] whose message is the problem's message. +
    + +
    +A [=linter=] MUST report a [=problem=] that carries a `path` at that path in the [=document=], a problem without one at the [=matched node=], and a problem whose path is not in the document at the document's root. +
    + +
    +A [=linter=] MUST treat a [=custom function=] that returns nothing as returning no [=problems=]. +
    + +Issue: A function that throws, or returns something that is not a list of problems, ends the run with no results and an error. Reporting it as a result for the rule, with the error as its message, would keep the results of the other rules. + +Note: The contract is tested through the JavaScript binding, the only binding this version defines, so the fixtures of its tests are JavaScript functions. + +### JavaScript binding ### {#custom-functions-javascript} + +
    +A [=linter=] MUST load each name in `functions` from `/.js`, resolved relative to the ruleset file, with `functions` as the directory when `functionsDir` is absent. +
    + +
    +When a JavaScript [=custom function=] returns a promise, a [=linter=] MUST wait for it and use the [=problems=] it resolves to. +
    + +A [=custom function=] is a JavaScript module whose export is the function. It receives the value and the options, and returns an array of problems, each an object with a `message`; an empty array means the value passes. + +
    +```js +// functions/forbiddenWords.js +module.exports = (value, options) => { + const problems = []; + for (const word of options.forbidden) { + if (String(value).includes(word)) { + problems.push({ message: `Contains "${word}".` }); + } + } + return problems; +}; +``` +
    + +Issue: A function module in CommonJS and one in ES module syntax both load in OpenLint, and the function receives a third argument with the document, the rule, and the path. Neither is stated normatively. + +# Security and privacy considerations # {#security} + +A [=ruleset=] is configuration, but loading one can read files, fetch URLs, and run code. Anyone who lints with a ruleset they did not write, or extends one, trusts its author with all of that. This section collects what a linter and its users have to be aware of. It states no requirements. + +Custom functions run code. A ruleset that names [=custom function|custom functions=] depends on code, and its document alone does not say what those rules check. This specification does not require a linter to isolate that code, so extending an untrusted ruleset that has custom functions runs that code with the linter's privileges, and `functionsDir` resolved relative to a remote ruleset loads code from that remote location. + +`extends` fetches rulesets. An `extends` entry that is a URL makes the linter fetch it, and an npm module specifier makes it load a package. Nothing in the format pins what is fetched, so a ruleset fetched twice can differ, and a compromised host or package changes what every document is checked against. A ruleset that is extended by file path is read from wherever the path leads. + +Path expressions and regular expressions evaluate input. A JSONPath-Plus filter expression in `given` is JavaScript evaluated by the linter, and a `pattern` option is an ECMAScript regular expression, which can take exponential time on crafted input. Both come from the ruleset, so a ruleset is as trusted as code even without custom functions. + +References read other documents. Resolving a `$ref` to an external file or URL reads it, so linting an untrusted document can make the linter read any file it has access to and fetch from any host it can reach. The `schema` function's option can carry `$ref` too. + +Results carry locations. `source` is the location of the document, which can be an absolute path or a URL, and results are often written to logs and CI output that others can read. + +

    Appendix A: Requirements index

    + +
    +path: requirements-index.include
    +
    + +

    Appendix B: Conformance tests

    + +Tests are grouped under the requirement they cover. Open a test to see its fixtures and expected outcome. + +
    +path: conformance-tests.include
    +
    + +

    Appendix C: Changes

    + +There is no release yet. From the first release on, this appendix lists what changed since the latest one. + +

    Appendix D: Using the schema

    + +This appendix shows how to use the ruleset schema while writing rulesets. The examples use `https://spec.openlint.org/v1/ruleset.schema.json`, which follows the newest 1.x release; see [[#versions]]. The format reserves no `$schema` key and rejects unknown root properties, so bind the schema from outside the document. + +For a YAML ruleset, add a modeline comment: + +```yaml +# yaml-language-server: $schema=https://spec.openlint.org/v1/ruleset.schema.json +extends: spectral:oas +``` + +For a JSON ruleset in VS Code, map the schema to your ruleset's file name in `.vscode/settings.json`: + +```json +{ + "json.schemas": [ + { + "fileMatch": ["ruleset.json"], + "url": "https://spec.openlint.org/v1/ruleset.schema.json" + } + ] +} +``` + +In CI, any JSON Schema 2020-12 validator works: + +```bash +npx ajv-cli validate --spec=draft2020 --strict=false \ + -s ruleset.schema.json \ + -d ruleset.yaml +``` + +Validating a ruleset against the schema catches misspelled keywords and invalid overrides before any document is linted. It does not catch everything. A JSON Schema validates the shape of one parsed value, so it cannot check that the file parses, that a name refers to something declared elsewhere, or that function options match the named function. The schema accepts rulesets that violate these requirements, and a linter rejects them: + +
    +path: schema-gaps.include
    +
    + +A linter also rejects, for the documents an override applies to, what /req/overrides/checked-when-applied lists. The schema checks that a `given` value begins with `$` or `#`, but does not parse it as JSONPath. A syntactically invalid JSONPath expression passes the schema and fails at lint time. + +

    Appendix E: Provenance

    + +The format described here originates in Spectral [[SPECTRAL]] by Stoplight, licensed Apache-2.0. OpenLint, the reference implementation, is openlint/openlint, forked from Spectral; the issues in the working draft describe its behavior by name. The specification and schema were first derived from Spectral's validation meta-schemas and guides. The OpenLint community drafted them in api-commons before the project had its name, and then moved them to openlint/spec. Its conformance classes follow the model of the OGC API standards. diff --git a/schema/v1/ruleset.schema.json b/v1/ruleset.schema.json similarity index 56% rename from schema/v1/ruleset.schema.json rename to v1/ruleset.schema.json index a204132..d581b3e 100644 --- a/schema/v1/ruleset.schema.json +++ b/v1/ruleset.schema.json @@ -1,52 +1,51 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://spec.openlint.org/schema/v1/ruleset.schema.json", + "$id": "https://spec.openlint.org/v1/draft/ruleset.schema.json", "title": "OpenLint Ruleset", - "description": "An OpenLint ruleset (the Spectral ruleset format): a portable, machine-readable set of rules applied to JSON or YAML documents. This schema describes the serialized (JSON/YAML) form of the format. See https://github.com/openlint/spec for the specification.", + "description": "An OpenLint ruleset: a set of rules applied to JSON or YAML documents. This schema describes the format as written in JSON or YAML. The specification is at https://spec.openlint.org/.", "type": "object", "properties": { "description": { "type": "string", - "description": "Human-readable description of what this ruleset governs." + "description": "Human-readable description of what the ruleset checks." }, "documentationUrl": { - "type": "string", - "format": "uri", - "description": "URL to documentation for this ruleset. Used as the fallback documentation link for any rule that does not declare its own." + "$ref": "#/$defs/Url", + "description": "URL to documentation for this ruleset. A rule that declares no documentationUrl of its own reports this URL followed by `#` and the rule name." }, "formats": { - "$ref": "#/$defs/formats", + "$ref": "#/$defs/Formats", "description": "Document formats this ruleset applies to. Acts as the default for every rule that does not declare its own formats. When omitted, rules apply to every document." }, "extends": { - "$ref": "#/$defs/extends", + "$ref": "#/$defs/Extends", "description": "Other rulesets this ruleset inherits from." }, "rules": { "type": "object", - "description": "Map of rule name to rule definition. A rule name is the stable identifier reported in results and used to override the rule downstream.", + "description": "Maps a rule name to a Rule Object, or to a shorthand that adjusts a rule inherited through `extends`. The name appears in results, and an extending ruleset uses it to adjust the rule.", "propertyNames": { "minLength": 1 }, "additionalProperties": { - "$ref": "#/$defs/rule" + "$ref": "#/$defs/RuleOrShorthand" } }, "aliases": { - "$ref": "#/$defs/aliases", - "description": "Named, reusable path expressions that rules may reference from `given` with a leading `#`." + "$ref": "#/$defs/Aliases", + "description": "Named, reusable path expressions that rules can reference from `given` with a leading `#`." }, "overrides": { - "$ref": "#/$defs/overrides", - "description": "Per-file and per-location adjustments applied on top of the ruleset." + "$ref": "#/$defs/Overrides", + "description": "File-scoped and pointer-scoped adjustments applied on top of the ruleset." }, "parserOptions": { - "$ref": "#/$defs/parserOptions", + "$ref": "#/$defs/ParserOptions", "description": "Severities for problems detected while parsing the document itself, before any rule runs." }, "functions": { "type": "array", - "description": "Names of custom functions to load from `functionsDir`. Serialized rulesets only; JavaScript rulesets import functions directly instead.", + "description": "Names of the custom functions the ruleset uses, loaded from `functionsDir`.", "items": { "type": "string", "minLength": 1 @@ -54,8 +53,7 @@ }, "functionsDir": { "type": "string", - "description": "Directory, relative to the ruleset file, that custom functions named in `functions` are loaded from. Defaults to `functions`.", - "minLength": 1 + "description": "Directory, relative to the ruleset file, that custom functions named in `functions` are loaded from. Defaults to `functions`." } }, "patternProperties": { @@ -80,7 +78,7 @@ } ], "$defs": { - "numericSeverity": { + "NumericSeverity": { "title": "Numeric severity", "description": "0 = error, 1 = warn, 2 = info, 3 = hint, -1 = off.", "enum": [ @@ -91,8 +89,8 @@ 3 ] }, - "humanReadableSeverity": { - "title": "Human-readable severity", + "SeverityName": { + "title": "Severity name", "enum": [ "error", "warn", @@ -101,66 +99,57 @@ "off" ] }, - "severity": { + "Severity": { "title": "Severity", "description": "How a rule's results are reported. `off` (-1) disables the rule. Defaults to `warn` when unset.", "oneOf": [ { - "$ref": "#/$defs/numericSeverity" + "$ref": "#/$defs/NumericSeverity" }, { - "$ref": "#/$defs/humanReadableSeverity" + "$ref": "#/$defs/SeverityName" } ] }, - "format": { + "Format": { "title": "Format", - "description": "Identifier of a document format. A format is a predicate that decides whether a given document is of that kind, so a rule scoped to a format only runs against matching documents. The listed values are those registered by the reference implementation; any string is permitted so that new and custom formats remain valid.", - "type": "string", - "minLength": 1, - "anyOf": [ - { - "enum": [ - "oas2", - "oas3", - "oas3_0", - "oas3_1", - "oas3_2", - "asyncapi2", - "aas2", - "aas2_0", - "aas2_1", - "aas2_2", - "aas2_3", - "aas2_4", - "aas2_5", - "aas2_6", - "aas3", - "aas3_0", - "aas3_1", - "arazzo1_0", - "jsonSchema", - "jsonSchemaLoose", - "jsonSchemaDraft4", - "jsonSchemaDraft6", - "jsonSchemaDraft7", - "jsonSchemaDraft2019_09", - "jsonSchemaDraft2020_12" - ] - }, - { - "type": "string" - } + "description": "Identifier of a document format. A format is a predicate that decides whether a given document is of that kind, so a rule scoped to a format only runs against matching documents.", + "enum": [ + "oas2", + "oas3", + "oas3_0", + "oas3_1", + "oas3_2", + "asyncapi2", + "aas2", + "aas2_0", + "aas2_1", + "aas2_2", + "aas2_3", + "aas2_4", + "aas2_5", + "aas2_6", + "aas3", + "aas3_0", + "aas3_1", + "arazzo1_0", + "jsonSchema", + "jsonSchemaLoose", + "jsonSchemaDraft4", + "jsonSchemaDraft6", + "jsonSchemaDraft7", + "jsonSchemaDraft2019_09", + "jsonSchemaDraft2020_12" ] }, - "formats": { + "Formats": { "title": "Formats", "type": "array", "items": { - "$ref": "#/$defs/format" + "$ref": "#/$defs/Format" } }, - "pathExpression": { + "PathExpression": { "title": "Path expression", "description": "Either a JSONPath expression beginning with `$`, or an alias reference beginning with `#` optionally followed by a JSONPath subset (for example `#Info.description`).", "type": "string", @@ -176,68 +165,67 @@ } ] }, - "arrayGiven": { + "PathExpressionList": { "title": "Path expression list", "type": "array", "minItems": 1, "items": { - "$ref": "#/$defs/pathExpression" + "$ref": "#/$defs/PathExpression" } }, - "given": { - "title": "Given", + "Given": { + "title": "Path expression or list", "description": "The part or parts of the document a rule is applied to. A single path expression, or an array of them.", "oneOf": [ { - "$ref": "#/$defs/pathExpression" + "$ref": "#/$defs/PathExpression" }, { - "$ref": "#/$defs/arrayGiven" + "$ref": "#/$defs/PathExpressionList" } ] }, - "functionInvocation": { + "FunctionInvocation": { "title": "Function invocation", - "description": "The assertion half of a rule: the function to run against each node matched by `given`, and the options it takes.", + "description": "What a rule asserts: the function to run against each node `given` matches, and the options it takes.", "type": "object", "properties": { "field": { "type": "string", - "description": "Narrow the assertion to a single property of the matched node, rather than the node itself. `@key` targets the node's own key." + "description": "Apply the function to part of the matched node: a property name, a path of property names and indices such as `info.contact.email` or `tags[0]`, or a JSONPath expression starting with `$`, evaluated against the matched node. `@key` applies the function to each property key of the matched node." }, "function": { "type": "string", "minLength": 1, - "description": "Name of a core function (`alphabetical`, `casing`, `defined`, `enumeration`, `falsy`, `length`, `pattern`, `schema`, `truthy`, `undefined`, `unreferencedReusableObject`, `xor`, `or`) or of a custom function declared in `functions`." + "description": "Name of a core function (`alphabetical`, `casing`, `defined`, `enumeration`, `falsy`, `length`, `or`, `pattern`, `schema`, `truthy`, `undefined`, `unreferencedReusableObject`, `xor`) or of a custom function declared in `functions`." }, "functionOptions": { "type": "object", - "description": "Options passed to the function. The accepted shape is defined by the function itself." + "description": "Options passed to the function. Each function defines the options it accepts." } }, "required": [ "function" ] }, - "then": { - "title": "Then", - "description": "One assertion, or an array of assertions all applied to the nodes matched by `given`.", + "Then": { + "title": "Function invocation or list", + "description": "One function invocation, or an array of them, each applied to every node `given` matches. An empty array asserts nothing.", "oneOf": [ { - "$ref": "#/$defs/functionInvocation" + "$ref": "#/$defs/FunctionInvocation" }, { "type": "array", - "minItems": 1, "items": { - "$ref": "#/$defs/functionInvocation" + "$ref": "#/$defs/FunctionInvocation" } } ] }, - "ruleDefinition": { - "title": "Rule definition", - "description": "A complete rule: where to look (`given`) and what must hold there (`then`).", + "Rule": { + "title": "Rule Object", + "description": "A rule selects nodes in a document with `given` and checks each selected node with `then`.", "type": "object", "properties": { "description": { @@ -245,8 +233,7 @@ "description": "Human-readable statement of what the rule requires." }, "documentationUrl": { - "type": "string", - "format": "uri", + "$ref": "#/$defs/Url", "description": "URL explaining the rule and how to satisfy it. Reported alongside results." }, "message": { @@ -254,26 +241,26 @@ "description": "Template for the reported message. Supports the placeholders `{{error}}`, `{{description}}`, `{{path}}`, `{{property}}`, and `{{value}}`." }, "severity": { - "$ref": "#/$defs/severity", + "$ref": "#/$defs/Severity", "description": "Severity of this rule's results. Defaults to `warn`." }, "formats": { - "$ref": "#/$defs/formats", + "$ref": "#/$defs/Formats", "description": "Document formats this rule applies to. Overrides the ruleset-level `formats`." }, "given": { - "$ref": "#/$defs/given" + "$ref": "#/$defs/Given" }, "then": { - "$ref": "#/$defs/then" + "$ref": "#/$defs/Then" }, "recommended": { "type": "boolean", - "description": "Whether the rule is enabled when a consumer extends this ruleset in `recommended` mode. Defaults to true." + "description": "Whether the rule is enabled when another ruleset extends this one in `recommended` mode. Defaults to true." }, "resolved": { "type": "boolean", - "description": "Whether the rule runs against the document with `$ref`s resolved (true, the default) or against the raw authored document (false). Set to false for rules about references themselves." + "description": "Whether the rule runs against the document with `$ref`s resolved (true, the default) or against the document as written (false). Set to false for rules about references themselves." }, "tags": { "type": "array", @@ -283,7 +270,7 @@ } }, "type": { - "description": "Coarse classification of the rule. `validation` rules assert correctness; `style` rules assert convention.", + "description": "The kind of rule. `validation` rules assert correctness; `style` rules assert convention.", "enum": [ "style", "validation" @@ -291,7 +278,7 @@ }, "extensions": { "type": "object", - "description": "Arbitrary consumer-defined metadata carried with the rule and ignored by the engine." + "description": "Metadata for the author's own tooling. A linter ignores it." } }, "required": [ @@ -303,34 +290,43 @@ }, "additionalProperties": false }, - "rule": { - "title": "Rule", - "description": "A full rule definition, or — when adjusting a rule inherited via `extends` — a severity string, or a boolean where false disables the rule and true restores its declared severity.", + "RuleOrShorthand": { + "title": "Rule Object or shorthand", + "description": "A Rule Object, or a shorthand that adjusts a rule inherited through `extends`.", "oneOf": [ { - "$ref": "#/$defs/ruleDefinition" + "$ref": "#/$defs/Rule" }, { - "$ref": "#/$defs/humanReadableSeverity" + "$ref": "#/$defs/Shorthand" + } + ] + }, + "Shorthand": { + "title": "Shorthand", + "description": "A severity name, or a boolean, where `false` disables the rule and `true` enables it at its current severity.", + "oneOf": [ + { + "$ref": "#/$defs/SeverityName" }, { "type": "boolean" } ] }, - "extendsEntry": { - "title": "Extends entry", - "description": "A reference to another ruleset: a built-in (`spectral:oas`, `spectral:asyncapi`, `spectral:arazzo`), a file path, an npm module, or a URL.", + "RulesetReference": { + "title": "Ruleset reference", + "description": "A reference to another ruleset: a built-in identifier (`spectral:oas`, `spectral:asyncapi`, `spectral:arazzo`), a file path, an npm module specifier, or a URL.", "type": "string", "minLength": 1 }, - "extendsTuple": { - "title": "Scoped extends entry", - "description": "A ruleset reference paired with the set of its rules to enable: `recommended` (the default), `all`, or `off`.", + "RulesetReferenceWithMode": { + "title": "Ruleset reference with mode", + "description": "A ruleset reference and a mode, which decides which of the inherited rules start out enabled: `recommended` (the default), `all`, or `off`. Elements after the mode are ignored.", "type": "array", "prefixItems": [ { - "$ref": "#/$defs/extendsEntry" + "$ref": "#/$defs/RulesetReference" }, { "enum": [ @@ -340,40 +336,41 @@ ] } ], - "minItems": 2, - "items": false + "minItems": 2 }, - "extends": { + "Extends": { "title": "Extends", "oneOf": [ { - "$ref": "#/$defs/extendsEntry" + "$ref": "#/$defs/RulesetReference" }, { "type": "array", "items": { "oneOf": [ { - "$ref": "#/$defs/extendsEntry" + "$ref": "#/$defs/RulesetReference" }, { - "$ref": "#/$defs/extendsTuple" + "$ref": "#/$defs/RulesetReferenceWithMode" } ] } } ] }, - "aliasTarget": { + "AliasTarget": { "title": "Alias target", "description": "The path expressions an alias resolves to for one set of formats.", "type": "object", "properties": { "formats": { - "$ref": "#/$defs/formats" + "$ref": "#/$defs/Formats", + "description": "The formats this target applies to." }, "given": { - "$ref": "#/$defs/arrayGiven" + "$ref": "#/$defs/PathExpressionList", + "description": "The path expressions the alias resolves to for documents of those formats." } }, "required": [ @@ -381,7 +378,7 @@ "given" ] }, - "scopedAlias": { + "ScopedAlias": { "title": "Scoped alias", "description": "An alias that resolves to different path expressions depending on the format of the document being linted.", "type": "object", @@ -394,64 +391,65 @@ "type": "array", "minItems": 1, "items": { - "$ref": "#/$defs/aliasTarget" - } + "$ref": "#/$defs/AliasTarget" + }, + "description": "One set of path expressions per set of formats." } }, "required": [ "targets" ] }, - "alias": { + "Alias": { "title": "Alias", "description": "Either a list of path expressions, or a scoped alias that varies by format.", "if": { "type": "object" }, "then": { - "$ref": "#/$defs/scopedAlias" + "$ref": "#/$defs/ScopedAlias" }, "else": { - "$ref": "#/$defs/arrayGiven" + "$ref": "#/$defs/PathExpressionList" } }, - "aliases": { + "Aliases": { "title": "Aliases", "type": "object", "propertyNames": { "pattern": "^[A-Za-z][A-Za-z0-9_-]*$" }, "additionalProperties": { - "$ref": "#/$defs/alias" + "$ref": "#/$defs/Alias" } }, - "parserOptions": { + "ParserOptions": { "title": "Parser options", "type": "object", "properties": { "duplicateKeys": { - "$ref": "#/$defs/severity", + "$ref": "#/$defs/Severity", "description": "Severity for duplicate keys found in the document. Defaults to `error`." }, "incompatibleValues": { - "$ref": "#/$defs/severity", - "description": "Severity for YAML values that cannot be represented in JSON. Defaults to `error`." + "$ref": "#/$defs/Severity", + "description": "Severity for a YAML mapping key that is not a string scalar. Defaults to `error`." } }, "additionalProperties": false }, - "overrides": { + "Overrides": { "title": "Overrides", "description": "Ordered list of adjustments. Later matching entries win over earlier ones, and every override wins over the base ruleset.", "type": "array", "minItems": 1, "items": { - "$ref": "#/$defs/override" + "$ref": "#/$defs/Override" } }, - "override": { + "Override": { "title": "Override", - "description": "An adjustment scoped by `files`. When every glob in `files` carries a JSON Pointer fragment (`path/to/file.yaml#/paths/~1users`), the override may only change rule severities. Otherwise it may change anything a ruleset can declare.", + "description": "An adjustment scoped by `files`. A pointer-scoped override, where every glob in `files` carries a JSON Pointer fragment (`path/to/file.yaml#/paths/~1users`), can only change rule severities. A file-scoped override can change anything a ruleset can declare.", "type": "object", "required": [ "files" @@ -481,7 +479,7 @@ "rules": { "type": "object", "additionalProperties": { - "$ref": "#/$defs/severity" + "$ref": "#/$defs/Severity" } } }, @@ -515,22 +513,22 @@ "properties": { "files": true, "formats": { - "$ref": "#/$defs/formats" + "$ref": "#/$defs/Formats" }, "extends": { - "$ref": "#/$defs/extends" + "$ref": "#/$defs/Extends" }, "rules": { "type": "object", "additionalProperties": { - "$ref": "#/$defs/rule" + "$ref": "#/$defs/RuleOrShorthand" } }, "parserOptions": { - "$ref": "#/$defs/parserOptions" + "$ref": "#/$defs/ParserOptions" }, "aliases": { - "$ref": "#/$defs/aliases" + "$ref": "#/$defs/Aliases" } }, "anyOf": [ @@ -548,6 +546,12 @@ } ] } + }, + "Url": { + "title": "URL", + "description": "An absolute http, https, or ftp URL whose host is a domain name with a top-level domain of at least two letters, or a public IPv4 address. Host names such as `localhost`, single-label intranet hosts, and private or loopback addresses do not match.", + "type": "string", + "pattern": "^(?:[Hh][Tt][Tt][Pp][Ss]?|[Ff][Tt][Pp])://(?:\\S+(?::\\S*)?@)?(?:(?!(?:10|127)(?:\\.\\d{1,3}){3})(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))|(?:(?:[a-zA-Z0-9\\u00a1-\\uffff]+-)*[a-zA-Z0-9\\u00a1-\\uffff]+)(?:\\.(?:[a-zA-Z0-9\\u00a1-\\uffff]+-)*[a-zA-Z0-9\\u00a1-\\uffff]+)*(?:\\.(?:[a-zA-Z\\u00a1-\\uffff]{2,})))(?::\\d{2,5})?(?:/[^\\s]*)?$" } } }