Skip to content

Structured queries

alint reads inside config files, not just at them. It parses JSON, YAML, TOML, XML, dotenv, properties, INI, and HCL into one common Value tree, then runs a single RFC 9535 JSONPath query over that tree. The payoff: the same query mental model covers all eight formats. A host nested under a server table is read by $.server.host whether the file is JSON, YAML, TOML, or XML, even though those files share not one character of syntax.

A JSONPath dissected, the same query resolving across five formats, and all eight supported formats A path like $.server.host breaks into a root token, a member token, and a leaf token, with extra selectors for index, wildcard, recursive descent, and bracketed keys. The one query $.server.host resolves the value db1 from json, yaml, toml, and xml, while a flat format like dotenv uses the whole key SERVER_HOST as the path. alint parses all eight formats -- json, yaml, toml, xml, ini, dotenv, properties, hcl -- into one Value tree, then asserts with equals, matches, or absent. one query, any format RFC 9535 JSONPath anatomy of a path $ .server .host root member leaf also: [0] nth, [*] all, .. any depth, ['a-b'] odd keys the same query, any file shape json {"server":{"host":"db1"}} $.server.host yaml server: {host: db1} $.server.host toml server.host = "db1" $.server.host xml <server><host>db1</host> $.server.host dotenv SERVER_HOST=db1 $.SERVER_HOST dotenv and properties are flat: the key is the path eight parsers, one Value tree json yaml toml xml dotenv properties ini hcl JSON, YAML, TOML, HCL keep number and boolean types XML, INI, dotenv, properties give string-typed leaves then assert equals "db1" matches /db\d/ absent

Every structured-query rule is named <format>_path_<op>: json_path_equals, toml_path_matches, yaml_path_absent, and so on across the eight formats. Under the hood they all parse the file into one common Value tree, then evaluate a JSONPath against it. JSON, YAML, and TOML coerce through serde; XML maps in an xmltodict style; dotenv, properties, and INI become key-value maps; and HCL nests blocks by type and labels. The query language is learned once and transfers, but the path depends on how each format shapes the tree:

formatfile contentquery
JSON / YAML / TOML / HCLversion: "1.2" (in that format)$.version
XML<pkg><version>1.2</version></pkg>$.pkg.version
INI[pkg] then version = 1.2$.pkg.version
dotenvVERSION=1.2$.VERSION
propertiesversion=1.2$.version

Four things do not transfer unchanged: XML wraps everything in its root element (with an element’s attributes read as @name keys and its text as #text), so the root name is the first path segment; INI sections are a level (keys before any section hoist to the top); dotenv keys keep their casing (usually upper); and, as below, the flat formats and XML give you string-typed leaves.

  • equals asserts the value at the path equals a literal, keeping its type: equals: 8080 matches the number and equals: "8080" matches the string. They are not interchangeable.
  • matches applies a regex, and only to string values. A numeric or boolean node is not a match target.
  • absent asserts the query selects nothing; any match is a violation. It is the mirror of equals / matches, for “this key must not be set.”

String-typed leaves. In JSON, YAML, TOML, and HCL a value keeps its type, so equals: 8080 matches the number 8080. But XML, dotenv, properties, and INI have no type system: every leaf is a string, so there equals: 8080 (a number) silently never matches and you must write equals: "8080". This is the one place the “one mental model” leaks, and it bites quietly.

Cardinality. A JSONPath can select zero, one, or many nodes. For equals and matches, every selected node must satisfy the op, and selecting zero is itself a “path not found” violation, unless you set if_present: true (which passes silently on zero matches and checks only the nodes that exist). In XML a single child is a scalar, not a one-element list, so $.items.item[*] reads nothing when there is exactly one <item>; reach it with recursive descent ($..item) instead. HCL and INI share this shape-shift: a repeated block or key becomes a list, but a lone one stays a scalar or object, so an index or [*] that works on many entries reads nothing when there is exactly one.

Keys with dashes or dots. Dot notation stops at a dashed or dotted key, so $.scripts.pre-commit and $.db.host (a single dotted properties key) do not resolve. Use bracket notation: $.scripts['pre-commit'], $['db.host'].

Parsing. A .json file may carry comments and trailing commas (JSONC, for tsconfig.json and friends) and still parses. An empty file parses to {}, which matters for *_path_absent and if_present. And a file that will not parse is one parse-error violation for that file, never a silent skip.

Pin two versions the same way across two formats: the Node engine in package.json (JSON) and the Python floor in pyproject.toml (TOML), reaching a dashed key with bracket notation.

version: 1
rules:
- id: node-engine-pinned
kind: json_path_matches
paths: ["package.json"]
path: "$.engines.node"
matches: '^>=?\d+'
level: error
message: "package.json must pin engines.node"
- id: python-requires-pinned
kind: toml_path_matches
paths: ["pyproject.toml"]
path: "$.project['requires-python']"
matches: '>='
level: error
message: "pyproject.toml must pin requires-python"

One JSONPath dialect, two file formats. On a repo where both are unpinned:

error node-engine-pinned package.json must pin engines.node
error python-requires-pinned pyproject.toml must pin requires-python
  • Cross-file rules reuse this query engine through the shared extract: extractor.
  • Rules lists every <format>_path_<op> kind, its options, and the per-format sharp edges.
  • Configuration covers paths: and the common rule fields.