Architecture
Status: Living design document. Describes alint's internals for contributors and embedders. For the current scope by version, see ROADMAP.md.
Diagrams: this document embeds interactive architecture diagrams that render on alint.org; GitHub strips the
<likec4-view>web component, so on GitHub they appear blank. The same views are static Mermaid flowcharts, generated from the same model, in DIAGRAMS.md.
Overview
Section titled "Overview"alint is a language-agnostic, general linter for finite, inspectable project state. The current implementation inspects a repository's working-tree files and directories, structured documents, filesystem metadata, and selected local Git state and history. It is a single static Rust binary that reads a declarative YAML config and emits findings or bounded remediations.
For the precise scope of the "general linter" claim, the limits of the current model, and
the proposed selector/extractor/relation/constraint direction, see
general-linter.md.
The companion
rule-coverage-gaps.md
inventories missing detection classes.
Examples of rules in scope:
- Does file
Xexist at pathY? Does directoryZcontain fileW? - Do filenames under
components/follow PascalCase? - Does every
.cfile have a matching.hin the same directory? - Does every
.javafile start with a required license-header comment? - Is anything binary present under
src/? - Does
package.json'slicensefield equal"Apache-2.0"? - Do dependency declarations agree across manifests?
- Do commits in a range satisfy subject, sign-off, author, and signature policy?
Out of scope (explicitly; use the named tool instead):
- Code semantics / AST linting → ESLint, Clippy, ruff
- Static application security testing → Semgrep, CodeQL
- Infrastructure-as-code scanning → Checkov, Conftest, tfsec
- Secret scanning → gitleaks, trufflehog
alint does include bounded commit and history policy. It does not try to replace a complete
semantic code analyzer, security scanner, build system, or package resolver. The exact boundary
and the proposal for generalizing it are documented in general-linter.md.
The clarity of these non-goals is itself a feature.
Design principles
Section titled "Design principles"- A repository snapshot is the primary input. Every filesystem rule sees a unified file/directory index; Git-aware rules may also request explicit local Git inputs. The engine builds its main index once for
checkand once perfixfixpoint pass (effectful delegated rules may perform their own explicitly documented probes; see the v0.17 fixpoint design). - A small set of composable rule families. Existence, content, naming, and cross-file were the original shapes; the model has since grown to thirteen families, all built on the same rule record. The rule reference lists every kind by family.
- Declarative by default, programmable at the edges. YAML covers typical rules. A bounded expression language gates rules on facts. The
commandrule is the current escape hatch for user-defined logic; a capability-limited WASM host remains a possible future extension. - Walk once per pass; share and parallelize safe work. The walker and check evaluator use
rayon; the shared index and file-major dispatch coalesce hot-path reads. Fix application remains serial and ordered. - Respect ecosystem defaults.
.gitignoreis honored by default. YAML is the config format. Case aliases (PascalCase/pascalcase/pascal-case) all parse. - Every rule carries its own story. Severity, message,
policy_url, and optionalfixare first-class fields. - Modern output formats from day one. Eight formats: human, json, sarif, github, gitlab, junit, markdown, and agent.
- Single static binary. Rust, no runtime dependency on Node, Ruby, or Python.
Rule model
Section titled "Rule model"Every rule is a record:
id: stable identifier, unique after composition (the schema currently accepts a lowercase ASCII letter followed by lowercase letters, digits,_, or-)kind: the primitive rule type, namespaced (file_exists,filename_case,pair, ...)level:error|warning|info|offpaths: the scope glob(s); accepts a string, an array (with!negation), or{include, exclude}when: optional expression gating rule application on factsmessage: optional human-message override.{{vars.*}}substitution occurs while expanding a template instance; supported{{ctx.*}}fields are kind-specific and are rendered only by the kinds that document them. Other messages are literal.policy_url: optional URL to a human-readable policy justificationfix: optional fixer block- kind-specific fields
Each canonical kind is registered with a static, serializable KindMeta record
beside its builder. The record declares dispatch, accepted selectors and common
fields, host capabilities and their activation conditions, content/formats,
changed-mode dependencies, fix operations, and nested-rule support. Aliases
resolve the canonical record. Load-time field validation, trust/audit gates,
schema restrictions, facts.json, and CLI capability discovery all consume this
catalog instead of maintaining independent kind lists.
Severity maps to exit codes: error with violations → 1; warning → 0 unless --fail-on-warning; info → 0; off → rule skipped. off is useful when overriding a rule inherited from an extends-ed ruleset.
The Rule trait is the unit of execution:
pub trait Rule: Send + Sync + std::fmt::Debug { fn id(&self) -> &str; fn level(&self) -> Level; fn policy_url(&self) -> Option<&str> { None } fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>>; fn fixer(&self) -> Option<&dyn Fixer> { None } fn as_per_file(&self) -> Option<&dyn PerFileRule> { None } // Additional hooks describe full-index, Git, scope, nesting, and trust needs.}Rules produce Violations; the engine aggregates them into a Report.
Selected-rule tracing (v0.20 P5)
Section titled "Selected-rule tracing (v0.20 P5)"Engine::trace_rule copies one effective RuleEntry into a mini-engine that shares the production
registry and run inputs. It executes the same rule-major or file-major dispatch, common gates,
nested-rule builder, failure partitioning, fixability marking, result redaction, and canonical
ordering as check; unrelated rules are never evaluated. An optional TraceSink on Context
records typed aggregate TraceStages for the selected rule and appends exactly one terminal
outcome.
Every canonical KindMeta declares its allowed and required kind-owned stages. Completion audits
the runtime trace against that inventory. Ordinary engine runs carry Context.trace = None and
never construct a recorder, format evidence, increment trace counters, or acquire a trace lock.
Dispatch flip + PerFileRule (v0.9.3)
Section titled "Dispatch flip + PerFileRule (v0.9.3)"Since v0.9.3 the engine partitions rules into two execution loops, covering three semantic
classes. The rule-major loop contains both full-index rules (cross-file, existence, and other
whole-repository predicates) and ordinary rule-major rules that have not or cannot opt into
file-major dispatch. requires_full_index() independently controls which index a rule sees under
--changed; it does not choose the dispatch loop.
Per-file rules, those that can evaluate each matched file independently
(usually from its content), opt into a sibling PerFileRule trait, and the engine
drives a file-major outer loop on their behalf: each matched file is
read at most once, then dispatched to every applicable per-file rule.
The trait hands a pre-loaded byte slice to evaluate_file rather than
having the rule re-read inside evaluate:
pub trait PerFileRule: Send + Sync + std::fmt::Debug { fn path_scope(&self) -> &Scope; fn evaluate_file(&self, ctx: &Context<'_>, path: &Path, bytes: &[u8]) -> Result<Vec<Violation>>; fn max_bytes_needed(&self) -> Option<usize> { None }}The engine discriminates at evaluation time through Rule::as_per_file(): Some joins the
file-major loop and None stays rule-major. The two traits are views of the same rule object;
identity, severity, fixes, and other common behavior stay on Rule. The opted-in set spans
content, text-hygiene, security/Unicode, and encoding rules. Metadata rules and full-index rules
remain rule-major. Full design, and the rules that did or did not migrate, is in
the v0.9 dispatch-flip pass.
Memory layout on the hot path (v0.9.2)
Section titled "Memory layout on the hot path (v0.9.2)"Path and string clones across the violation hot path were a substantial allocator pressure source at the 100k-violation benchmarks. v0.9.2 retyped the affected fields to share allocations across every violation of a rule:
FileEntry::path: Arc<Path>: shared across all rules touching one file.Violation::path: Option<Arc<Path>>: refcount bump per violation instead ofPathBuf::clone.Violation::message: Cow<'static, str>:Borrowedfor the rule's staticmessage:field;Ownedfor templated per-match strings viaformat!.RuleResult::rule_id: Arc<str>,RuleResult::policy_url: Option<Arc<str>>: one allocation per rule run, shared across all violations of that rule.
Behavioural invariants verified via the cross-formatter snapshot test: byte-identical output before/after the type pass. Full design: the v0.9 memory pass.
The JSON Schema (draft 2020-12) at
schemas/v1/config.json is
generated from canonical Rust option types, a shared exact optionless model, and KindMeta
common-field restrictions. Recursively nested require: rules reuse the same generated kind
dispatch while excluding parent-only fields. The committed file remains the descriptive base for
shared DSL structures and prose. It is embedded into alint-dsl via include_str! (exposed as
alint_dsl::CONFIG_SCHEMA_V1). The schema drives editor completion and documentation; the runtime
performs the same typed Serde decoding and metadata-aware builder validation rather than invoking
a JSON Schema validator. Generation fails on registry, metadata, or schema drift; integration
tests validate representative configs and gate the root and embedded copies byte-for-byte.
The loader validates each source before composition. Root, local-extended, bundled, HTTPS, and
nested sources must state a supported version; .alint.d drop-ins may omit it and inherit the
root version, while an explicit drop-in version must match the root. A source can also declare a
literal minimum binary release:
version: 1requires: alint: "0.19.0"requires.alint is exactly three unsigned decimal components, not a range or interpolated value.
It is checked before the source is merged or any child extends: is resolved. Version and
requirement records survive composition as audit metadata; they do not participate in rule or
top-level policy precedence.
For editor autocomplete, reference the schema via the YAML language server pragma, either by a relative path (recommended inside this repo) or by the GitHub raw URL (for downstream users):
# yaml-language-server: $schema=./schemas/v1/config.json # or: $schema=https://raw.githubusercontent.com/asamarts/alint/main/schemas/v1/config.json version: 1requires: alint: "0.20.0"
extends: # Optional subresource integrity is a `#sha256-<hex>` URL fragment. - url: "https://raw.githubusercontent.com/example/rulesets/base.yaml#sha256-0000000000000000000000000000000000000000000000000000000000000000" # A local path is a bare string entry. - ./team-policy.alint.yml
ignore: - "target/**" respect_gitignore: true
vars: copyright_year: "2026"
facts: - id: has_rust any_file_exists: ["Cargo.toml"]
rules: - id: readme-exists kind: file_exists paths: ["README.md", "README", "README.rst"] root_only: true level: error message: "A README file is required at the repository root."
- id: c-requires-h kind: pair primary: "**/*.c" partner: "{dir}/{stem}.h" level: error
- id: components-pascalcase kind: filename_case paths: "components/**/*.{tsx,jsx}" case: pascal level: error
- id: cargo-lock-checked-in when: facts.has_rust kind: file_exists paths: "Cargo.lock" root_only: true level: errorRule families
Section titled "Rule families"Rules are grouped into thirteen families. Every kind shares the record shape above; what differs is the predicate. Not every kind ships in every release. The full, always-current catalogue, with a one-line summary per kind, lives at the rule reference.
- Existence (
file_exists,file_absent,dir_exists,dir_absent): a path must, or must not, be present. - Content (
file_content_matches,file_header,file_hash,file_max_size,file_max_lines,file_is_text,file_shebang, ...): assertions over a file's bytes or lines. - Structured query (
json_path_equals,yaml_path_matches,toml_path_equals,xml_path_matches,json_schema_passes, ...): query a value inside a JSON / YAML / TOML / XML / dotenv / properties / INI / HCL file via RFC 9535 JSONPath, or validate against a JSON Schema. - Naming (
filename_case,filename_regex): a basename matches a case convention or a regex. - Text hygiene (
no_trailing_whitespace,final_newline,line_endings,line_max_width,indent_style,max_consecutive_blank_lines): whitespace and line-shape checks, most with a fixer. - Security / Unicode (
no_merge_conflict_markers,no_bidi_controls,no_zero_width_chars): flag conflict markers and Trojan-Source bidi / zero-width characters. - Encoding (
no_bom): byte-order-mark checks. General text/binary and ASCII checks live in the content family. - Structure (
max_directory_depth,max_files_per_directory,no_empty_files): shape of the tree itself. - Portable metadata (
no_case_conflicts,no_illegal_windows_names): reject tree shapes that look fine on one OS but break checkouts on a case-insensitive or Windows filesystem. - Unix metadata (
no_symlinks,executable_bit,executable_has_shebang,shebang_has_executable): permission-bit and symlink checks. Platform-specific operations are isolated so the binary and configs remain portable. - Git hygiene (
no_submodules,git_no_denied_paths,git_commit_message,git_commit_signed_off,git_commit_subject_matches,git_blame_age, ...): properties of the git tree and the commit range. - Cross-file (
pair,for_each_dir,for_each_file,every_matching_has,unique_by,dir_contains,file_graph, ...): a verdict that spans more than one file. - Plugin (
command): shell out to an external checker per matched file (see Plugin model).
scope_filter: is a common field accepted by compatible file-scoped kinds. git_tracked_only:
is instead a kind-specific option of file_exists, file_absent, dir_exists, and
dir_absent. See Rule scoping and
Git-tracked filtered index below.
Fix operations
Section titled "Fix operations"Rules that declare a fix: block opt in to automatic remediation. The op is a discriminated union keyed by op name; each rule kind accepts at most one op.
Path-only ops (ignore fix_size_limit):
| Op | Shape | Rule kinds |
|---|---|---|
file_create | {content | content_from, path?, create_parents?, applicability?} | file_exists |
file_remove | {applicability?} | file_absent, no_empty_files, no_symlinks, no_submodules |
file_rename | {applicability?} (target derived from rule config) | filename_case |
dir_create | {applicability?} | dir_exists |
relocate | {applicability?} (moves the file to the repo root) | file_absent |
chmod | {applicability?} (direction from the rule) | executable_bit, shebang_has_executable |
git_untrack | {applicability?} (git rm --cached; spawning) | file_absent |
command | {run, timeout?, applicability?} (runs argv; spawning) | command |
Content-editing ops (skipped on files over fix_size_limit; default 1 MiB, null disables):
| Op | Shape | Rule kinds |
|---|---|---|
file_prepend | {content | content_from, applicability?} | file_header |
file_append | {content | content_from, applicability?} | file_content_matches, file_footer |
file_trim_trailing_whitespace | {applicability?} | no_trailing_whitespace |
file_append_final_newline | {applicability?} | final_newline |
file_normalize_line_endings | {applicability?} (target read from parent rule) | line_endings |
file_strip_bidi | {} | no_bidi_controls |
file_strip_zero_width | {} | no_zero_width_chars |
file_strip_bom | {applicability?} | no_bom |
file_collapse_blank_lines | {applicability?} (max read from parent rule) | max_consecutive_blank_lines |
replace | {replacement, pattern?, applicability?} (structured matches require their own search pattern) | file_content_forbidden, {json,yaml,toml,xml,dotenv,properties,ini,hcl}_path_matches |
set_value | {applicability?} (value from the rule's equals:) | {json,yaml,toml,xml,dotenv,properties,ini,hcl}_path_equals |
remove_value | {applicability?} (target from parent rule) | {json,yaml,toml,xml,dotenv,properties,ini,hcl}_path_absent |
sync_from | {applicability?} (source + relation from parent rule) | cross_file (relation: identical / equals) |
create_and_register | {content?, content_from?, applicability?} | cross_file (relation: registered) |
sort | {applicability?} (markers / comparator / unique / select from parent rule) | ordered_block |
indent_style | {applicability?} (style / width from parent rule) | indent_style (style: spaces + width) |
insert_line | {applicability?} (require lines / comparator from parent rule) | ordered_block (markerless, with require:) |
insert_header | {content | content_from, applicability?} (inserts after a leading BOM / shebang / <?xml?>) | file_header |
Over-limit content-editing ops report Skipped with a stderr warning instead of applying. Reads
are streaming where possible; otherwise the file is loaded in full. set_value and
remove_value are located ops over selected structured nodes; sort is a whole-file rewrite,
and create_and_register combines file creation, when needed, with a structured list append.
replace, set_value, and remove_value are located ops: they emit byte-range edits that the
located regime batches, orders, overlap-skips, verifies, and splices in a single pass. replace
and remove_value are Unsafe by default; scalar set_value is Safe by default, while
ambiguous or unsupported shapes are withheld or demoted.
Every built fixer resolves to an applicability tier (Safe, Unsafe, Suggestion, or
Never), although fixed-behavior marker ops do not all expose a YAML override. Most
normalizations default to Safe. Destructive, arbitrary, or direction-sensitive operations
default to Unsafe, including file_remove, git_untrack, command, relocate, sync_from,
create_and_register, remove_value, replace, indent_style, insert_line, and sort when
unique: true. A bare alint fix reports unsafe work as suggested; --unsafe-fixes applies it.
Per-op details are generated in Rules. An inherited ruleset may
demote but not promote a fix, and spawning fixes are top-level-only. Fixers run serially. Content
edits compose in config order in memory and flush atomically once per touched file; path-only ops
apply directly (or are staged for --diff). --dry-run writes nothing, and --diff renders the
staged result.
Path template tokens
Section titled "Path template tokens"Used in path-shaped fields such as partner, nested require paths, command argv,
cross-file mapping templates, and unique_by.key:
{dir}: parent directory of the matched file{path}: full relative path{basename}: filename including extension{stem}: filename without the final extension{ext}: final extension without the dot{parent_name}: immediate parent directory name
Unknown tokens are preserved literally. Case conversion is a behavior of the
filename_case/file_rename pair, not an additional family of path-template tokens.
Scope and globbing
Section titled "Scope and globbing"Globs compile via globset:
*: any run of non-separator chars**: any number of path segments (own segment only)?: one non-separator char[abc],[a-z]: character classes{a,b,c}: brace alternation!pattern: negation (arrays only)
Every rule's paths accepts one of three shapes:
paths: "src/**/*.rs" # single globpaths: ["src/**/*.rs" , "!src/**/testdata/**" ] # array with negationpaths: {include: ["src/**" ], exclude: ["**/*.test.*" ]} # explicit pair.gitignore (plus .ignore and git exclude files) is honored by default. ignore: in config adds to the exclusion set.
Facts and conditional rules
Section titled "Facts and conditional rules"Facts are declarative scalar properties of the repository. A normal check evaluates them once
per engine run; the LSP's repeated single-file path caches them on the lifetime of its
FileIndex. when clauses gate rules on facts.
Fact kinds are any_file_exists, all_files_exist, count_files, file_content_matches, git_branch, and custom: {argv: [...]} (shell out, stdout → value). Language/license detectors (detect: linguist, detect: askalono) are deferred — see the planned alint-facts crate below.
The when expression language is deliberately bounded:
- Operators:
==,!=,<,<=,>,>=,and,or,not,in,matches - Identifiers:
facts.<name>,vars.<name>,iter.<name>,env.<name>(ctx.<name>is available in messages, not inwhen:) - Literals: strings, numbers, booleans, null, lists
There are no user-defined functions or recursion. The grammar includes the bounded built-in
iter.has_file(...); I/O happens only while named fact providers are evaluated, never from the
expression itself. Examples:
when: facts.has_rustwhen: facts.release_branch in ["main", "release"] when: facts.has_rust and not facts.is_workspace_memberwhen: facts.java_file_count > 0Rule scoping (scope_filter:, v0.9.6+)
Section titled "Rule scoping (scope_filter:, v0.9.6+)"scope_filter: is a second file-scope gate orthogonal to when: and paths:. Its predicates
AND-compose:
has_ancestoradmits a file when a named manifest occurs in its ancestor chain;changed_sinceadmits paths in a<ref>...HEADGit diff;include_manifest_pathsandexclude_manifest_pathsuse path sets extracted from a manifest, with optional target derivation.
The closest-ancestor lookup walks from the file's directory toward the root and stops at the nearest directory containing any configured manifest name.
- id: rust-sources-no-bidi when: facts.has_rust # tree-level gate kind: no_bidi_controls paths: "**/*.rs" # path glob scope_filter: # ancestor walk has_ancestor: Cargo.toml level: errorConceptually the gates are: tree-level when:, the kind's target selector/paths:, compatible
scope_filter: predicates, any kind-specific Git-tracked view, then the rule body. The exact
iteration order is an optimization; all declared gates must admit the target.
Cross-file rules (pair, for_each_dir, file_exists, ...) reject scope_filter: at build time and direct authors to for_each_dir + when_iter:. Per-file rules, both PerFileRule and the remaining rule-major ones, honour the filter through the shared Scope::matches call (see the v0.9.10 structural fix below).
Used by the seven bundled ecosystem rulesets (rust@v1, node@v1, python@v1, go@v1, java@v1, dotnet@v1, php@v1) so their per-file content rules narrow to files inside their ecosystem's package subtree in polyglot monorepos. Full design: the v0.9 scope-filter pass.
Structural fix in v0.9.10: Scope owns its Option<ScopeFilter> and Scope::matches(&Path, &FileIndex) consults both predicates in one call. The signature change is compile-enforced (every per-rule path check must thread the index), so the silent-drop bug class that produced sweeps in v0.9.7 and v0.9.9 is structurally closed. No rule has a separate scope_filter field to forget about. Full design: the v0.9 scope-owns-scope-filter pass.
Git-tracked filtered index (v0.9.11)
Section titled "Git-tracked filtered index (v0.9.11)"git_tracked_only: true on one of the four existence kinds narrows its view to paths in
git ls-files output, skipping locally built but untracked artefacts (target/,
node_modules/, ...). git_no_denied_paths is separately and inherently tracked-path based.
Through v0.9.10 each opted-in existence rule consulted ctx.is_git_tracked(path) inline, the
same silent-drop bug class that hit scope_filter:.
v0.9.11 builds two filtered FileIndex views once per run when any rule opts in: a file-tracked subset (entries where git_tracked.contains(path)) and a dir-aware subset (dirs that recursively contain at least one tracked file). The engine substitutes the pre-filtered index via pick_ctx; the rule's evaluate body never sees an untracked path, and the runtime is_git_tracked() check disappears from per-rule code. Full design: the v0.9 git-tracked filtered-index pass.
Composition
Section titled "Composition"extends accepts local paths, HTTPS URLs (with optional SHA-256 subresource integrity), and
bundled URIs. Entries may filter inherited rule IDs with only/except. Sources resolve
left-to-right with cycle detection and caching; rule mappings merge one field level deep by ID,
and the child wins. Setting level: off disables an inherited rule.
Bundled rulesets are referenced via alint://bundled/<name>@v<major>.
Top-level templates provide string substitution before typed rule decoding. Optional nested configs scope rules to subtrees. Baselines suppress known finding identities after evaluation. Trust is monotone: an inherited source cannot grant itself process execution, outside-root reads, top-level exception/baseline authority, or a more permissive fix tier. These are distinct composition mechanisms and intentionally do not form a general macro language.
Execution model
Section titled "Execution model"The pipeline from alint check to output:
- Config load and build. Read
.alint.yml; resolve composition with caching, cycle and capability-catalog trust checks; validate each effective common field against the selected kind'sKindMeta; decode merged YAML into typed specs; build every enabled top-level rule and structurally validate its nested rules. Unknown kinds, unsupported fields/fixes, invalid options, and cross-field combinations fail load. JSON Schema is the matching editor/test contract, not a runtime validation pass. - Walk. Build one full
FileIndexwith theignorecrate's parallel walker. Workers collect entries locally; a deterministic post-sort removes filesystem/thread scheduling from output. LazyOnceLockindexes accelerate membership, child, descendant, and basename queries. - Inputs and derived views. Evaluate facts sequentially against the full index. Collect Git tracked/blame data only if requested, resolve changed/manifest-derived scope maps once, and build changed or Git-tracked index views as needed. The LSP's single-file evaluator caches facts on its long-lived index; a normal CLI run does not use a persistent content-hash cache.
- Gates. Skip
offentries during loading/building; evaluatewhen:once per live rule; apply changed-mode,expect_matches, scope, and kind-specific target gates. Runtime gate errors become typed, always-error analysis failures rather than silent passes or policy violations. - Dispatch.
Rule::as_per_file() == Some(...)joins the file-major loop. All other rules use the rule-major loop. Independently,requires_full_index()selects the full rather than changed-only index for rules whose verdict needs whole-repository context. - Evaluate. During
check, both loops fan out throughrayon. The file-major loop reads one matched file once and shares its byte slice among applicablePerFileRules (respecting each rule's prefix-read cap). Rule-major evaluators own any reads they require and may therefore reread a file used by another rule. - Aggregate and post-process. Reassemble results in config order; partition typed analysis failures and informational notes from policy violations; attach fixability/proposed edits where the selected formatter needs them; and apply an optional baseline before deciding the exit status. A baseline operates only on violations and cannot suppress incomplete analysis.
- Fix (optional). Fix evaluation and application are serial to preserve configuration order.
Applicability gates writes; whole-file edits compose in memory, located edits are ordered and
overlap-checked, and path operations apply directly or stage for
--diff. A real fix re-walks after a pass that changed bytes and repeats to a bounded fixpoint; dry-run is a non-mutating single-pass preview. Policy outcomes areApplied,Skipped,Suggested, orUnfixable; analysis failures remain separate, are never offered to a fixer, and force exit 1. - Emit. Render one of the selected output formats and derive the command exit code.
The engine builds its main FileIndex with one repository walk per pass. An explicitly effectful
kind such as generated_file_fresh may run a tool and perform its own before/after inspection;
that work is outside the shared-walk guarantee. The stronger read-once guarantee applies to the
file-major partition only, not to every rule or fact provider. Check evaluation is parallel; fix
evaluation/application is ordered and serial. Content writes are atomic per flushed file, but the
whole multi-file fix pass is not a transaction. See the
v0.17 fixpoint design.
Step 3 in detail: facts are evaluated once per normal engine run, then gate rules through their
when: conditions. The LSP reuses a cached result while its index is valid.
Steps 5 and 6 in detail: dispatch partitions rules into rule-major and opted-in file-major work; only the latter coalesces reads across matching rules (ADR-0003).
Crate layout
Section titled "Crate layout"alint is a Cargo workspace, the standard shape for Rust tools (rustc, cargo, tokio, ruff, biome, rust-analyzer, wasmtime, ...). The reasons apply here: pre-1.0 breaking changes in the core ripple through the graph, so every such change is one PR rather than a multi-repo release; one Cargo.lock guarantees consistent transitive deps; one CI run (cargo test --workspace) validates the full graph; contributors clone once.
Building block view
Section titled "Building block view"The crate dependency graph is generated from cargo metadata and committed at
docs/design/architecture/crate-graph.md
— now the generated LikeC4 crateGraph view (crate-graph.gen.c4, solid = runtime deps,
dashed = dev/build-only; the original Mermaid is subsumed by that view) plus a crate-by-tier
table, regenerated by cargo run -p xtask -- gen-arch and gated by gen-arch --check (so it
can't drift from the Cargo manifests). alint-core is the foundation (a runtime dependency
sink, enforced by a test); the alint binary and xtask sit at the top.
The C4 model (intent: system context, containers, the crates as components) now lives in
the single LikeC4 model under
docs/design/architecture/model/
(ADR-0005);
its crate elements are gated against the cargo metadata workspace members, so the model can't
silently omit a crate. The hand-modeled Structurizr
workspace.dsl
is retained through the transition and retires once the LikeC4 model fully subsumes it.
Architecture decisions are recorded as ADRs under
docs/adr/. See
architecture-as-code.md
(the original Mermaid + Structurizr generator design, Phase 4 / WS3) and its LikeC4 follow-on
architecture-diagrams.md
(ADR-0005).
The directory tree below is the on-disk layout; the dependency graph above is the build-block structure.
Current crates:
alint/├── crates/│ ├── alint/ binary entrypoint; `cargo install alint`│ ├── alint-core/ engine, walker, rule trait, config AST, errors│ ├── alint-dsl/ YAML composition/typed loading + embedded editor schema + bundled rulesets│ ├── alint-rules/ built-in rule implementations│ ├── alint-output/ formatters (human, json, sarif, github, gitlab, junit, markdown, agent)│ ├── alint-lsp/ language server behind the `lsp` subcommand (tower-lsp)│ ├── alint-bench/ criterion micro-benches + seeded tree generator│ ├── alint-testkit/ public policy-test v1 model, hermetic runner, tree fixtures, optional strategies│ └── alint-e2e/ general policy tests + coverage audits + cross-cutting invariant tests├── xtask/ cargo-xtask helpers (bench-release driver, docs-export, publish-benches)├── ci/ self-hosted runner + per-job shell scripts├── editors/ editor integrations (VS Code, Zed, JetBrains, Neovim, Helix, Emacs, Sublime, Eclipse)├── schemas/v1/ JSON Schemas for config, reports, and policy tests (config.json is mirrored in alint-dsl)├── docs/│ ├── design/ architecture, roadmap, per-cut design passes (v0.7, v0.9, ...)│ ├── development/ contributor docs (rule-authoring.md)│ └── benchmarks/ methodology + per-platform published numbers (micro/, macro/, investigations/, archive/)├── install.sh curl-pipeable platform-detecting installer├── action.yml official GitHub Action (composite)├── npm/ npm wrapper that downloads the platform-matched binary├── Dockerfile distroless image; rebuilt per release├── .alint.yml dogfood config└── Cargo.toml workspace manifestPossible future extraction/extension points (see ROADMAP.md):
crates/alint-plugin/: a WASM plugin host remains unscheduled backlog; the tier-1commandplugin already lives inalint-rules.crates/alint-facts/: currently subsumed byalint-core::facts; promotion to its own crate is deferred until the language and license detectors (detect: linguist,detect: askalono) land.
Publishing intent (crates.io)
Section titled "Publishing intent (crates.io)"The public crate surface is kept narrow so the semver-stable API remains maintainable.
| Crate | publish | Why |
|---|---|---|
alint (binary) | public | Enables cargo install alint. Package name matches [[bin]] name. |
alint-core | public | Embeddable engine for custom drivers: scripts, custom CI gates, third-party hosts. Semver-stable from 1.0. |
alint-testkit | public | Versioned policy-test documents, validating hermetic runner, and fixture utilities for third-party rulesets. |
alint-dsl, alint-rules, alint-output, alint-lsp | published, but documented as internal | Cargo requires the binary and testkit workspace dependencies to be resolvable from crates.io. Publication does not make these semver-stable public APIs. |
alint-bench, alint-e2e, xtask | publish = false | Repository-only benchmarking, integration testing, and maintenance tooling. |
publish is a packaging property, not an API-stability promise. alint-core and
alint-testkit are the supported library surfaces; implementation crates carry an Internal
package description even though the release pipeline publishes them as dependencies.
Plugin model
Section titled "Plugin model"Today there is one tier, and a three-host model is decided
(ADR-0019,
general-linter.md section 9.4,
detailed in plugin-architecture.md):
commandrule kind (shipped). The rule spawns a configured argv per matched file; it does not implicitly invoke a shell. Exit code is the verdict; bounded stdout/stderr form the default failure message. Environment variables expose path, root, rule id, level, vars, and facts. Simple, scriptable, language-agnostic.- Plugin contract (planned, designed with the Phase 2 relation layer). One versioned contract, written in WIT, with adapter, analyzer, and fixer roles. Plugins propose typed edits; the host applies them. Built-in kinds and formats implement the same internal traits.
- Three hosts for that contract (planned, in this order):
- native Rust crates compiled into a custom binary by
alint build(no dynamic loading, because Rust has no stable ABI); - out-of-process plugins in any language, speaking length-prefixed protobuf over stdio;
- sandboxed, pure WASM components on wasmtime.
- native Rust crates compiled into a custom binary by
Native and process plugins are fully trusted and integrity-pinned; process plugins are
spawn-gated like the command kind. Only SRI-pinned WASM plugins may arrive through extends:.
Output formats
Section titled "Output formats"Selected via --format:
human(default): colorized, per-rule grouped output with source snippets.json: stable, versioned schema.sarif: SARIF 2.1.0 for GitHub Code Scanning and Azure DevOps.github: GitHub Actions annotations (::error file=...).gitlab: GitLab Code Quality JSON.junit: JUnit XML for generic CI reporting.markdown: report with TOC, suitable for posting as a GitHub issue body.agent: agent-oriented JSON sibling ofjson, with a per-violation instruction composed from severity, message, location, actual Safe-fix availability, and policy URL, plus a machine-usable fix command/edit when available (v0.6).
All eight formats preserve typed analysis failures separately from policy violations. Failures always count as errors, cannot be baselined or fixed, and retain a stable class plus resource location where available. See the output-format reference.
Full example
Section titled "Full example"A Rust project dogfood config, showing composition, facts, and multiple rule families:
# yaml-language-server: $schema=./schemas/v1/config.json version: 1
extends: - alint://bundled/oss-baseline@v1 - alint://bundled/rust@v1
vars: copyright_year: "2026" org: "Acme Corp"
ignore: - "target/**"
facts: - id: has_benches any_file_exists: ["benches/**/*.rs" ]
rules: # Override an inherited rule (field-merge by id: the inherited # `oss-readme-exists` keeps its `kind`, this narrows its `paths`). - id: oss-readme-exists paths: ["README.md", "README.adoc"]
# Disable an inherited rule. - id: rust-sources-snake-case level: off
# New rules. - id: cargo-member-paths-are-canonical kind: toml_path_matches paths: "Cargo.toml" path: "$.workspace.members[*]" matches: "^crates/[a-z][a-z0-9-]*$" level: error
- id: crates-have-readme kind: for_each_dir select: "crates/*" require: - kind: file_exists paths: "{path}/README.md" level: error
- id: handlers-have-integration-tests kind: pair primary: "src/handlers/*.rs" partner: "tests/{stem}_test.rs" level: warning
- id: bench-gated when: facts.has_benches kind: file_exists paths: "benches/Cargo.toml" level: errorContributing new rule kinds
Section titled "Contributing new rule kinds"A new kind follows the repository's spec-driven workflow, not only a trait implementation:
- Establish demand and semantics in the appropriate design document: target domain, empty-set behavior, changed-mode/full-index needs, errors, finding identity, trust, and fixes.
- Define a
deny_unknown_fieldsoptions type and generated schema fragment, then implement theRule(andPerFileRuleonly when it truly accepts one preloaded file at a time). - Declare the static
KindMetabeside the builder, then register that pair, the option schema, canonical kind, aliases, and family/category metadata in their single sources of truth. - Add unit/property tests plus at least one firing and one silent end-to-end scenario. Cover changed mode, scoping, limits, trust, output identity, and fix convergence when applicable.
- Add examples and generated rule documentation; run all catalogue/schema/docs/coverage gates.
- Update roadmap/design status only when the implementation and evidence ship together.
The authoritative checklist is
docs/development/rule-authoring.md,
with non-negotiable invariants in
constitution.md and
the spec-driven development guide.