Skip to content

Cross-file rules

Most rules judge one file at a time. Cross-file rules judge a relationship between files, so the verdict for one file depends on what else is in the tree. That is why most of them read the whole tree even under --changed, opting out of diff filtering on purpose: deleting one file can break an invariant about another the diff never touched. (A few that are intrinsically about the diff, like pair_changed_together, are the deliberate exception.)

file_graph builds a reference graph, then checks it with a require: mode A reference graph of four proto files. Three form an import cycle (order, item, user), which the acyclic mode catches; a fourth (util) is unreferenced, which the no_orphans mode catches. The five require: modes are acyclic, forbidden_edges, no_dangling, no_orphans, and fresh. reference graph edges parsed from content order.proto item.proto user.proto util.proto orphan import cycle require: one property of the graph acyclicno import cycle forbidden_edgesblock edges across a layer no_danglingevery edge resolves to a path no_orphansevery node is referenced freshgenerated files stay current file_graph spans the whole tree, never a changed subset

The oldest cross-file rules ask about neighbours. pair requires that every file matching primary has a matching partner (every foo.c a foo.h); pair_hash is stricter, requiring the partner to carry the source file’s content digest; and the diff-scoped pair_changed_together is directional, requiring that when an if_changed: file appears in the diff, a then_changed: file changes with it. for_each_dir and for_each_file iterate directories or files and run nested require: rules against each, gated by when_iter:; every_matching_has is lighter sugar over that iteration, running nested require: rules against every file or directory matching select: (it honours when_iter: as well). unique_by forbids two files from sharing a path-template key: the default {basename} forbids the same filename anywhere in the tree, while the case_insensitive: option additionally catches names that collide only under case-folding, the Windows and macOS hazard (no_case_conflicts is the dedicated kind for exactly that check). dir_contains and dir_only_contains constrain a directory’s membership, and a resolve family checks references against disk: registry_paths_resolve requires every path a manifest enumerates to exist, and markdown_paths_resolve does the same for the backticked workspace paths in a Markdown file. Most of these read the whole tree, because the answer for one path depends on the others, so they opt out of --changed filtering; the per-file markdown_paths_resolve (each file’s links are self-contained) is the exception, participating in --changed like any per-file rule. The rules reference carries the complete set.

file_graph builds a reference graph of your files, then asserts one property of it. The four reference-graph modes take edges parsed from file content: acyclic forbids dependency cycles, forbidden_edges blocks an edge that crosses a layering boundary (a firewall), no_dangling requires every edge to resolve to an existing path (a file or a directory), and no_orphans requires every node to be referenced (except declared roots:). The fifth mode, fresh, takes name-derived edges instead: a derive_target template maps a source to the file it should generate, and the rule asserts the generated file is up to date, checked by content rather than mtime so it holds on a fresh clone. (no_dangling accepts derived edges too, asserting each derived sibling exists.) The standalone generated_file_fresh rule enforces freshness for a committed artefact too, but by running the command that regenerates it and diffing the output (a spawning rule, so top-level config only), where fresh mode stays pure-parse and shells out to nothing; reach for it when you want freshness enforcement outside a graph.

Edges resolve as paths, which is the point: a from_content regex captures a reference, and only genuinely path-shaped ones are followed. A bare module name (crate::db), an absolute path, a URL, or a ..-escaping ref is dropped, not chased, so file_graph stays a file graph and never tries to be a package resolver. resolve: picks the base: relative_to_file (the default; only refs starting with .) or relative_to_repo_root.

cross_file asserts a relation between one file’s extracted value and one or more targets. Six relations: the value relations equals, subset, superset, and set_equals; identical, a whole-file byte match; and resolves, which requires each extracted path to exist on disk. It pulls values through the shared extract: extractor and an optional normalize, so normalize: semver-minor reconciles 4.36-dev, 4.36.0, and >=4.36 to the same 4.36 band, letting the engines.node in every package.json be checked against the root without version-string noise. allow_missing_target tolerates an absent target, and cross_file_value_equals is a registered alias with relation: equals.

file_graph and cross_file (plus registry_paths_resolve and scope_filter’s manifest predicates) share one extractor, which reads a value out of a file in exactly one of four ways: a structured query (an RFC 9535 JSONPath, keyed by format: json, yaml, toml, xml, dotenv, properties, ini, or hcl), a lines list, a regex capture, or whole_file (the entire content as one value, for byte comparisons). The structured form is the same machinery structured queries use standalone.

Forbid import cycles among Protobuf files, the capability no single-file rule can express:

version: 1
rules:
- id: no-proto-import-cycles
kind: file_graph
nodes: "proto/**/*.proto"
edges:
from_content:
extract: { regex: 'import "([^"]+)"' }
resolve: relative_to_repo_root
require: acyclic
level: error
message: "import cycle: order.proto -> item.proto -> user.proto -> order.proto"

On a tree where three protos import in a loop, the rule fires (without a message: override, file_graph auto-names the cycle for you):

error no-proto-import-cycles import cycle: order.proto -> item.proto -> user.proto -> order.proto
  • Structured queries is the extract: JSONPath machinery in depth.
  • Changed mode explains why these rules stay whole-tree under --changed.
  • Rules is the per-kind reference for file_graph, cross_file, pair, and the rest.