@amritk/lint

中文文档
0.10.0发布于 2026/10/27MITTypeScriptES Module构建来源已验证近 30 天下载 5.9万

A fast, format-agnostic JSON/YAML style-guide linter with JSON Schema and custom rules.

原始使用文档
文档目录18 节
  1. Overview
  2. Installation
  3. Usage
  4. Lint a document
  5. Lint data you already parsed
  6. Validate against a JSON Schema
  7. Custom functions and extends
  8. Trust boundary — who may write a ruleset
  9. Auto-fix
  10. Rendering findings
  11. CLI
  12. Ruleset format
  13. given — the JSONPath subset
  14. API
  15. OpenAPI ruleset
  16. AsyncAPI ruleset
  17. Benchmarks
  18. License

@amritk/lint

A fast, format-agnostic JSON/YAML style-guide linter — JSON Schema validation and custom rules, with exact line:column findings.

status  version  license  JSON Schema  node  vibe coded


Overview

@amritk/lint lints any JSON or YAML document against a ruleset you define. A rule matches nodes with a JSONPath (given) and runs a function (then) over each match — structural validation against a JSON Schema, style checks (casing, pattern, alphabetical, length, …), or your own custom function. Every finding carries an exact line:column range, because the parser keeps source positions on every node.

It is format-agnostic: the core engine ships no built-in ruleset and knows nothing about OpenAPI or any other schema — you bring the rules. This is JSON/YAML style-guide linting with JSON Schema and custom rules at its core.

For OpenAPI and AsyncAPI specifically, the @amritk/lint/rules/openapi and @amritk/lint/rules/asyncapi subpaths ship ready-made presets on top of that engine — see OpenAPI ruleset and AsyncAPI ruleset below.

The CLI lives in the mjst binary as mjst lint; this package is the programmatic library behind it.


Installation

npm install @amritk/lint
# or
pnpm add @amritk/lint
# or
bun add @amritk/lint

For the command line, install @amritk/mjst and run mjst lint (see CLI below).


Usage

Lint a document

import { lintDocument } from '@amritk/lint'

const ruleset = {
  rules: {
    'require-name': { given: '$', severity: 'error', then: { field: 'name', function: 'truthy' } },
    'name-kebab': { given: '$.name', severity: 'warn', then: { function: 'casing', functionOptions: { type: 'kebab' } } },
  },
}

const findings = await lintDocument('version: 1\n', { ruleset, source: 'service.yaml' })
// → [{ code: 'require-name', message: 'The value must be truthy', path: ['name'],
//      severity: 0, source: 'service.yaml', range: { start: { line: 0, character: 0 }, … } }]

severity is a DiagnosticSeverity — 0 error, 1 warning, 2 info, 3 hint — and range is a zero-based { line, character } span you can render a squiggle from.

Lint data you already parsed

A caller that has the document parsed already — a loader that read and normalized it — passes the data instead of the text, and the parse is skipped:

const findings = await lintDocument({ data: spec, text: source }, { ruleset, source: 'openapi.json' })

text is optional and only read to put line:column ranges on findings, lazily: JSON text is scanned for just the paths that carry findings, YAML text is parsed on the first lookup. A path the caller's normalization added resolves to its closest ancestor in the text, and without text every range is the zero range. Since the data is not parsed, there are no parser diagnostics: parserOptions (duplicateKeys, incompatibleValues) and syntax errors are only reported when linting the text. Plugins that rewrite the document (auto-fix) need text. createDocumentFromData(data, { text }) builds the same document for the lower-level createLinter(ruleset).run().

Validate against a JSON Schema

The built-in schema function runs an arbitrary JSON Schema (Draft 2020-12) over the matched node, via @amritk/runtime-validators:

const ruleset = {
  rules: {
    'config-schema': {
      given: '$',
      severity: 'error',
      then: { function: 'schema', functionOptions: { schema: { type: 'object', required: ['port'], properties: { port: { type: 'integer' } } } } },
    },
  },
}

await lintDocument('port: not-a-number\n', { ruleset })
// → a `config-schema` finding pointing at `port`

Custom functions and extends

A ruleset can pull in rules from another file and load custom functions by name (resolved relative to the ruleset that declares them):

# .lint.yaml
extends:
  - ./base.yaml            # a file path or an npm package
functions: [no-secrets]    # loaded from ./functions/no-secrets.{js,cjs,mjs}
rules:
  no-secrets:
    given: $..*
    severity: error
    then: { function: no-secrets }
import { lintDocument } from '@amritk/lint'

await lintDocument(source, { ruleset: definition, rulesetBasePath: '/path/to/config/dir', source: 'doc.yaml' })

A custom function has the signature (value, options, context) => { message: string, path?: JsonPath }[].

Trust boundary — who may write a ruleset

A document being linted is untrusted input: it is parsed, walked, and matched, and nothing in it can execute code. A ruleset is not input — it is configuration, and it is as privileged as the process running the linter:

  • extends accepts any path. rulesetBasePath is where resolution starts, not a fence: ../../../elsewhere/rules.yaml and /etc/rules.yaml both resolve and load.
  • extends of a .js / .cjs / .mjs file, and every custom function named in functions, is required — that is code execution, by design. This is how custom functions work.
  • YAML/JSON rulesets are data: their given filter expressions ([?(...)]) are parsed and interpreted, never evaluated as JavaScript, so a data-only ruleset cannot run code. A ruleset that names a .js file still can.

So: load rulesets from sources you would trust to run a script. If you accept a ruleset from somewhere less trusted (a multi-tenant service, a PR from a fork), pass restrictTo — an optional root that every extends target and custom function must resolve under:

await lintDocument(source, {
  ruleset: definition,
  rulesetBasePath: '/srv/rulesets/tenant-42',
  restrictTo: '/srv/rulesets/tenant-42', // refuse anything resolving outside this tree
})

restrictTo is off by default and narrows which files a ruleset can name. It is not a sandbox: a .js file inside the permitted root still runs with full privileges. The OpenAPI preset takes the same option — createOpenApiRuleset(definition, basePath, { restrictTo }).

One thing restrictTo does not cover: a regular expression a ruleset writes — in pattern's match/notMatch, or as a literal inside a [?(...)] filter — is compiled and run against text from the document. An ambiguous pattern (nested quantifiers over overlapping character classes) can backtrack catastrophically on an input crafted to trigger it, so a hostile document can hang the linter through a regex the ruleset provided. The built-in rules avoid such patterns deliberately (see the note in casing); keep the same discipline in rules you write, and prefer anchored patterns with unambiguous alternatives.

Auto-fix

fixDocument runs the linter and applies a FixerRegistry — fixers keyed by rule code that map a finding to a formatting-preserving text edit — to a fixpoint, then reports the findings that remain:

import { fixDocument, type FixerRegistry } from '@amritk/lint'

const fixers: FixerRegistry = {
  'no-trailing-slash': {
    fix: ({ diagnostic, data }) => {
      const value = (data as Record<string, unknown>)[diagnostic.path[0] as string]
      return typeof value === 'string' ? { op: 'setValue', path: diagnostic.path, value: value.replace(/\/$/, '') } : undefined
    },
  },
}

const { output, applied, remaining } = await fixDocument('host: api.example.com/\n', { ruleset, fixers })
// output === 'host: api.example.com\n'

The engine ships no built-in fixers (rule codes are yours to define), so the default registry is empty and fixDocument is a no-op until you supply one.

options.ruleset takes either a definition (built here with the built-in functions) or a Ruleset you built yourself. Pass a built one whenever the rules need functions or format detectors the package root does not have — an OpenAPI preset, for instance, whose rules would otherwise all be skipped as unknown-function/unmatched-format:

import { fixDocument } from '@amritk/lint'
import { createOpenApiRuleset, oasFixers } from '@amritk/lint/rules/openapi'

const { output, applied } = await fixDocument(source, {
  ruleset: createOpenApiRuleset(),
  fixers: oasFixers,
})

Fixing runs to a fixpoint, capped at 10 passes. remaining comes from the pass that found nothing left to fix, so a run that converges does not lint the fixed document a second time; only one that hits the cap does. The result reports how that ended: converged is false when the cap was hit while the document was still changing (usually two fixers undoing each other), and passes counts the passes that changed something. applied is de-duplicated by rule code and path, so a report can safely say "fixed N problems".

Rendering findings

lintDocument returns structured IDiagnostic[] — each with a code, message, path, severity, source, and a zero-based range. source, path and range all describe the same node in the same document: the one the author wrote. A rule that runs against the dereferenced tree matches inside an inlined $ref target, and its finding is reported at the declaration that target came from, not at the $ref site. Two $refs to the same mistake therefore produce one finding, not two. Rendering is the caller's job: print them, serialize them to JSON, or map them to whatever your editor or CI consumes. The linter deliberately ships no output "formatter" layer (that is not the same thing as prettier/biome format, which reformat source).

const findings = await lintDocument(source, { ruleset, source: 'doc.yaml' })
for (const f of findings) {
  const { line, character } = f.range.start
  console.log(`${f.source}:${line + 1}:${character + 1}  ${f.code}  ${f.message}`)
}

CLI

The mjst binary exposes the linter as a subcommand, which prints a compact file:line:col report:

mjst lint "**/*.{yaml,json}" -r .lint.yaml

With no -r, it discovers a .lint.{yaml,yml,json,js,mjs} ruleset by walking up from each file. The exit code is derived from --fail-severity (default error). See the CLI README for the full flag reference.


Ruleset format

A ruleset is a plain object (authored as YAML, JSON, or a JS module):

FieldDescription
rulesMap of name → rule. A rule has given (one or more JSONPath expressions), then (a function to run, or a list), severity (error/warn/info/hint/off), and optional message, description, formats, recommended, resolved (default true; false runs the rule against the document as written, before $ref dereferencing), descent (default all; schema keeps .. out of example, examples, default, const and enum values, so a rule about schemas does not match an example payload), and documentationUrl.
then{ function, field?, functionOptions? } — field narrows the match to a child (@key targets the property name).
extendsA ruleset (or list) to inherit rules from: a file path or npm package. [target, 'recommended' | 'all' | 'off'] controls what it contributes.
functions / functionsDirCustom functions to load by name (default dir functions/).
overridesPer-file-glob rule tweaks.
aliasesReusable given fragments referenced as #alias.
parserOptionsYAML parser strictness, each set to a severity. duplicateKeys reports a repeated key (default error; off allows it, and the last value wins). incompatibleValues is off by default and reports .nan / .inf / -.inf, which JSON.stringify silently turns into null. Parser findings carry the code parser.

Built-in functions: alphabetical, casing, defined, enumeration, falsy, length, or, pattern, schema, truthy, undefined, unreferencedReusableObject, xor, typedEnum.

given — the JSONPath subset

$, child and ['child'] access, .. recursive descent, [*] / .* wildcards, [a,b] unions, [n] indices (negative counts from the end), [start:end:step] slices, [(@.length-1)], ^ (parent) and ~ (property name), and [?(...)] filters.

Filters are parsed and interpreted, not evaluated as JavaScript — a ruleset is data and cannot execute code. The supported grammar:

Context@, @.name, @['name'], @[0], @property, @parentProperty, @parent, @path, @root, $
Operators=== !== == != < <= > >= && || ! - (negation), parentheses
Literalsstrings, numbers, true, false, null, undefined, void 0, /regex/flags
Members & calls.length, .indexOf(…), .lastIndexOf(…), .includes(…), .startsWith(…), .endsWith(…), .match(/re/), /re/.test(…), .toLowerCase(), .toUpperCase(), .trim()

Anything outside that grammar is a ruleset error (createRuleset throws and names the rule) rather than a filter that quietly matches nothing. Member reads see own properties only, so @.constructor and @['__proto__'] are plain undefined.


API

ExportWhat it does
lintDocument(input, options?)Parse input and lint it against options.ruleset — a definition, or a Ruleset you already built; returns IDiagnostic[]. input is the text, or { data, text? } for a document already parsed.
lintDocumentWithResult(input, options?)Like lintDocument, but returns { diagnostics, output?, pluginData } (including any plugin's rewritten output).
fixDocument(input, options?)Lint and apply options.fixers to a fixpoint; returns { output, fixed, applied, remaining, converged, passes }.
createRuleset(definition?, basePath?, options?)Normalize a ruleset definition into a runnable Ruleset, layering the built-in functions and resolving extends. Memoized per (definition object, basePath, restrictTo) — treat a definition you have passed in as frozen.
resolveNamedRuleset(name, basePath?, options?)Resolve an extends reference (file path or npm package) to its definition.
builtinFunctionsThe registry of built-in rule functions.

The engine internals (createDocument, lint, lintWithResult, query, validateRuleset, parseWithPointers, createFixPlugin, …) are re-exported from the package root for advanced use; DiagnosticSeverity and the rule/diagnostic types (IDiagnostic, RulesetDefinition, IRuleDefinition, …) live on the @amritk/lint/types subpath.


OpenAPI ruleset

The core package is format-agnostic, but OpenAPI is common enough to ship a ready-made preset. It lives at the @amritk/lint/rules/openapi subpath — a self-contained layer on top of the engine that adds no dependencies beyond what @amritk/lint already uses.

import { lint } from '@amritk/lint'
import { createOpenApiRuleset } from '@amritk/lint/rules/openapi'

// Defaults to `extends: [oas]` (recommended rules only, like `spectral:oas`).
const ruleset = createOpenApiRuleset()
const findings = await lint(spec, { ruleset })

createOpenApiRuleset(definition?, basePath?, options?) builds a runnable Ruleset with the OpenAPI functions and format detectors layered over the built-ins, and with extends resolution that understands the oas / loupe:oas / spectral:oas names (the last two accepted so existing Spectral-style rulesets extend unchanged). Enable every rule with createOpenApiRuleset({ extends: [['oas', 'all']] }), or pass your own definition to override severities, add rules, or point extends at a file/npm package. options.restrictTo applies the same trust boundary the package root's restrictTo does.

ExportWhat it does
createOpenApiRuleset(definition?, basePath?, options?)Build a runnable OpenAPI Ruleset (functions + formats + extends resolution).
resolveOpenApiRuleset(name, basePath?, options?)Resolve an extends reference, including the oas / loupe:oas / spectral:oas names.
oasThe built-in OpenAPI ruleset definition.
oasFunctions / allFunctionsThe OpenAPI-specific functions; allFunctions = built-ins + OpenAPI.
oasFormatsOpenAPI version detectors (oas2, oas3, oas3.0, oas3.1, oas3.2).
oasFixersAuto-fixers for the mechanically-repairable OpenAPI rules (pass to fixDocument alongside a built OpenAPI ruleset).
loadOasSchema(version)Lazily load one OpenAPI version's official structural meta-schema ('2.0' / '3.0' / '3.1' / '3.2'), vendored as raw .json from spec.openapis.org (3.0/3.1/3.2 verbatim; 2.0 with its external draft-04 metaschema refs inlined). See schemas/README.md.

The structural rules validate against the official spec.openapis.org meta-schemas, vendored as raw .json (schemas/). 3.0/3.1/3.2 are byte-for-byte verbatim; only 2.0 differs (its external draft-04 metaschema refs are inlined, since the offline interpreter never fetches remote refs, and its top-level id/$schema keys are dropped). OpenAPI 3.1/3.2 express Schema Objects as JSON Schema 2020-12 via a local $dynamicRef/$dynamicAnchor, which @amritk/runtime-validators resolves natively — so the whole document envelope is validated against the official schema with no bundling or dialect engine, while Schema Object internals stay permissive.

$ref resolution stays the caller's job: the preset doesn't pull in a resolver, so for rules that need the dereferenced document (resolved: true) pass a resolve function to the core lintWithResult (for example wrapping @amritk/resolve-refs). The mjst lint CLI already wires one up.


AsyncAPI ruleset

The same treatment for event-driven APIs. The @amritk/lint/rules/asyncapi subpath ships a preset covering AsyncAPI 2.0–2.6 and 3.0, layered on the same engine and adding no dependencies.

import { lint } from '@amritk/lint'
import { createAsyncApiRuleset } from '@amritk/lint/rules/asyncapi'

// Defaults to `extends: [asyncapi]` (recommended rules only, like `spectral:asyncapi`).
const ruleset = createAsyncApiRuleset()
const findings = await lint(document, { ruleset })

createAsyncApiRuleset(definition?, basePath?, options?) mirrors its OpenAPI counterpart: functions and format detectors layered over the built-ins, and extends resolution that understands the asyncapi / loupe:asyncapi / spectral:asyncapi names (the last two so existing Spectral-style rulesets extend unchanged). Enable every rule with createAsyncApiRuleset({ extends: [['asyncapi', 'all']] }). options.restrictTo applies the same trust boundary.

ExportWhat it does
createAsyncApiRuleset(definition?, basePath?, options?)Build a runnable AsyncAPI Ruleset (functions + formats + extends resolution).
resolveAsyncApiRuleset(name, basePath?, options?)Resolve an extends reference, including the asyncapi / loupe:asyncapi / spectral:asyncapi names.
asyncapiThe built-in AsyncAPI ruleset definition.
aasFunctions / allFunctionsThe AsyncAPI-specific functions; allFunctions = built-ins + AsyncAPI.
aasFormatsAsyncAPI version detectors (aas2, aas2.0–aas2.6, aas3, aas3.0).
loadAsyncApiSchema(version)Lazily load one version's official structural meta-schema ('2.0'–'2.6', '3.0'). See schemas/README.md.
ASYNCAPI_VERSIONS / LATEST_ASYNCAPI_VERSION / asyncApiSchemaVersion(declared)The bundled versions, the newest one, and the mapping from a document's asyncapi field to the minor whose schema covers it ('2.6.4' → '2.6'; an unbundled minor → undefined).

Both majors are covered, and the rules are gated by format so a 2.x document never picks up a 3.x rule. The 3.0-specific ones carry an asyncapi-3- prefix, matching Spectral's names so a .spectral.yml that re-severities individual rules keeps working.

The structural rules validate against the official asyncapi/spec-json-schemas meta-schemas, vendored as raw .json (schemas/). These are draft-07 documents that reference their own subschemas by absolute $id URI rather than by JSON Pointer — nothing is fetched, because every target is declared inside the same document. Each file is byte-for-byte upstream apart from three regular expressions, one of which is genuinely exponential on a failing input; each is replaced by a provably equivalent pattern with a single unambiguous quantifier, and the test suite asserts the equivalence over a generated corpus. See the schemas README.

Structural validation runs once per document, against the document as written (resolved: false), matching the oas*-schema rules above. That is a deliberate choice rather than an oversight: validating the dereferenced tree instead re-checks every components entry once per $ref that reaches it, so a single mistake in a reusable message reported three times in a document that used it twice. The trade-off is what a $ref hides: content pulled in from another file is not structurally checked, and neither is a same-file reference that points at the wrong kind of object (a messages entry aimed at components.schemas, say) — the reference itself is well-formed, and only the dereferenced tree would show the mismatch. That is the same gap the OpenAPI preset has. Everything written in the document itself, components included, is validated exactly once.


Benchmarks

The bench/ suite pits @amritk/lint head-to-head against Spectral — the OpenAPI linter this package is modelled on (hence the spectral:oas alias) — over the real-world specs the test suite lints: Swagger's petstore, the DigitalOcean API, and the OpenAI API, spanning a small config and a genuinely large document. Both do the same job: parse → dereference internal $refs → run their recommended OpenAPI ruleset (mjst dereferences in memory with @amritk/resolve-refs, exactly as the CLI does; Spectral uses its own default resolver). Run it with bun run bench (Bun) or bun run bench:node (Node).

@amritk/lint finishes each document several times faster than Spectral on both runtimes. The lead is smaller on Node, because Spectral runs materially faster on V8 than on JavaScriptCore while this linter is close to even between the two.

Each figure is the wall time of one whole pass — every rule, not a subset — dominated by real work: JSONPath matching, the rule functions, and the dereference pass. A fresh document is parsed on every iteration on both sides, matching how the tools are actually called. The finding counts differ because the two rulesets are not byte-identical (different rule implementations and $ref resolution), so this is a throughput comparison rather than a correctness parity check — but on petstore both land on the same findings. Spectral's JSONPath engine (nimma) has thrown on the OpenAI spec under some Bun versions, so the bench reports that side as errored rather than aborting the run.

Large documents. bun run bench:large <document.json> times each phase of a lint run on its own — the source-mapped parse, the JSONPath rules, a hand-written walk doing the same checks, lint(text) against lint({ data, text }), and the recommended preset with $refs resolved — against any large OpenAPI document (or a synthetic one built from the fixtures when none is given), and fingerprints the findings so two runs can be checked for identical output.

Assembling the ruleset is timed separately, because a process pays it once and then lints many documents: createOpenApiRuleset (compiling every rule's JSONPath and wiring up functions and format detectors) is cheaper than new Spectral() + setRuleset(oas) on both runtimes.


License

MIT

版本与发布记录

@amritk/lint 共发布 28 个版本,首次发布于 2026/07/08;过去 12 个月发布了 28 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.10.0latest2026/10/2710 天前
0.9.02026/10/2413 天前
0.8.02026/10/2116 天前
0.7.12026/10/1027 天前
0.7.02026/09/261 个月前
0.6.22026/09/241 个月前

关于 @amritk/lint 的常见问题

@amritk/lint 是什么?

A fast, format-agnostic JSON/YAML style-guide linter with JSON Schema and custom rules.

如何安装 @amritk/lint?

在项目目录运行 npm install @amritk/lint,或使用 pnpm add / yarn add / bun add。

@amritk/lint 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@amritk/lint 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@amritk/lint 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@amritk/lint 可以商用吗?

@amritk/lint 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@amritk/lint 的下载量有多少?

根据 npm 官方下载统计,@amritk/lint 在 2026/10/06 – 2026/11/04 这 30 天内被下载 59,406 次,日均约 1,980 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@amritk/lint 还在维护吗?

最新版本 0.10.0 发布于 2026/10/27(10 天前);至今共发布 28 个版本,其中过去 12 个月发布了 28 个;首次发布于 2026/07/08。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。