实现一个 Redocly CLI lint 规则
将自然语言描述的规定(例如“每个数组模式必须声明 items”)转化为一个可工作、可测试的 Redocly lint 规则。
阶梯路线。 Redocly 提供了三种执行检查的方式。第一种维护成本最低。
- 内置规则 — 使用内置规则之一执行检查。
- 可配置规则 — 在
redocly.yaml中配置一个或多个rule/*条目,使用断言执行检查。 - 自定义插件 — 使用访问者模式编写 JavaScript 代码实现自己的规则。最后才考虑使用这个层级。
每次只处理一个检查。 每个检查从第 1 级开始。 仅当当前层级无法执行检查时才下降到下一级。 所有语法参考官方文档或食谱。 信任文档,而不是你的记忆。 使用现代语法,例如使用 ESM 而不是 CommonJS。如果项目有自己的风格,遵循项目规范。
1. 分类(Triage)
对于每个检查,写下三件事:它检查的节点、必须为真的条件、以及违规的表现形式。 每个检查独立分类。不同的检查可能落在不同的层级上。
从 inspect-node-types 命令获取节点名称,而不是凭记忆。
类型树在不同规范版本和不同版本中是不同的。
命令打印的名称是可配置规则的 subject.type,以及插件的访问者键。
| 要了解的信息 | 运行命令 |
|---|---|
| 某一位置的类型 | --pointer='#/paths/~1orders' |
| 每个节点,或某一类型的所有节点 | 无标志,或 --type=Schema |
| 描述使用了哪些类型 | --summary |
| 类型可以出现的位置 | --type=<Type> --parents |
| 一个节点的祖先 | --pointer=<pointer> --parents |
最后两行给出了规则的作用范围。
当一个类型有多个父类型时,规则需要一个 where 门控。
没有这个门控,规则也会报告你不想要的分支。
该命令展示了眼前的描述。它并未显示规范允许的所有内容。
当命令没有打印某个分支时,说明该分支未经证实,并非不存在。
该命令不能缩短第 3 步。它给你类型名称,但不告诉你你的断言是否表达了检查条件。
对于每个检查,沿着阶梯逐级尝试:
- 在内置规则列表中搜索相关概念。
路径中的尾随斜杠对应
no-path-trailing-slash。 当配置已经扩展了一个启用了该内置规则的规则集时,告知用户。不要再添加该规则。 当内置规则的描述与你的检查匹配时,就使用它,即使它报告的范围比你替换的规则更广。 内置规则通常是同一思想的更好版本,因此它额外的发现只是正确执行的检查。 当你手动编写更窄的行为时,你就用一个需要维护的规则换成了一个需要你自己维护的代码。 仅当内置规则的检查内容不同时才下降到下一级。 报告你采用了哪个内置规则,以及它增加了什么。 - 确保内置规则支持项目要 lint 的规范版本。
一些内置规则只支持一个版本。
no-invalid-media-type-examples只支持 OpenAPI 3.x,所以它不会强制执行 Swagger 2.0 的检查。 当一个启用的规则无法应用于文档时,Redocly 会打印Unused rules found in redocly.yaml。 更正该行。那不是噪音。 - 尝试断言和
where门控。参见下面的“可配置规则”。 一个检查可能需要多个rule/*条目,每个条目针对一种情况或位置。这仍然比写插件好。 - 仅当可配置规则(即使组合使用)也无法表达检查时,才编写插件。 首先尝试下面的三个旋钮。 插件可以执行任何检查,但它是代码。它会增加复杂性、维护成本和自身的错误。
- 只有在没有可配置规则能填补实际空白时,才建议用户提交 issue。
当每个检查都分配了层级后,分类完成。 为每个检查提供一个解决方案:能工作的最高层级。 只报告用户必须采取行动的内容:描述与实现之间的不匹配、未经证实的规则、或可能走另一条路的决定。 在聊天中报告。不要写摘要文件。 配置记录了其余内容,所以不要叙述每个检查的层级。
从其它 linter 迁移
- 迁移实际生效的规则集,而不仅仅是文件中编写的规则。
extends或继承的基础规则集会启用更多具有默认严重级别的规则。这些规则中的每一个也是一个检查。 在开始前解析那个基础规则集并统计规则数量。这样你就知道了工作的真实规模。 - 从两个地方获取每个检查:规则的描述和它的实现。 永远不要根据你对规则名称的记忆来获取。 当两者不一致时,使用描述。描述说明了作者的意图,而实现可能包含错误。 告诉用户这个差异。
- 跳过严重级别为
off的规则。不要迁移它,即使是作为禁用的条目也不行。 当启用规则完成后,将每个跳过的规则列在一行。然后用户可以决定如何处理。 - 对于 Spectral,首先阅读迁移指南中的映射表。 许多 Spectral 核心规则映射到一个内置规则。空单元格意味着你需要沿阶梯下降。
- 当你从源中复制函数时,你也将其中的错误复制到了一个更严格的主机中。
Redocly 在规则抛出异常时会停止处理整个文档,而源 linter 可能只停止一条规则。
未经防护的匹配,例如对可能匹配失败的值执行
v.match(/…/)[0],会抹去该文件中的所有发现项。 为你复制的代码添加防护。然后对一个包含困难模式的文档运行 lint。
2. 配置
写入用户的 redocly.yaml 文件。使用该确切名称,不带前导点。
如果文件已存在,先读取它,并保留现有的 extends 和规则。
将每个规则设置为 error,除非用户要求其他级别。
迁移时,保留源的严重级别并选择最接近的 Redocly 级别。
只有 error(导致运行失败)、warn 和 off 存在,所以 info 和 hint 变为 warn。
从配置中引用你创建的每个文件,并在 lint 运行中对其进行测试。不要留下死文件。
没有 extends 的配置不继承任何东西 — 不继承 recommended,甚至不继承 struct。
仅当不存在配置文件时,才会应用默认基础规则集。
所以,redocly.yaml 只运行它明确命名的规则。
只有当你通过 extends 命名了规则集,或者配置中列出了每个内置规则本身时,内置规则层级才可用。
两者都没有时,一个三次违反规范的文档也能通过 lint 检查。
连接每个规则。
如果插件中的规则未出现在 rules: 中,plugins: 中的插件不执行任何操作,并且 Redocly 不会给出警告。
编写配置后,列出必须激活的规则 ID。
确认每个 ID 都在 rules: 或一个规范作用域块中。
当所有规则都已配置后,将你的列表与预定义的规则集进行比较:minimal、recommended、recommended-strict 和 spec。
- 当你的规则接近某个规则集时,扩展它。 只保留差异作为覆盖项:不同的严重级别、要关闭的规则、以及该规则集不包含的检查。
- 规则集也会启用用户未要求的规则。
阅读它添加了什么,并覆盖每个与你编写的规则冲突或重复的规则。
一个规则集启用了源中设置为
off的检查,这是一个需要纠正的错误,而不是一个好处。 - 当覆盖项与规则本身一样多或者更多时,保留纯列表,不使用
extends。
3. 验证
直到一次真实的运行使其产生报告,一个规则才是被证实的。
使用 npx redocly lint <api-file> 从两个方向验证每个规则:
- 一个违反夹具:规则报告,并在正确的位置;
- 一个符合夹具:运行保持静默。
误报是常见的失败,因此只通过了违反夹具的规则是未经证实的。 编写违反夹具时,要使该规则与相邻规则区分开:你替换的规则,或配置中它的邻居。 两种错误需要这种区分:
- 在违反夹具中放入一个假值。
一个必须拒绝空值
description的规则必须报告description: ''。 如果你只在符合夹具中放入'',你什么也证明不了,因为存在性断言会接受它。 规则看起来正确,但漏洞依然存在。 - 一个替换了两个源检查的内置规则需要一个仅违反第二个检查的夹具。
no-unsafe-markdown会报告<script>,但从来不报告eval(。 一个同时包含两者的夹具会让内置规则获得它并未执行的检查的功劳。
为了保持输出整洁,使用 --config 传递一个小的临时配置。
完成时删除该配置和夹具。
你交付的每个代码片段必须按编写的那样正确:它能解析为 YAML,且行为如你所声称。
当每个检查都命名了一个已报告的违反夹具和一个保持静默的符合夹具时,你的工作就完成了。
在报告中命名这些夹具。
计数不是证据。
在项目自身文件上运行通过也不是证据:它只说明那里没有报告,这对于正确的规则和死规则都是成立的。
当你验证了风险规则并假设其他规则正确时,交付内容是未经证实的。说明情况,并命名每个未经证实的规则。
符合夹具只证明了它内部包含的模式。
你未尝试的规范版本,或 --parents 显示但你未测试的位置,仍然是未经测试的。报告这一点。
当无法进行真实运行时,将该规则标记为未验证。不要声称它工作。
当每个请求的检查都已存在于配置或跳过列表中,并且项目中只包含你创建的配置和插件文件时,你的工作也完成了。
可配置规则
文档:configurable rules。 工作示例:cookbook/configurable-rules。
规则是 rules: 下的一个条目,其名称以 rule/ 开头:
rules:
rule/my-check:
subject:
type: Schema # 来自文档类型列表的节点类型
property: title # 可选
assertions:
defined: true
where: [] # 可选门控
message: 自定义问题文本。
severity: error
纯 rules 适用于项目 lint 的每个文档。默认将规则保留在此处。
仅当检查需要某个规范版本中特有的内容(如 OAS 3.0 中的 nullable),或用户要求作用域时,才将规则移到规范作用域键下。
这些键是 oas2Rules、oas3_0Rules、oas3_1Rules、oas3_2Rules、async2Rules、async3Rules、arazzo1Rules 和 overlay1Rules。
某个键内部的规则不会在其他版本上运行,因此如果项目需要在一个检查在两个版本上都运行,则需要一个两个版本都能访问到的条目。
相反,在插件内部的作用域化插件规则,放在其 rules 映射中。
断言测试什么
两个事实导致了大多数误报和静默遗漏。
缺失的值会通过。
几乎所有的断言在它检查的值缺失时都会通过。
在 property: type 上使用 const: array 会匹配一个类型为 array 的模式,但也会匹配所有没有 type 的模式。
因此,当一个 where 条目测试一个属性值时,如果该属性可能缺失,请在值断言旁边添加 defined: true。
存在不等同于值。
defined、required、requireAny、disallowed 和 mutuallyExclusive 测试键是否存在。
它们对内容什么也不说,所以 description 上的 defined: true 对于 description: '' 也会通过。
相反,断言值:nonEmpty: true 会拒绝空字符串,而 const、enum 和 pattern 测试内容。
这是实践中最常见的缺陷。
它在审查时看起来正确,只有持有 '' 的违反夹具才能揭示它。
这两个事实的两个结果:
const: <value>也是表达“此字段必须为假值”的方式,因为断言对于该值和缺失字段都成立。requireAny只计算键。 “至少这些字段中的一个,且不为空”没有断言形式。这种检查是真正的插件案例。
断言检查什么
property: name— 主题节点上该字段的值。property: [name, url, email]— 分别检查每个列出的字段。 每个失败的字段都会变成自己的问题,带有自己的 JSON 指针,例如#/info/contact/name。property缺失 — 主题节点的键。 例如,subject: { type: Paths }配合assertions: { notPattern: /.+\/$/ }会在路径字符串本身上运行。
定位主题,然后门控
三个旋钮定位一个规则。按此顺序调整它们。
1. 主题:拥有你要断言内容的节点。
- 关于映射所持有键的检查,目标对象是映射类型本身。
响应、标头、模式属性和路径的映射各自都是单独的类型。
因此,“此映射必须持有该键”是一个主题上的一个
required。不要搜索父节点。 - 关于一个值的检查,目标对象是持有该字段的节点,加上
property。 type: any配合property会在它出现的每个地方检查该属性。- 使用
inspect-node-types确认类型名称,使用该规则目标的规范版本的文档。 一个命名类型可以在一个版本中存在,在另一个版本中不存在。HeadersMap是 OpenAPI 3 中的一个类型。在 OpenAPI 2 中,响应headers映射是匿名的,没有类型名称,所以oas2Rules下的HeadersMap主题从不报告。 Redocly 会警告未解析的规则 ID,但不会警告未解析的subject.type,因此错误的类型会给你一个从不报告的规则。