Spec Linting & API Governance with Spectral

API governance fails quietly: one team ships an endpoint without a description, another names a path /getUsers instead of /users, and six months later your generated SDKs and rendered docs are an inconsistent mess that no style guide can retroactively fix. This guide, part of OpenAPI & AsyncAPI Schema Authoring, shows how to enforce a machine-readable style guide on every commit with Spectral, the open-source linter from Stoplight, using the @stoplight/spectral-cli v6.x package. It covers the .spectral.yaml ruleset format, the built-in spectral:oas and spectral:asyncapi rulesets, writing custom rules with given/then/JSONPath, wiring the linter into CI and pre-commit, custom functions, and troubleshooting. It does not cover spec authoring itself — for that, see the sibling guides on structuring OpenAPI paths and defining JSON Schema components.

The diagram below shows where the governance gate sits: a contributor opens a pull request, Spectral lints the changed spec against your ruleset, and the result either blocks the merge or lets the spec flow downstream into docs and SDK generation.

Spectral governance gate in CI A pull request runs Spectral against a ruleset; errors block the merge, a pass flows to docs and SDK generation. Pull request openapi.yaml diff spectral lint .spectral.yaml error severity exit 1 — merge blocked clean / warn only exit 0 — merge allowed docs + SDK gen

Prerequisites & Environment Setup

Spectral v6.x runs on Node.js 16 or later; Node 20 LTS is the safe target. Install the CLI as a dev dependency so the version is pinned in your lockfile rather than relying on a globally installed binary that drifts between machines:

# package.json devDependency — pinned, reproducible in CI
npm install --save-dev @stoplight/[email protected]

Verify the install and check the version. The npx form resolves the locally pinned binary:

npx spectral --version
# 6.11.1

Spectral reads rulesets written in YAML or JSON, and (since v6) JavaScript modules for advanced cases. The default ruleset filename it auto-discovers in the working directory is .spectral.yaml, .spectral.yml, .spectral.json, or .spectral.js. Keep your spec and ruleset under version control together so governance rules evolve alongside the API.

If you author multi-file specs with external $ref pointers, also install the Redocly CLI to bundle them when a rule needs the resolved document:

npm install --save-dev @redocly/[email protected]

Core Configuration

The heart of Spectral is the ruleset file. A minimal .spectral.yaml extends a built-in ruleset and adds your own rules. Spectral ships two governance-grade rulesets out of the box:

  • spectral:oas — rules for OpenAPI 2.0, 3.0.x, and 3.1.0 (operation IDs, descriptions, valid schemas, unused components).
  • spectral:asyncapi — rules for AsyncAPI 2.x event-driven documents (channel naming, message payloads, server definitions).

Extend both in one ruleset; Spectral applies the correct set per document by detecting the format. The annotated file below is a complete, copy-paste-ready starting point:

# .spectral.yaml — organization API style guide
extends:
  - "spectral:oas"          # OpenAPI 2.0/3.0/3.1 built-in rules
  - "spectral:asyncapi"     # AsyncAPI 2.x built-in rules

# rules override or extend whatever 'extends' brought in
rules:
  # --- tune a built-in rule's severity ---
  operation-tag-defined: warn   # downgrade from its default 'warn'/'error'

  # --- turn a noisy built-in rule off entirely ---
  oas3-unused-component: off

  # --- custom rule: every operation needs a summary ---
  operation-summary-required:
    description: "Operations must declare a human-readable summary."
    message: "{{path}} is missing a summary."   # {{path}} interpolates the JSONPath hit
    severity: error
    given: "$.paths[*][get,put,post,delete,patch,options,head]"
    then:
      field: summary
      function: truthy        # value must be present and non-empty

  # --- custom rule: path segments must be kebab-case, no verbs ---
  path-kebab-case:
    description: "Path segments use kebab-case nouns, not camelCase or verbs."
    severity: error
    given: "$.paths"
    then:
      field: "@key"           # @key targets the object key (the path string)
      function: pattern
      functionOptions:
        match: "^(\\/[a-z0-9-{}]+)+$"   # only lowercase, digits, dashes, braces

  # --- custom rule: schema property names must be camelCase ---
  schema-property-camel-case:
    description: "Schema property names must be camelCase."
    severity: warn
    given: "$.components.schemas[*].properties"
    then:
      field: "@key"
      function: casing
      functionOptions:
        type: camel

Three pieces drive every custom rule. given is a JSONPath expression selecting the nodes the rule applies to. then declares what must hold for each selected node — optionally narrowed to a field (or @key for object keys) and validated by a function. severity is one of error, warn, info, or hint; only results at or above --fail-severity (default error) make the process exit non-zero.

The built-in core functions you will use most are truthy (value present and non-empty), falsy, defined, undefined, pattern (regex match/notMatch), casing (camel, pascal, kebab, snake, macro), enumeration, length, alphabetical, schema (validate against a JSON Schema), and xor. For a deeper treatment of authoring rules and bundling a custom JavaScript function, see writing custom Spectral rules.

A few conventions keep a ruleset maintainable as it grows. Give every custom rule a description and a message — the description documents intent for the next maintainer, while the message is what a contributor sees when the rule fires, so make it actionable. Group rules by the part of the spec they govern (paths, components, info, servers) and prefer many small, single-purpose rules over one rule that checks several things; a focused rule produces a clear violation and is easy to disable in isolation when a legitimate exception appears. Resist the urge to set everything to error on day one. Introduce a new rule as warn, let the team clear the existing violations, then promote it to error once the spec is clean — that way the gate never blocks unrelated work the moment a rule lands.

Severity also governs noise. info and hint results never affect the exit code and are useful for surfacing suggestions in editors via the Spectral VS Code extension without breaking CI. Reserve error for rules that protect downstream automation — anything a docs renderer or SDK generator will choke on — and use warn for stylistic preferences your team has agreed to but does not want to block a release.

Run the linter against a spec to confirm the ruleset loads:

npx spectral lint openapi.yaml --ruleset .spectral.yaml

A typical run prints one line per result with the line/column, severity, rule name, message, and path:

openapi.yaml
 12:5  error  operation-summary-required  /paths/~1users/get is missing a summary.  paths./users.get
 30:9  warn   schema-property-camel-case  "user_id" is not camelCase.                components.schemas.User.properties.user_id

✖ 2 problems (1 error, 1 warning, 0 infos, 0 hints)

Integration Pattern

Governance only works when it runs automatically. Add Spectral as a required status check on pull requests, and run it locally before commits so contributors get feedback without waiting for CI.

The GitHub Actions workflow below lints both an OpenAPI and an AsyncAPI document on every pull request. It uses the JUnit formatter so failures surface inline in the PR via test-report annotations, and it fails the job on error-severity results:

# .github/workflows/spec-governance.yml
name: API Spec Governance
on:
  pull_request:
    paths:
      - "specs/**"
      - ".spectral.yaml"
jobs:
  spectral:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci                       # installs pinned @stoplight/[email protected]
      - name: Lint OpenAPI
        run: >
          npx spectral lint specs/openapi.yaml
          --ruleset .spectral.yaml
          --format pretty
          --format junit --output.junit results-oas.xml
          --fail-severity error
      - name: Lint AsyncAPI
        run: >
          npx spectral lint specs/asyncapi.yaml
          --ruleset .spectral.yaml
          --format pretty
          --format junit --output.junit results-async.xml
          --fail-severity error
      - name: Publish lint report
        if: always()
        uses: mikepenz/action-junit-report@v4
        with:
          report_paths: "results-*.xml"

For local enforcement, add a pre-commit hook so contributors cannot commit a spec that violates the ruleset:

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: spectral
        name: spectral lint specs
        entry: npx spectral lint --ruleset .spectral.yaml --fail-severity error
        language: system
        files: ^specs/.*\.(ya?ml|json)$   # only run on changed spec files
        pass_filenames: true

Install and activate the hook once per clone:

pip install pre-commit
pre-commit install
# every git commit now lints staged spec files

This same gate slots into the broader portal pipeline: once a spec passes governance, it flows to docs rendering and client generation. Teams running automated client builds should keep the linter as the first stage so a malformed spec never reaches the generator covered in the SDK generation guides.

Two operational details matter for a reliable gate. First, scope the workflow trigger with paths: so the job only runs when a spec or the ruleset actually changes — running Spectral on every commit to unrelated code wastes minutes and trains reviewers to ignore the check. Second, make the status check required in the repository’s branch-protection settings; a job that runs but is not required is advisory, and contributors will merge over a red check under deadline pressure. Pin the action versions and the CLI version (via npm ci against a committed lockfile) so a transitive update to a rule’s behavior cannot silently change what passes between two otherwise identical runs.

For monorepos that hold several specs, lint them in a matrix so each spec reports independently and a failure in one does not mask results in another:

  spectral-matrix:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false            # report every spec, don't stop at the first failure
      matrix:
        spec: [payments, billing, identity]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20", cache: "npm" }
      - run: npm ci
      - run: npx spectral lint specs/${{ matrix.spec }}/openapi.yaml --ruleset .spectral.yaml --fail-severity error

Advanced Options

Custom functions. When pattern, casing, and truthy cannot express a rule — for example, “the version in info must match the latest Git tag” or “every 2xx response must define an application/json content type” — write a JavaScript function. Point the ruleset at a folder of functions and reference them by name:

# .spectral.yaml
functions: [resInZeroToHundred]
functionsDir: "./functions"   # default is ./functions
rules:
  rate-limit-header-present:
    given: "$.paths[*][*].responses[*].headers"
    severity: warn
    then:
      function: hasRateLimitHeaders   # your custom function in ./functions/hasRateLimitHeaders.js

The companion guide on writing custom Spectral rules shows a full custom function implementation. A custom function receives the matched node, the functionOptions you declared, and a context object carrying the node’s path and the resolved document — enough to implement cross-cutting checks that no single JSONPath expression can express.

Sharing a ruleset across repositories. As soon as more than one service has its own spec, you want one canonical style guide rather than copies that drift. Publish the ruleset as an npm package or host it at a URL, then extends it by reference. A child ruleset can extend the shared one and still override individual rules locally:

# .spectral.yaml in a service repo
extends:
  - "@acme/api-style-guide"   # shared org ruleset from npm
rules:
  list-endpoint-rate-limit: off   # this internal service is exempt

Use the two-element array form [ruleset, "recommended"] or [ruleset, "all"] when extending to control whether you inherit only the recommended rules or every rule the parent defines.

The formats key. Scope a rule to specific document versions so a 3.1-only rule never runs against a 3.0 spec. Valid format identifiers include oas2, oas3, oas3_0, oas3_1, asyncapi2, and json-schema:

rules:
  no-nullable-in-31:
    description: "OpenAPI 3.1 forbids the legacy 'nullable' keyword."
    formats: [oas3_1]          # only evaluated for 3.1 documents
    severity: error
    given: "$..[?(@.nullable !== void 0)]"
    then:
      function: undefined
      field: nullable

Rule overrides by path. Use the overrides block to relax or strengthen rules for specific files or JSONPath locations — invaluable when migrating a legacy spec where you cannot fix every violation at once:

overrides:
  - files: ["specs/legacy/**/*.yaml"]
    rules:
      operation-summary-required: off   # grandfather the legacy spec
  - files: ["specs/openapi.yaml#/paths/~1internal"]
    rules:
      path-kebab-case: off              # internal-only path exempt

Verification & Testing

Confirm the ruleset itself is valid and that it catches what you expect. First, lint a deliberately broken spec and assert the exit code:

npx spectral lint specs/openapi.yaml --ruleset .spectral.yaml --fail-severity error
echo "exit code: $?"
# exit code: 1   ->  governance gate is working

A clean spec returns exit 0 with a success line:

No results with a severity of 'error' found!

To machine-check results in a test suite, emit JSON and parse it:

npx spectral lint specs/openapi.yaml --ruleset .spectral.yaml --format json --output.json out.json
# out.json is an array of {code, message, severity, range, path} objects

Severity in the JSON output is numeric: 0 = error, 1 = warn, 2 = info, 3 = hint. Assert that a known-bad fixture produces the expected rule code to lock the rule against regressions when you refactor the ruleset. For schema-level checks, validate example payloads against their schemas using the built-in schema function rather than re-implementing validation in a custom function.

Treat the ruleset itself as code under test. Keep a small fixtures/ directory with one intentionally broken spec per rule and one fully clean spec, then run both in CI: the broken fixtures must report their rules (and exit non-zero), and the clean fixture must pass. This guards against the silent failure mode where a given expression stops matching after a refactor and a rule quietly does nothing while still appearing in the config. A few seconds of fixture testing is far cheaper than discovering months later that governance was a no-op.

Troubleshooting

Error running Spectral! No ruleset has been found. Spectral could not locate a ruleset. Pass --ruleset .spectral.yaml explicitly, or ensure the file sits in the working directory with a recognized name. In CI, confirm the checkout step ran before the lint step so the file is on disk.

A custom rule matches nothing. The given JSONPath does not select any nodes, usually because the path needs the resolved document. Bundle multi-file specs first: npx @redocly/cli bundle specs/openapi.yaml -o dist/bundled.yaml, then lint dist/bundled.yaml. Test the expression in isolation by setting the rule severity to info and adding --verbose to see what Spectral evaluated.

Cannot find module for a custom function. The functionsDir path is wrong or the filename does not match the function name listed under functions. Spectral expects ./functions/<name>.js exporting a default function; the entry in the functions array must equal the filename without the extension.

Built-in rules fire that you never wrote. Extending spectral:oas/spectral:asyncapi pulls in dozens of recommended rules. To start from nothing, omit extends entirely, or set unwanted rules to off in the rules map. Run npx spectral lint --verbose to see exactly which ruleset contributed each result.

FAQ

What is the difference between spectral:oas and spectral:asyncapi?

spectral:oas bundles rules for OpenAPI 2.0, 3.0, and 3.1 documents, while spectral:asyncapi targets AsyncAPI 2.x event-driven specs. Spectral selects the matching ruleset by detecting the document format, so you can extend both in one .spectral.yaml.

How do I stop Spectral from failing the build on warnings?

Spectral exits non-zero only on results at or above the --fail-severity threshold, which defaults to error. Set --fail-severity warn to also fail on warnings, or leave it at error so warnings stay advisory.

Can Spectral resolve external $ref pointers across split spec files?

Yes, Spectral resolves local and remote $ref pointers before applying rules. Bundle multi-file specs with @redocly/cli first when a rule needs to see the fully merged document, since some given paths only match resolved structures.

How do I disable a single rule for one part of the spec?

Set the rule to off in the rules map of .spectral.yaml to disable it everywhere, or add an override scoped by file and JSONPath in the overrides block. Prefer overrides so the rule stays active for the rest of the document.