Skip to main content

Custom lint rules

GOV-1.3 (#4429). Author organization-specific lint rules ("all list endpoints paginate", "headers use Train-Case") in a YAML dialect that is a strict subset of the Spectral ruleset format, easing migration from Stoplight/Redocly. Rules are validated by POST /v1/lint/custom-rules/validate; since GOV-1.4 (#4430), the custom rules of the resolved style guide (project → tenant → default) are evaluated in every lint path and their findings count toward the quality score.

The format​

A style-guide document is a YAML mapping with one top-level key, rules:

rules:
servers-use-https:
description: Every server URL uses https.
severity: error
given: "$.servers[*].url"
then:
function: pattern
functionOptions:
match: "^https://"

operations-have-summary:
description: Every operation carries a summary.
severity: warning
given: "$.paths[*][*]"
then:
field: summary
function: truthy

Each rule id maps to a definition with exactly these keys (anything else is rejected):

KeyRequiredMeaning
descriptionyesHuman explanation; becomes the base finding message.
severitynoerror | warning | info (default warning).
givenyes*One JSONPath expression, or a list of them, selecting the values to test.
thenyes*One clause, or a list of clauses, applied to every given match.
scopenoWhich model the rule reads: document (default) | canonical | declared.
unevaluabledeclared only{reason, detail} — why a declared rule is recorded but never evaluated.

* given and then are optional only on a declared rule, which is a record rather than a check.

A then clause:

KeyRequiredMeaning
fieldnoTest this property of each matched object instead of the match itself. The special value @key tests each key of a matched object.
functionyesOne of the core functions below.
functionOptionsper functionThe function's options (unknown options are rejected).

Rule ids are lowercase alphanumeric segments separated by ., - or _, and may not shadow a built-in rule id.

Rule scope​

Almost every rule is written against a source document — a reconstructed OpenAPI/JSON-Schema mapping — and that is the default, so a rule with no scope behaves exactly as it always has. Two other scopes exist for rules that cannot be:

scopeThe rule readsWritten by
documentThe reconstructed source document.Hand-authored rules, Spectral imports.
canonicalThe canonical-model governance projection — every named type keyed by name, its members split into children and attributes.Schematron imports.
declaredNothing. The rule is recorded, with a machine-readable reason, and never evaluated.An import that could not express an assertion, so it says so instead of dropping it.

One scope is evaluated per lint run, so adding canonical rules to a guide cannot change what an OpenAPI lint reports, and a declared rule never produces a finding in any scope:

rules:
schematron.br-02:
description: An invoice shall have an invoice number.
severity: error
scope: canonical
given: "$.elements['Invoice'].children"
then: {field: ID, function: defined}

schematron.br-co-10:
description: Totals shall agree with the sum of the line amounts.
severity: error
scope: declared
unevaluable:
reason: variable_reference
detail: "reads $lineExtension, a `let` computed at validation time"

unevaluable.reason is a lowercase snake_case code; detail is optional prose, capped at 1024 characters.

Core functions​

FunctionOptionsPasses when
truthy—The target is defined and truthy (not false, 0, "", [], {}, null).
defined—The target exists.
undefined—The target does not exist.
patternmatch and/or notMatch (regex)A string target matches match / does not match notMatch. Non-string or absent targets pass — combine with truthy/defined to require presence.
casingtype (required), disallowDigitsA string target is flat, camel, pascal, kebab, cobol, snake or macro case.
enumerationvalues (required)The target equals one of values (scalars only).
lengthmin and/or maxStrings/arrays/objects: their length is within bounds. Numbers: the value itself is within bounds (Spectral semantics).

JS-function custom rules (Spectral's functions: directory) are not supported. An existing .spectral.yaml does not have to be re-authored by hand, though: POST /v1/lint/custom-rules/import translates one onto this subset and reports every rule it could not map, with a reason each — see Import a Spectral ruleset.

Validation: actionable errors with pointers​

POST /v1/lint/custom-rules/validate accepts {"yaml": "<document>"}. A well-formed guide returns the parsed rules (the exact shape stored in style_guide_rules.custom_def). Anything malformed returns HTTP 422 whose detail carries a message and a pointer to the offending YAML node:

{
"detail": {
"message": "'match' is not a valid regular expression: missing ), unterminated subpattern at position 0",
"pointer": "rules.broken.then.functionOptions.match"
}
}

Strictness is deliberate: unknown keys anywhere, duplicate rule ids, invalid regexes/JSONPath, wrong option types, and out-of-range cardinalities (max 200 rules per guide, 10 given / 10 then per rule) are all rejected at authoring time, never at lint time.

Sandboxed evaluation​

Rule authors control two potentially explosive inputs, so evaluation is sandboxed:

  • Regexes run through the regex engine with a hard per-match timeout (re2-style bound), so catastrophic backtracking ((a|aa)+$) cannot hang the service. Pattern length is capped, and the JSONPath filter operator =~ is rejected (its regexes would bypass the timeout).
  • JSONPath evaluation spends from a fixed per-rule node budget; adversarial expressions such as $..*..*..* (cost exponential in the number of .. operators) abort deterministically instead of hanging. Expression length and the number of .. operators are also capped.

A rule that trips the sandbox is aborted and reported per rule; the rest of the guide still evaluates.

JSONPath notes​

  • [*] iterates object properties and array items (Spectral semantics), so $.paths[*][*] selects every operation and findings carry real key paths (paths./pets/{id}.get).
  • Filters are supported: $.paths[*][*].parameters[?(@.in == 'path')].
  • Finding paths are dotted, with [i] for array positions — the same style built-in lint findings use.