Linting the shape of a repository
On this page
Most repositories run on rules that few tools enforce automatically. A package is supposed to ship a README. No build output is supposed to land in version control. Each CI action is supposed to be pinned to a commit SHA, and the LICENSE file is supposed to exist and actually contain license text. You know these rules. You have corrected pull requests that broke them. What you probably do not have is anything that checks them.
The rules your repository assumes but never checks
Conventions like these tend to live in one of three places, and each one has a hole.
The first is a maintainer’s head. That works until the maintainer is on vacation, or leaves, or the project grows past the point where one person reads every diff.
The second is a document: a CONTRIBUTING file, a wiki page, an onboarding guide. Prose is not enforcement. The rule is written down and drifts anyway, because nothing fails when someone ignores it.
The third is a pile of bespoke scripts, and this is what large or mature projects often add. You can see it in the biggest repositories on GitHub. VS Code ships build/hygiene.ts, an in-tree program that checks copyright headers, indentation, product metadata, and a few other things. Kubernetes has around fifty hack/verify-*.sh scripts wired into make verify. These work. The catch is that each one is specific to its repository, and many of them wrap other programs (gofmt, golangci-lint, shellcheck) rather than standing alone, so the convention does not transfer to the next project.
At the other extreme, some projects keep little of that machinery in the tree. At the revision I inspected, golang/go had no GitHub Actions workflows, no top-level Makefile, and no .golangci.yml; its structural conventions are not expressed as in-tree declarative policy. They are real and strict all the same, enforced through Gerrit code review together with the TryBots and LUCI submit infrastructure that run outside the repository tree. Between bespoke in-tree scripts on one side and review-plus-external-automation on the other, few projects run a single portable tool aimed squarely at this layer.
This is neither small nor hypothetical. In March 2025 a single compromised GitHub Action, tj-actions/changed-files, was pulled into over 23,000 repositories through a moving version tag (incident analysis); the repositories that had pinned it to a full commit SHA got the old, safe code and were untouched. A 2026 Datadog analysis found that 71 percent of organizations pin none of their actions that way, and even a direct pin does not cover unpinned transitive dependencies. Step from security to ordinary hygiene and the shape repeats. An OpenSSF scan of about 1.02 million packages (roughly 832,000 on npm and 191,000 on PyPI) found that only about three percent published a security policy, and that the missing-license rate splits sharply by ecosystem: about 32 percent on npm against about 12 percent on PyPI. These are conventions that are widely agreed on and unevenly enforced.
What alint is
alint is a linter for the shape of a repository, and it is the tool I built to close that gap. It checks which files exist, how they are named, what they contain, the values inside your config files, and how files relate to one another, among other repository-level checks. You describe the intended shape once, most often in a single .alint.yml at the repo root, and alint check enforces it. It ships as one native executable with no language runtime to install, and because it reads files rather than building source-language syntax trees, the same engine works on a Go service, a TypeScript monorepo, or a tree with six languages in it.
A small config reads like a list of the things you already wish someone checked:
# .alint.yml (repo root)version: 1rules: - id: readme-exists kind: file_exists paths: README.md level: error
- id: no-committed-build-output kind: dir_absent paths: dist level: error
- id: no-merge-conflict-markers kind: no_merge_conflict_markers paths: ["**/*.md"] level: errorUnderneath, alint ships 89 rule kinds as of v0.14.2 (full catalogue). They cover existence, naming, content, values inside structured config, relationships between files, and encoding or security checks such as the Trojan-Source (bidirectional-control) scan. Those are six ways to group the kinds, drawn from a formal catalogue of 13 families. You compose kinds by hand or by extending one of the 22 bundled rulesets, so a starter config can be a few lines of extends; a real project config often grows a set of rules specific to that repository.
A few things alint deliberately does not do. It does not build source-language syntax trees, so it is not a replacement for ESLint, Clippy, ruff, or Semgrep; it runs beneath them. It does not identify a license by reading its text the way a semantic detector does. An existence-and-content rule can check that a LICENSE file is present and non-trivial, not that it holds a particular license. Its command rules can hand work to tools already in your project, but those tools bring their own runtimes and costs.
Where alint fits
It helps to be precise about where alint sits, because the first reaction to a repository linter is that some tool must already do this.
ESLint, Clippy, and ruff read the code inside your files. They parse a file into a syntax tree and reason about one language at a time. Semgrep and CodeQL work at a related level for security and data-flow patterns (Semgrep’s Pro engine adds cross-file analysis). gitleaks and trufflehog scan repository content and history for secrets. Each of these is anchored to code, or to a property of code.
alint sits one level out, at the filesystem shape. Whether a required file exists, whether a filename follows a convention, whether a version in package.json matches a version in a workflow: none of that lives in a syntax tree, little of it is specific to a single language, and much of it crosses language boundaries. A per-language linter does not see it, because it only reads its own slice of the tree. That is the gap alint is built around.
That same hygiene.ts from VS Code is a useful illustration. It runs a handful of checks; alint can express several of them as declarative rules and delegate others to the tools that already run them. A couple, a per-line indentation check and a Unicode rule with special-case escape hatches, are a better fit for a script than for a declarative rule. VS Code also ships a directory of in-tree ESLint rules, and the ones I read are source-code checks implemented as syntax-tree visitors, which is not what alint is for. alint runs beneath tools like these, as the structural floor. It is not trying to replace them.
An honest comparison uses qualified axes rather than empty cells:
| Concern | Per-language linter | Specialist config/docs/CI tool | alint |
|---|---|---|---|
| Source-language syntax and semantics | Typically strong | Usually out of scope | Explicit non-goal |
| One structured or config format | Sometimes | Often strong | Supported |
| Filesystem existence and naming | Usually limited | Varies | Core |
| Cross-file and tree invariants | Varies | Varies | Core |
| Unified repository-oriented policy | Not typical | Often one surface | Core positioning |
| Delegating to existing tools | Via task runner or plugin | Sometimes | Command rules |
The columns describe tendencies, not absolutes. yamllint, JSON Schema validators, Conftest, Vale, markdownlint, actionlint, and zizmor each cover parts of the config, docs, and CI surface. What alint adds is one repository-oriented policy model across those surfaces, with the option to delegate to the specialists where they are stronger.
What it finds in real repositories
I ran alint against about thirty well-known open-source repositories and wrote each one up (the case studies are public). A few patterns recur, and they surface for the same reason: alint gives this layer a repeatable, repository-wide check that these projects did not otherwise have. Every run is a snapshot, a named alint version against a specific commit of the repository with a specific config, so treat the exact counts as of that snapshot and the categories as the durable part.
Reading bytes turns up things that hide in plain sight. In the Flutter repository, at the commit I pinned, alint flagged five documentation files (twenty occurrences in all), most of them archived release notes, containing U+202C, a bidirectional Unicode control character. That is the class behind CVE-2021-42574, the Trojan-Source attack, where text can render differently than it is interpreted. The researchers who described it demonstrated it across twelve language contexts and reported it working against most of the compilers, editors, and repositories they tested; GitHub began warning on bidirectional Unicode in files and pull requests around the CVE disclosure. These particular instances are almost certainly harmless, most likely contributor names copied out of pull requests, and they had been sitting there for years. Harmless or not, catching them takes a tool that decodes text and inspects its Unicode scalar values, which a code linter reading for syntax does not do.
Structured-config drift is the second pattern. In facebook/react, at the pinned commit, the published react-refresh package points its package.json repository.directory at packages/react instead of packages/react-refresh. The correction is one word, and it reads like metadata copied from a sibling package. A study of what code review catches (open manuscript) found that about 75 percent of the defects industrial reviewers raised had no visible effect on program behavior and instead improved evolvability and understandability; a wrong path in a manifest is squarely that kind of change. A json_path_equals rule asserts the expected directory deterministically, at pull-request time.
The third pattern is where the per-language argument becomes concrete: some invariants only exist across languages. In protocolbuffers/protobuf, the language bindings do not share a uniform layout, so there is no single parity check to run. What the case-study config does instead is assert a set of targeted invariants: that named binding directories resolve, that the expected workflow and failure-list files exist, and that the published versions in selected Java and Ruby manifests agree with the project version. Flutter, similarly, keeps a BSD header across source files in several languages. No single per-language linter sees these, because each one knows only its own subtree; alint checks them from one config.
A related pattern is the build that runs green while quietly doing less than you meant. VS Code exposes its proposed APIs as declaration files in src/vscode-dts/, one per proposal, each named vscode.proposed.<name>.d.ts; creating a correctly named file is what regenerates the registry the build reads. Nothing checks the names, so a single typo in that prefix leaves a .d.ts the discovery glob never matches. The build still succeeds, the proposal it was meant to add simply is not there, and nothing flags the mistake. Every declaration file in that folder, at the commit I pinned, is either the stable vscode.d.ts or a vscode.proposed.<name>.d.ts proposal, so a filename_regex rule over the whole directory, holding each .d.ts to one of those two shapes, turns that silent omission into a failing check.
Istio, at the commit I pinned, showed the other shape of it: a check that does run, but is too loose to catch what it should. Its copyright check, lint_copyright_banner.sh, requires each source file to contain the strings Copyright and Apache License, Version 2. At that commit, istioctl/pkg/precheck/precheck.go opened with the placeholder header that the Cobra CLI scaffolder drops in to be replaced, Copyright © 2021 NAME HERE <EMAIL ADDRESS>, above the standard Apache block. Both required substrings are present, so the substring check accepts it, even though it names no copyright holder. A file_header rule anchored to the expected banner, rather than to two substrings, would reject the placeholder. In the same tree at that commit, a release note, releasenotes/notes/27430.yaml, declared piVersion where it meant apiVersion. Istio’s release-note tool has since added schema validation that rejects exactly that typo, so this one is fixed upstream; it stands here as an example of the class, not a live bug. A yaml_path_equals rule on apiVersion is how you would pin the key from your own side.
None of these are disasters, and that is the point. They are the small, quiet gaps that open up when a check is written once and trusted forever, and they are what a second, structural pass is for.
alint holds itself to a version of this standard. It lints its own repository in CI, and this site runs alint in CI too, under a narrower, site-specific prose policy. One of those site rules forbids em dashes in the top-level docs. That is a house-style choice rather than an objective signal, and it is exactly the kind of small, boring rule that is easy to state and easy to forget to enforce.
Why not a tool that already exists
If this is a real category, has nobody built it? People have. The closest was Repolinter, a repository-policy linter from the Linux Foundation’s TODO Group. It could check file existence, contents, and more, and it was archived in February 2026; the final commit is titled, plainly, “Archiving Repolinter.”
The archive commit does not say why. Its issue tracker does record limitations that shaped alint’s design: it never honored .gitignore, it hit memory ceilings on large repositories (one report describes a 16 GiB heap), and it leaned on both Node.js and Ruby-backed Licensee and Linguist tooling, which made packaging on Windows and macOS harder. Adding a new primitive generally meant a change to the core rather than to config. I do not know that any of these caused the archive, and I will not claim they did.
alint is built to avoid them. It is one native executable, so installing it is installing one file. It honors .gitignore (walker docs). It emits SARIF and GitHub annotations from the start. It adds what Repolinter did not have: filename-pattern rules, per-directory quantification, and cross-file relationships as first-class primitives. For projects coming from Repolinter, the bundled oss-baseline ruleset is a starting point rather than a drop-in replacement: in alint’s current 42-entry migration matrix, it maps 30 of Repolinter’s defaults fully, 8 partially, and 4 without a clean equivalent, so the migration is a few lines of extends plus a review of what to keep from your old setup.
Narrower tools overlap at the edges. In a v0.5.7 filename-only benchmark, ls-lint ran faster than alint on that specific job; I have not re-measured that head-to-head at v0.14.2. Conftest evaluates values inside structured inputs with policy rules, but does not itself walk the tree or reason about which files exist. Semgrep matches patterns, and depending on edition and mode that is within a file or across files. Of the alternatives I evaluated, none combined the same set: language-agnostic, cross-file, content and structure and naming together, in one fast native executable that honors your ignore file. That combination is the gap.
alint and AI coding agents
There is a newer reason this layer matters. If you use an AI coding agent, you have probably watched it reintroduce the mess you just cleaned up: a scratch file left in the root, a new package with no README, a workflow written without pinning its actions. The agent is not exactly careless, but you cannot count on it to keep your conventions in mind. Some were never written anywhere a machine could read; the ones that are, in an AGENTS.md or a CONTRIBUTING file, an LLM still follows unevenly. Its adherence slips as the context window fills up (Chroma’s 2025 study of eighteen models found reliability degrading with input length, even on simple tasks, in line with the older lost-in-the-middle finding that models use the middle of a long context worst), and it slips again as the number of rules it has to satisfy at once grows (accuracy falls with each added instruction). A convention can sit right in the prompt and still be skipped deep into a session.
Some of the associated drift is measurable, with the usual caveats about young evidence. On a linked cohort of five frontier models, a 2026 preprint (summary) measured invented-package rates of 4.62 to 6.10 percent; it is a preprint rather than peer-reviewed work, and older cohorts of open-source models scored much higher, so the number depends heavily on which models you mean. Separately, one observational vendor report across 211 million changed lines reports rising code duplication and less of the refactoring that keeps a tree coherent.
alint does not detect duplicated code today, though a text-similarity duplicate-code detector is on the roadmap, and it does not discover arbitrary invented dependencies. What it can do right now is make selected consequences checkable: an allowlist of dependencies, a manifest or lockfile that has to stay consistent, a required path, a generated file that must be fresh, a package that must actually exist where a config says it does. And it can hand the agent the same rules you enforce everywhere else.
AGENTS.md is the emerging convention for telling an agent how a project works. It helps, but it drifts from what CI enforces, so you end up with two sources of truth that disagree. alint export-agents-md --inline --output AGENTS.md writes the currently active rules into a marked block in that file and leaves your prose around it untouched. By default it lists the active non-info rules (pass --include-info to include level: info nudges), phrased from each rule’s message or a generic description of its kind, not a full serialization of every parameter. Regenerate the block when .alint.yml changes, and gate it in CI if you want drift protection. Separately, alint check --format agent emits each finding as JSON with an exact fix command where one exists, which an agent can act on directly.
--inline
→
alint export-agents-md --inline --output AGENTS.md writes the active, non-info rules into the marked block and leaves your prose around it untouched. The listed rules are illustrative; --include-info adds info-level rules. Regenerate when .alint.yml changes, and gate the block in CI to catch drift.alint export-agents-md --inline --output AGENTS.mdalint check --format agentHow fast, and how to start
Because it reads files instead of building syntax trees, and does most of its work in a parallel walk with coalesced reads, alint is cheap to run. (Some rule classes, cross-file, git-aware, and command rules among them, do additional work beyond that walk.) On the published Linux benchmark host, an old laptop repurposed as a quiet dedicated box (an Intel Core i7-6700HQ with 4 physical cores and 8 threads, and 15 GB of RAM), the v0.14.2 S3 full-tree workspace scenario, which is 32 effective rules drawn from the bundled oss-baseline, rust, and monorepo rulesets and the cargo-workspace overlay, averaged about 1.702 seconds over 100,000 synthetic files and about 18.328 seconds over one million. The lighter S1 filename scenario averaged about 222 milliseconds at 100,000 files. These are workload- and hardware-specific numbers; the generator and methodology are public and rerunnable, and the full benchmarks carry per-release figures. The structural floor is cheap on hardware like this, so there is little reason not to run it on every pull request; a config that shells out to heavier tools can of course cost more.
Adopting alint on an existing repository does not require cleaning it first. alint baseline records today’s violations to .alint-baseline.json. Point your config at it (baseline: .alint-baseline.json in .alint.yml, or pass --baseline on the command line), and from then on alint check reports and gates only on new findings. The recorded debt is suppressed from the default output and shown as a count; --show-baselined lists it in full. So you can turn the check on today and pay the backlog down on your own schedule (baseline docs).
alint is pre-1.0. It is extensively tested, it dogfoods itself in CI, and the case studies are all public. What is not settled is the config format, which is deliberately what 1.0 is for; adopting now means the feedback you give shapes the format before it freezes.
If your repository leans on a convention it does not actually check, I would like to know which one.
brew install asamarts/alint/alint # macOS or Linuxbrew; other options in the docsalint init # scaffold a starter .alint.ymlalint check # run itThe installation guide covers install.sh, crates.io, Docker, and building from source; alint also publishes an npm package and a GitHub Action. The rule catalogue, the thirty case studies, and the benchmarks are all online, and the source is on GitHub under MIT or Apache-2.0.
Frequently asked questions
Is alint a replacement for ESLint, Clippy, or ruff?
No. Those parse the code inside your files and reason about source-language syntax and semantics, one language at a time. alint reads the shape of the repository around the code: which files exist, how they are named, what they contain, and the values inside config files. It runs beneath your existing linters, not against them.
Does alint work with my language?
It is language-agnostic. It reads files, directory structure, and structured config such as JSON, YAML, TOML, and XML, so one configuration model covers a polyglot repository and a single-language one alike. Scopes, formats, and any external commands you wire in still differ per project.
How is it different from Repolinter?
Repolinter was the closest prior tool, and it was archived in February 2026. alint covers much of the same ground: it is one native executable with no Node or Ruby runtime, it honors .gitignore, it adds cross-file and filename rules Repolinter did not have, and it emits SARIF and CI annotations out of the box. In alint's current 42-entry migration matrix, the bundled oss-baseline ruleset maps 30 of Repolinter's defaults fully, 8 partially, and 4 without a clean equivalent, so it is a strong starting point rather than a drop-in replacement. Keep specialist checks where you need them.
Can I adopt it on a repository that already has violations?
Yes. Run alint baseline to record today's findings, then enable the baseline in .alint.yml (baseline: .alint-baseline.json) or pass --baseline. From then on alint check reports and gates only on new findings; the recorded debt is suppressed from the default output, shown as a count, and can be listed with --show-baselined. So you can turn alint on in CI without a cleanup sprint first.
Is it production-ready?
It is pre-1.0. It is extensively tested and it dogfoods itself in CI, but the config format is not frozen yet. That is what 1.0 is for. Adopting now means your feedback shapes the format before it settles.
Comments