Implementing a Redocly CLI lint rule
Turn a check written in plain language ("every array schema must declare items") into a Redocly lint rule that works and is tested.
The ladder. Redocly gives three ways to do a check. The first is the cheapest to maintain.
- Built-in rule — one of the built-in rules does the check.
- Configurable rule — one or more
rule/*entries inredocly.yamldo the check with assertions. - Custom plugin — JavaScript code that implements your own rule with the visitor pattern. Use this rung last.
Do the checks one at a time. Start each check at rung 1. Go down one rung only when the current rung cannot do the check. Take all syntax from the official docs or the cookbook. Trust the docs, not your memory. Use modern syntax, such as ESM instead of CommonJS. If the project has its own style, follow the project.
1. Triage
For each check, write down three things: the node it looks at, what must be true, and what a violation looks like. Triage each check on its own. Different checks can land on different rungs.
Get node names from the inspect-node-types command, not from memory.
The type tree is different in each specification and each version.
The names it prints are the subject.type of a configurable rule, and the visitor keys of a plugin.
| to learn | run |
|---|---|
| the type at one location | --pointer='#/paths/~1orders' |
| every node, or every node of one type | no flag, or --type=Schema |
| which types the description uses | --summary |
| where a type can sit | --type=<Type> --parents |
| the ancestors of one node | --pointer=<pointer> --parents |
The last two rows give a rule its scope.
When a type sits below more than one parent, the rule needs a where gate.
Without that gate, the rule also reports the branches you do not want.
The command shows the description in front of you. It does not show everything the specification permits.
When the command does not print a branch, that branch is unproven. It is not absent.
The command does not shorten step 3. It gives you type names. It does not tell you whether your assertions express the check.
Walk the ladder for each check:
- Search the built-in rules list for the concept.
A trailing slash in a path gives
no-path-trailing-slash. When the config already extends a ruleset that turns the built-in on, tell the user. Do not add the rule again. Use the built-in when its description matches the check, even when it reports more than the rule you replace. A built-in is usually a better version of the same idea, so its extra findings are the check done correctly. When you write the narrower behavior by hand, you exchange a maintained rule for code that you must maintain. Go down one rung only when the built-in checks something different. Report which built-in you took, and what it adds. - Make sure the built-in supports the spec versions that the project lints.
Some built-ins support one version only.
no-invalid-media-type-examplessupports OpenAPI 3.x, so it leaves a Swagger 2.0 check unenforced. Redocly printsUnused rules found in redocly.yamlwhen an enabled rule cannot apply to a document. Correct that line. It is not noise. - Try assertions and a
wheregate. See "Configurable rules" below. One check can need severalrule/*entries, one for each case or location. That is still better than a plugin. - Write a plugin only when configurable rules cannot express the check, also in combination. First work through the three dials below. A plugin can do any check, but it is code. It adds complexity, maintenance, and its own bugs.
- Tell the user to file an issue only when no configurable rule can close a real gap.
Triage is complete when each check has a rung. Give one solution for each check: the highest rung that works. Report only what the user must act on: a mismatch between a description and an implementation, an unproven rule, or a decision that could go another way. Report in the chat. Never write a summary file. The config records the rest, so do not narrate the rung of each check.
Migrate from another linter
- Migrate the effective ruleset, not only the rules written in the file.
An
extends, or an inherited base, turns on more rules at their default severities. Each of those rules is also a check. Resolve that base and count the rules before you start. Then you know the true size of the job. - Take each check from two places: the description of the rule, and its implementation. Never take it from your memory of the rule name. When the two disagree, use the description. The description says what the author wanted, and the implementation can hold a bug. Tell the user about the difference.
- Skip a rule with severity
off. Do not migrate it, not even as a disabled entry. When the enabled rules are done, list each skipped rule on one line. Then the user can decide. - For Spectral, read the map table in the migration guide first. Many Spectral core rules map to one built-in. An empty cell means that you go down the ladder.
- When you copy a function from the source, you also copy its bugs into a stricter host.
Redocly stops the whole document when a rule throws, but the source linter can stop one rule only.
An unguarded match, such as
v.match(/…/)[0]on a value that can fail to match, removes every finding in that file. Add a guard to the code you copy. Then lint a document that holds the difficult shape.
2. Configure
Write to the user's redocly.yaml. Use that exact name, with no leading dot.
When the file exists, read it first, and keep the existing extends and rules.
Set each rule to error, unless the user asks for another level.
When you migrate, keep the source severity and choose the nearest Redocly level.
Only error, which fails the run, warn, and off exist, so info and hint become warn.
Reference every file you create from the config, and exercise it in the lint run. Leave no dead files.
A config with no extends inherits nothing — not recommended, and not even struct.
The default base applies only when no config file exists.
So a redocly.yaml runs exactly the rules that it names.
The built-in rung exists only when extends names a ruleset, or when the config lists each built-in itself.
With neither, a document that breaks the specification three times lints clean.
Wire every rule.
A plugin in plugins: does nothing when its rules are absent from rules:, and Redocly gives no warning.
After you write the config, list the rule ids that must be active.
Confirm that each id is in rules: or in a spec-scoped block.
When all rules are in, compare your list with the predefined rulesets: minimal, recommended, recommended-strict, and spec.
- When your rules are close to one ruleset, extend it. Keep only the differences as overrides: other severities, rules to turn off, and checks that the ruleset does not hold.
- A ruleset also turns on rules that the user did not ask for.
Read what it adds, and override each rule that contradicts or repeats a rule you wrote.
A ruleset that turns on a check which the source sets to
offis an error to correct, not a benefit. - When the overrides are as many as the rules, or more, keep the plain list and no
extends.
3. Verify
A rule is unproven until a live run makes it report.
Prove each rule with npx redocly lint <api-file> in two directions:
- a violating fixture: the rule reports, at the correct location;
- a conforming fixture: the run stays silent.
A false positive is the common failure, so a rule that passed only the violating fixture is unproven. Write the violating fixture to separate this rule from the rule next to it: the rule you replace, or its neighbour in the config. Two errors need that separation:
- Put a falsy value in the violating fixture.
A rule that must reject an empty
descriptionmust report ondescription: ''. When you put''in the conforming fixture only, you prove nothing, because a presence assertion accepts it. The rule then looks correct and the gap stays. - A built-in that replaces two source checks needs a fixture that breaks only the second check.
no-unsafe-markdownreports<script>, but nevereval(. A fixture that holds both gives the built-in credit for a check it does not do.
To keep the output clean, pass a small temporary config with --config.
Delete that config and the fixtures when you finish.
Every snippet you deliver must be correct as written: it parses as YAML, and it behaves as you claim.
You are done when each check names a violating fixture that reported, and a conforming fixture that stayed silent.
Name those fixtures in your report.
A count is not evidence.
A clean run on the project's own files is not evidence either: it shows only that nothing reports there, and that is true for a correct rule and for a dead rule.
When you verify the risky rules and assume the others, the delivery is unproven. Say so, and name each unproven rule.
A conforming fixture proves only the shapes inside it.
A spec version you did not try, or a location that --parents showed and you did not exercise, stays untested. Report that.
When a live run is impossible, mark the rule unverified. Do not claim that it works.
You are also done when each requested check is in the config or on the skipped list, and the project holds only the config and plugin files that you created.
Configurable rules
Docs: configurable rules. Worked examples: cookbook/configurable-rules.
A rule is an entry under rules: with a name that starts with rule/:
rules:
rule/my-check:
subject:
type: Schema # a node type from the docs' type list
property: title # optional
assertions:
defined: true
where: [] # optional gate
message: Custom problem text.
severity: error
Plain rules applies to every document that the project lints. Keep rules there by default.
Move a rule under a spec-scoped key only when the check needs something that exists in one spec version, such as nullable in OAS 3.0, or when the user asks for the scoping.
The keys are oas2Rules, oas3_0Rules, oas3_1Rules, oas3_2Rules, async2Rules, async3Rules, arazzo1Rules, and overlay1Rules.
A rule inside one key does not run for the other versions, so a check that the project needs on two versions needs an entry that both versions reach.
Scope a plugin rule inside the plugin instead, in its rules map.
What an assertion tests
Two facts cause most false positives and most silent misses.
An absent value passes.
Nearly every assertion holds when the value it checks is absent.
const: array on property: type matches a schema typed array, and also every schema with no type.
So when a where entry tests a property value, add defined: true beside the value assertion if the property can be absent.
Presence is not value.
defined, required, requireAny, disallowed, and mutuallyExclusive test whether a key exists.
They say nothing about the content, so defined: true on description passes for description: ''.
Assert the value instead: nonEmpty: true rejects an empty string, and const, enum, and pattern test the content.
This is the most frequent defect in practice.
It looks correct in review, and only a violating fixture that holds '' shows it.
Two results of these facts:
const: <value>is also the way to say "this field must be falsy", because the assertion holds for that value and for an absent field.requireAnycounts keys only. "At least one of these fields, and not empty" has no assertion form. That check is a real plugin case.
What the assertion inspects
property: name— the value of that field on the subject node.property: [name, url, email]— each listed field on its own. Every field that fails becomes its own problem with its own JSON pointer, such as#/info/contact/name.propertyabsent — the keys of the subject node. For example,subject: { type: Paths }withassertions: { notPattern: /.+\/$/ }runs on the path strings themselves.
Aim the subject, then gate it
Three dials aim a rule. Resolve them in this order.
1. Subject: the node that owns what you assert.
- A check about the keys that a map holds targets the map type itself.
The maps of responses, headers, schema properties, and paths are each their own type.
So "this map must hold that key" is one
requiredon one subject. Do not search the parent. - A check about a value targets the node that holds the field, plus
property. type: anywith apropertychecks that property everywhere it appears.- Confirm the type name with
inspect-node-types, on a document of the spec version that the rule targets. A named type can exist in one version and not in another.HeadersMapis a type in OpenAPI 3. In OpenAPI 2 the responseheadersmap is anonymous and has no type name, so aHeadersMapsubject underoas2Rulesnever reports. Redocly warns about an unresolved rule id, but not about an unresolvedsubject.type, so a wrong type gives you a rule that never reports and never complains.
2. Parent-key filters: filterInParentKeys, filterOutParentKeys, matchParentKeys.
These filters match the key of the subject node inside its parent.
So they work only where that key varies: a method inside a path item, a status code inside a responses map, a path string inside a paths map.
matchParentKeys takes a regular expression, so it reaches a class of keys, and a negative lookbehind inside it excludes a shape.
The common error is a filter one level too high.
The key of a Responses node is always responses, so filterInParentKeys: [put] on a Responses subject matches nothing and the rule never reports.
The method varies one level above. Reach it with where:
rule/put-responses-must-include-200:
subject:
type: Responses # the key here is always `responses`, so no filter belongs on it
assertions:
required: ['200']
where:
- subject:
type: Operation
filterInParentKeys: [put] # the key that varies
assertions:
defined: true
3. where: the ancestor gate.
Build the gate from --type=<Type> --parents.
Each chain in that output is a place where the subject sits, and the gate keeps the chain you want.
Each entry names a type that must sit above the subject, and the rule skips every node that is not inside a matching ancestor.
The assertions of a gate entry are filters, never checks: when one fails, the node drops out in silence instead of becoming a problem.
The mechanics are exact:
- Entries run in document order, outermost first. Each type can appear in one entry only.
- The chain can skip levels. Name the ancestors that matter, not every node between them.
- The subject's own type can also appear in the last entry. That is how the gate carries a parent-key filter or an exclusion while the subject stays whole.
- An entry of the subject's own type narrows the subject by one of its properties, such as "this schema, when its
typeisarray". - One entry can carry both a parent-key filter and assertions, so it says "inside an ancestor with this key and this value".
- References resolve at the gate, so entries see resolved nodes.
- For a Schema subject with a
where, evaluation stops at the first schema level that matches the gate. - A gate cannot exclude a nested
callbacksbranch. An operation inside a callback still reaches a rule gated onPaths. Say so. Do not claim that the gate excludes it.
When one subject becomes hard to aim, write one rule for each case.
Several small rule/* entries stay readable, and they are still better than a plugin.
Recipes
A check that looks like plugin work usually needs one of these. Try them before you choose a plugin.
Gate a check about paths with the paths map. The type that holds a path also holds webhook names and callback keys, so a rule about the shape of a URL also reports those unless the gate names the paths map:
rule/put-path-ends-with-parameter:
subject:
type: Operation
filterInParentKeys: [put]
assertions:
defined: false
where:
- subject:
type: Paths # without this entry the rule also reports webhooks and callbacks
assertions:
defined: true
- subject:
type: PathItem
matchParentKeys: /[^}]$/
assertions:
defined: true
Run --type=<Type> --parents on the type you want to target.
Every chain it prints is a place where the rule reports, and each chain you do not want needs a gate entry.
Require a value, not only a key.
additionalProperties must not sit beside properties, but additionalProperties: false stays permitted:
rule/no-additional-properties-with-properties:
subject:
type: Schema
property: additionalProperties
assertions:
const: false
where:
- subject:
type: Schema
property: properties
assertions:
defined: true
Guard a value gate against an absent value.
The defined: true keeps this rule off a schema that declares no type:
rule/array-items:
subject:
type: Schema
assertions:
required: [items]
where:
- subject:
type: Schema
property: type
assertions:
const: array
defined: true
message: The 'items' field is required for schemas of array type.
A value assertion cannot match a list, so this gate misses the OpenAPI 3.1 union form type: [array, 'null'].
Cover that version with a second entry, or report that the rule is narrower than the source.
Locate by ancestor, and skip the levels between. "The media types of the 200 and 400 responses of one path" needs two gate entries, and no entry for the operation or the responses map:
rule/health-media-type:
subject:
type: MediaTypesMap # keys, so `const` checks the media type itself
assertions:
const: application/health+json
where:
- subject:
type: PathItem
matchParentKeys: '/health'
assertions:
defined: true # a pure gate: this ancestor must exist
- subject:
type: Response
filterInParentKeys: ['200', '400']
assertions:
defined: true
Filter the subject from the gate, and exclude with it.
rule/post-should-define-requestBody:
subject:
type: Operation
assertions:
required: [requestBody]
where:
- subject:
type: PathItem
matchParentKeys: /^([\w-\{\}/.](?<!/actions))*$/ # the lookbehind excludes a URI shape
assertions:
defined: true
- subject:
type: Operation # the subject's own type, in the last entry
filterInParentKeys: [post]
assertions:
disallowed: [deprecated] # a filter, so a deprecated operation drops out
Gate on the value of an ancestor, to say "only inside a node that looks like this":
where:
- subject:
type: Operation
property: deprecated
assertions:
defined: true
const: true
Combine assertions in one rule. All of them must hold:
assertions:
requireAny: [minLength, maxLength, enum]
mutuallyRequired: [minLength, maxLength]
Match without case. pattern and notPattern accept inline regular expression flags, so a header-name check needs no plugin:
assertions:
notPattern: '/^(authorization|content-type|accept)$/i'
Assert on the reference, not on the resolved value.
ref: true requires a $ref, ref: false requires an inline value, and a string requires the unresolved $ref to match:
subject:
type: MediaType
property: schema
assertions:
ref: /^#\/components\/.*/
Bound a list. minLength and maxLength apply to lists and to strings, so a list type as the subject limits how many members it holds. Use maxLength: 1 for a single server.
Forbid a node type. A subject with defined: false and no property rejects a construct anywhere in the description.
Record an exception. When the rule is correct and a few nodes are real exceptions, keep the pattern strict and record those nodes in .redocly.lint-ignore.yaml.
YAML gotcha. Quote a message that holds a colon followed by a space, or the config fails to parse:
message: 'Schemas with type: array must define items.'
Custom plugin rules
Docs: custom rules. Worked examples: cookbook/custom-plugin-rules. The full visitor guide is at https://raw.githubusercontent.com/Redocly/redocly-cli/refs/heads/main/.claude/rules/walker-visitors-nodes.md.
A plugin is a module. Its default export is a function that returns the plugin object:
export default function myPlugin() {
return {
id: 'my-plugin',
rules: {
oas3: {
'schema-title-defined': SchemaTitleDefined,
},
},
};
}
Rules are keyed by spec flavor: oas2, oas3, async2, async3, and arazzo.
Register the same rule under several flavors when the check applies to all of them.
Register and use
# redocly.yaml
plugins:
- ./my-plugin.js
rules:
my-plugin/schema-title-defined: error
The config name of the rule is <plugin id>/<rule name>.
Both halves are required.
A plugin whose rules are absent from rules: is delivered and dead.
The visitor pattern
A rule is a factory that returns a visitor object keyed by node type.
The walker traverses the document, resolves each $ref, and calls your hooks.
Never parse the document yourself, and never walk into it by hand.
Confirm each visitor key with the inspect-node-types command.
A key that is wrong or invented is never called, and nothing reports the mistake.
There is no Reference node, because a $ref and its target share the type of the target.
Watch the singular and the plural: the map of component schemas is NamedSchemas, and NamedSchema matches nothing.
Keep the rule small. Express everything through the visitors that the walker gives you.
A plugin rule needs no traversal helper, no second walker, and no resolver of its own.
The walker already reaches every node of a type, and it resolves each $ref before leave.
Use another visitor key before you write a helper.
function SchemaTitleDefined() {
return {
Schema: {
enter(schema, ctx) {
if (schema.type === 'object' && !schema.title) {
ctx.report({
message: 'Object schemas must define a title.',
location: ctx.location.key(),
});
}
},
},
};
}
- The hooks are
enter(node, ctx),leave(node, ctx), andskip(node, ctx).leaveruns after the children, when every$refis resolved. Write the hooks as an explicit object withenterandleavekeys, as in the example. - Keep state that crosses nodes in the factory scope.
Collect in the
enterhooks, and report inRoot.leave.
The context object
Use only these fields. The context is not the document, and it holds no path and no root.
| use | for |
|---|---|
ctx.report({ message, location }) |
report the problem |
ctx.location.child([...]) |
point at one field |
ctx.location.key() |
point at the key of the node — a report location, not a string |
ctx.key |
the key of the node itself, as a value |
ctx.parent |
the parent node |
ctx.resolve(ref) |
resolve a $ref to its target |
ctx.rawNode |
the unresolved node, when you need the $ref itself |
ctx.specVersion |
the spec version of the document |
There is no ctx.document, no ctx.root, and no ctx.path.
A rule that reads one of them throws.
A rule that throws stops the whole document. Every finding from every rule is lost for that file, not only the findings of your rule. So an invented context field costs much more than a rule that never reports.
ctx.key is the reason a method or a status code belongs on the node whose key varies.
Inside a Responses visitor, ctx.key is always responses, so if (ctx.key !== 'delete') return never passes.
That is the same error as a parent-key filter one level too high.
Read resolved nodes, not raw children
Inside enter, a child that is a $ref is still a reference.
So response.schema, or the name of a parameter, is undefined for every member that is a $ref.
That turns into a false positive on an ordinary document: a uniqueness check reports "duplicate name: undefined", and a required-field check reports a field that is present behind the reference.
Use one of these instead:
- report in
leave, where every$refbelow the node is resolved; - visit the type of the child, so the walker gives you the resolved node;
- call
ctx.resolve(ref).
Prove the rule with a fixture whose members are $refs.
A fixture with everything inline does not test this.