Skip to content

Config layering

Your root .alint.yml is rarely the whole config. Drop-ins layer over it, per-directory nested configs add subtree-scoped rules, and three interpolation timings fill in values at three different moments. Knowing which layer resolves when is the difference between a config that behaves and one that surprises you.

The three interpolation timings resolve at three different moments A timeline with three stages. At config load, {{env.SRC_ROOT | default('src')}} resolves to src. At template expansion, {{vars.dir}} resolves to src. Per violation, {{ctx.primary}} resolves to src/app.c. Each resolves at its own moment. three interpolation timings three moments config load {{env.SRC_ROOT|default('src')}} -> src from the process environment; local configs only template expansion {{vars.dir}} -> src only inside a templates: body per violation {{ctx.primary}} -> src/app.c in a rule's message, once per finding each resolves at its own moment; no layer sees another's values

When a .alint.d/ directory sits next to your root .alint.yml, alint discovers every *.yml (or *.yaml) inside it and merges them in alphabetical order, last wins on a field-level conflict. It is the /etc/*.d/ pattern applied to config: ops layer 50-policy.yml through provisioning, a developer gitignores 99-local.yml. Each drop-in is a complete config (its own version: 1) and can add rules, override existing ones by id, add extends:, or layer more facts: and vars:.

Drop-ins are trust-equivalent to your root config: they live in the same workspace under your control, so they may declare spawning rules and custom: facts, unlike anything reached through extends:. Only the root config gets .alint.d/ discovery; a config reached via extends: does not carry its own drop-ins. Reserve numeric prefixes (00-base.yml, 50-team.yml, 99-local.yml) to control the order, the one merge knob. A drop-in overrides an existing rule field-by-field by id, but nested structures (a fix: block, a paths: include/exclude pair) replace wholesale rather than merging into the base.

Opt in with nested_configs: true in the root config, and alint walks the tree (respecting .gitignore and ignore:) and picks up a .alint.yml in any subdirectory. A nested config’s rules are added, not overridden, and each rule’s path-like scope is auto-prefixed with that subtree, so a rule in packages/web/.alint.yml only ever looks at packages/web/.

The guardrails keep nesting predictable: of a nested config’s fields, only version: and rules: carry weight; the dangerous ones (extends:, facts:, baseline:, allow_out_of_root:, nested_configs:, and spawning rules) are rejected at load, while other top-level knobs such as respect_gitignore: are silently ignored rather than honored. Every nested rule needs at least one scope field; absolute and ..-escaping paths are rejected; and a duplicate rule id anywhere is a load error, never a silent override. Nesting is untrusted in the same way an extends:’d ruleset is (no spawning rules), and only the top-level config may turn it on; a nested config cannot enable its own nested discovery.

The same {{...}} syntax resolves at three distinct times, and each layer only ever sees its own inputs:

  • {{env.X}} resolves at config load, from the process environment, in local config files only. A {{env.X | default('...')}} filter supplies a fallback.
  • {{vars.X}} resolves when a templates: body expands into its instances. A plain, non-template rule does not expand {{vars.X}} in its fields; a bare when: vars.X reads a top-level var directly.
  • {{ctx.X}} resolves per violation, inside the message of a rule that populates it. A pair rule fills {{ctx.primary}} and {{ctx.partner}}, for example; not every rule interpolates its message, and one like file_exists emits its message verbatim.

Because they resolve at different moments, they never cross: {{ctx.primary}} is meaningless at load, and {{env.X}} is long since resolved by the time a violation is formatted.

One config threads all three timings in order: an env value feeds a template var, the var scopes which files the rule pairs, and each violation names the file and its missing partner:

version: 1
templates:
- id: sources-need-headers
kind: pair
primary: "{{vars.dir}}/**/*.c" # {{vars}} fills at template expansion
partner: "{dir}/{stem}.h"
message: "{{ctx.primary}} has no header at {{ctx.partner}}" # {{ctx}} fills per violation
level: error
rules:
- extends_template: sources-need-headers
id: lib-headers
vars: { dir: "{{env.SRC_ROOT | default('src')}}" } # {{env}} fills at load

With SRC_ROOT unset and src/app.c missing its header, the default resolves at load, the template expands to pair src/**/*.c files, and the pair rule fills the message per violation:

error lib-headers src/app.c has no header at src/app.h