jsonvetdocsjsonvet design

playgroundthis page on GitHub

jsonvet design

What jsonvet promises, and why it is built the way it is. How to use it is in the reference. Each choice where we turned down a measured or argued alternative has a record in decisions/, with the numbers. Items marked since 3.0 changed after 2.0.0.

The contract with 1.4

jsonvet 2.0 replaced check-json5 1.4 (Python) in place, with one hand-written parser (0001). These stay the same:

These change, and the CHANGELOG marks them Breaking:

A syntax error points at the first character that cannot continue the document, or at the end of the input. Two point earlier: a missing , points just after the value before it, and a number with a leading zero at that zero. A word such as True is named whole, at its first letter. Any difference from 1.4 that is not listed below is a bug.

Known differences from 1.4

scripts/differential.py compares jsonvet with check-json5 1.4.1 file by file. These differences are intended, besides the line ends and paths above:

Speed

Median of 40 runs of jsonvet from uv run scripts/bench.py, on 4 cores of an EPYC 9V74 under Linux (2026-11-11), of 10 for 1.4.1 and of 3 for 1.0.1. Every tool gives the same verdict in every case:

Case check-json5 1.0.1 check-json5 1.4.1 jsonvet 3.4 Speed-up over 1.4.1 over 1.0.1
1,100 config files 2,732 ms 48 ms 6.6 ms 7x 417x
2.7 MB JSON 15,145 ms 53 ms 9.1 ms 6x 1,657x
2.7 MB JSON, duplicate key 14,888 ms 488 ms 12.5 ms 39x 1,189x
120 KB JSONC 738 ms 28 ms 1.7 ms 16x 422x
1.8 MB JSON5 17,252 ms 17,087 ms 8.9 ms 1,916x 1,934x

1.4 sent strict JSON and JSONC to Python's C parser, so those were already fast, and everything else through pure Python. 1.0.1, the original hook that pre-commit configs pin from GitHub, sent every file through pure Python. jsonvet reads each file once with one parser, and checks files on every core.

A JSON Lines file of 1 MB or more is cut at newlines into parts, one for each core that is free, and the parts are checked at once. That is safe because every raw newline ends a record: a JSON string must escape it, and JSON Lines has no comments. Each part counts offsets from the start of the file and stops at its first syntax error, so the output matches one pass. With --schema, each line is its own document and the error cap counts per document, so the parts' errors joined in order are the ones one pass finds.

A speed-up must measure faster with the same output, and at least 5% faster end to end if it adds a dependency, unsafe or a slower build (0004).

Dialects

One parser covers six dialects. What each accepts is in the README and the reference. Three choices behind them:

Rules

Every problem belongs to a rule with a default severity per dialect (README). The I-JSON rules leave alone the two cases RFC 7493 leaves open: a number that underflows to zero (1e-400), and an integer written with a fraction or an exponent past 2⁵³ (9007199254740993.0).

jsonvet writes SARIF and JSON by hand, so every format escapes strings by one rule (0007). SARIF holds its results until the end. JSON and text write each problem as they get it, but the library holds a file's problems until its report is done, about 110 bytes each: 20 million duplicate keys in a 120 MB file took 2.3 GB.

Inline suppressions

per-file-rules sets a severity for the files a glob matches, but not for one line. In jsonc, jwcc and json5, a comment // jsonvet-ignore RULE turns a rule off for the next line that starts with a token, and the comment can say why after --. Because a comment can outlive the problem it hid, the unused-suppression rule reports one that hides nothing. A rule that is always an error or is about the whole file cannot be suppressed; set the latter in per-file-rules. The code is crates/jsonvet/src/suppress.rs, and usage.md lists the details.

Output

File names and messages come from the files you check, so jsonvet escapes anything that could break the line or drive a terminal:

Text output, code frames and annotations can be translated, and the SARIF and JSON documents stay English (0013). SARIF and JSON strings escape the same characters the same way. An annotation's file= value is the escaped name, then percent-encoded as GitHub requires.

A code frame also prints a line of the file. There, each of those characters prints as U+FFFD instead, so the line keeps one character per character of the file and the marks under it line up. A frame shows at most 160 characters each side of what it marks, with … where it cuts, so a minified file is not copied whole for each problem on its one line.

--schema

schema is a rule like the others, but it belongs to the CLI: the library knows no schemas. jsonschema runs with external references disabled in every build. Only --schema-catalog enables HTTPS downloads and a local cache (0016). Schemas a project picks stay inside it and quote nothing in their errors (0012).

The library's parse_with hands the CLI each value as an event with its byte offset. The CLI builds its own tree of the document from the events, which borrows strings and keys from the text, and validates each document as soon as it is complete, keeping no events (0014). When a document has errors, the CLI looks up each error's instance path in that tree and reads the offset of the value there. A path reads the same for [0] and ["0"], so a segment counts as an index only where it steps into an array. Messages name the value by JSONPath, through the same code as duplicate-key.

A crafted schema or file can cost far more than its size. The limits are fixed caps, not a timeout, so a file gets the same verdict on every machine (0010). Their values are in the reference. Why each exists:

Configuration

Each file takes the nearest config file, which replaces any above it (0005). Where the search stops, and why a bad config shows no value, is in 0009. The rules users see are in the reference.

Config, schema and ignore files are read only when they are regular files under a size cap, as checked files are: a committed link to /dev/zero would fill memory, and a FIFO would hang the run. A config file may be a symlink that leads anywhere on the machine, so its errors show the line and column only. The ignore crate reads .gitignore and .ignore with no such check, so the walk checks those two in each folder before it enters it.

Architecture

crates/jsonvet/        library: the parser, rules and positions. Depends on hashbrown, memchr and
                       unicode-properties.
crates/jsonvet-cli/    binaries jsonvet and check-json5: arguments, config, output, --schema
                       (jsonschema, serde_json) and the language server, `jsonvet lsp`
                       (lsp-server). Built into the PyPI wheels by maturin.
crates/jsonvet-wasm/   the library as WebAssembly for the playground: `check`, `fix`, `format` and `version`
editors/vscode/        the VS Code extension: a thin client for `jsonvet lsp`, in TypeScript
playground/            the browser playground: a static page around the wasm. Deployed by pages.yml.
compat/check-json5/    the check-json5 package on PyPI: depends on jsonvet, nothing else
setup.py               placeholder that pre-commit installs: pins the jsonvet wheel
action.yml             the GitHub Action: installs the jsonvet wheel from PyPI and runs it
fuzz/  tests/  scripts/

The library interface

The public API is check, Config, Dialect, UnknownDialect, Rule, Severity, UnknownRule, Location, Report, Problem, Related and RelatedKind (docs.rs). Each type is #[non_exhaustive] or has private fields, so a new variant or field is not a breaking change. The #[doc(hidden)] items are public for jsonvet-cli only, and can change in any release: parse, parse_with, parse_lines, join_lines, line_parts, Lines, Event, EventKind, json_path, PathStep, key_text, did_you_mean, closest, MESSAGES, fill, Problem::message_with, is_hidden, fix, Fix, Fixed, try_fix, try_fix_at, report, format, FormatError, FormatOptions and Indent.

format is the formatter behind jsonvet fmt. It checks the text first and returns the report if there is a syntax error. Then it reads its own token stream, so the check path is unchanged: it attaches each comment to the token it sits next to, and lays the tree out from the tree alone, so a second run changes nothing. The tree is flat vectors of 16-byte records with 32-bit offsets into the text, a first pass counts the output so it is built once at its size, and a file whose output would pass 16 times its size is refused. It recurses once per nesting level, which max_depth bounds.

The text is &str, so every offset falls on a character boundary. check never panics and has no error mode: the problems in the file are its output. A Report is Send but not Sync, because location caches line starts inside it.

The parser keeps an explicit stack of frames, not recursion, so the depth limit is a setting and not the call stack's size. Past 32 keys, an object's keys go through a hash index keyed with std's SipHash from the OS, so a crafted file cannot make the check quadratic (0003). There is no unsafe code, so the worst a crafted file can do is a panic (0002).

Python packaging

pre-commit installs a language: python hook with pip install .. The root setup.py is a placeholder that depends on the exact released jsonvet wheel, so a hook install downloads a binary and never compiles Rust (0008). maturin builds the wheels with bindings = "bin", for Linux (glibc 2.28 or newer, and musl), macOS and Windows, each on x86-64 and arm64.

Testing

How to run each layer is in CONTRIBUTING.md. CI runs on Linux, macOS and Windows, and on the oldest supported Rust (1.88). What each layer is for:

Publishing

A pushed vX.Y.Z tag starts release.yml, not a published GitHub release, so the release can be immutable. The workflow publishes to PyPI and crates.io by trusted publishing, then publishes the GitHub release it drafted, with the binary archives, the SBOM and SHA256SUMS. The archives hold the binaries from the wheels, so PyPI and the GitHub release ship the same bytes. Every file gets a build provenance attestation (SECURITY.md). The binaries are not built with cargo auditable, which maturin cannot run without a cargo wrapper in every build container, so the SBOM lists the dependencies instead.

Social card

playground/og.png (1200x630) is rendered from docs/og-card.html, not drawn by hand. Edit the HTML and run, from the repository root, with Playwright's Chromium:

playwright screenshot --viewport-size=1200,630 file://$PWD/docs/og-card.html playground/og.png
sed -i.bak "s|og.png?v=[0-9a-f]*|og.png?v=$(shasum -a 256 playground/og.png | cut -c1-8)|" playground/index.html
rm playground/index.html.bak

The sed line sets the ?v= on the og:image URL to the PNG's hash. Link previews are cached by image URL, so without it Facebook, Slack, Discord and iMessage keep the old card. playground/og.test.mjs fails when the two disagree.

Link previews show it at 300-500 px and some crop to the centred 630 px square, so the name, the snippet and the address sit in that square at 36 px or larger. The snippet is the duplicate-key example from the playground's sample text. The serif falls back to Liberation Serif where Charter is missing.

Dependencies and pins

Every version falls in one of five groups, which .github/renovate.json5 encodes.

Not supported

JSON text sequences (RFC 7464) and Hjson. Hjson's unquoted strings need a tokenizer of their own.