Felix suggested I start a dedicated topic after I mentioned this project in the introduction thread, so here it is
I recently built ansible-static-lint (astl), a Go implementation of the ansible-lint rules that can be decided from the YAML source alone.
The starting observation was fairly simple: a significant part of the latency of ansible-lint comes from starting and loading the Ansible runtime, while many lint rules do not actually need that runtime.
So instead of trying to replace ansible-lint, I separated the two concerns:
astl handles the static rules where very fast feedback is useful
ansible-lint remains the reference for syntax checking, collection resolution, schemas, --fix, and the other runtime-dependent checks
Currently astl implements 38 of the 51 default ansible-lint rules.
On the frozen ansible-lint compatibility corpus, it reproduces 2370 / 2370 findings within its supported scope, including file, line, column and message.
On my test machine:
ansible-lint
astl
6-line playbook
2.1 s
2.5 ms
478 files corpus
46.8 s
37 ms
The comparison is intentionally not presented as equivalent work: ansible-lint also performs runtime-dependent checks that astl deliberately does not. The point is that the static subset becomes cheap enough to run in an editor, pre-commit hook, or on every CI push.
I have also tried to make adoption incremental rather than introducing another configuration:
existing .ansible-lint configuration
skip_list, enable_list, warn_list, profiles
# noqa
.ansible-lint-ignore (added in v0.2.0 after the first user request)
ansible-lint rule identifiers by default
GitHub Action and pre-commit support
SARIF output
GitHub:
The project is still very young, and at this stage what I am most interested in is real Ansible repositories and workflows where this approach breaks down or is simply not useful.
In particular I’d be interested in hearing:
whether a fast static layer alongside ansible-lint makes sense in your workflow
which compatibility gaps would prevent you from using it
and examples of repositories where I should test it
@arhuman this is great work, and the static/runtime split is the right framing. I want to raise a use case that I think astl is well positioned to solve, and float an idea for it.
The developers I work with write Ansible YAML, not Python, and many of them are on locked down corporate Windows machines. Today they effectively can’t lint in their editor. ansible-lint needs the Python and Ansible runtime, and since the control node isn’t supported natively on Windows, they’re pushed to WSL, Docker/Podman, or installing extra tooling which is blocked or heavily gated in enterprise environments. So a lot of people who would benefit from linting get none.
A Go implementation is a big step forward because a compiled binary carries no runtime dependency. But in the most locked down shops, a standalone .exe has its own issues. A new, unknown native executable would likely need elevated permissions and be flagged by Windows Defender (and other similar tools).
The one common runtime already present and approved on those machines is VS Code itself, which ships Node. So the idea is to compile the existing astl static core to WASM and bundle it inside a VS Code extension. As I understand Go’s WASM targets, that’s a recompile of your current code rather than a rewrite. The rule engine stays as is, and the work is mostly at the I/O boundary (WASI/stdin stdout or JS glue) plus a binary size decision (standard Go vs. TinyGo, the latter being tricky with reflection heavy YAML libs).
From an enterprise approval standpoint that packaging is a lot cleaner than an exe. Nothing new executes at the OS level (the .wasm is data loaded by the already vetted VS Code process), so there’s no new signed binary, no child-process behavior for EDR to chase, and a single platform independent VSIX that’s easy to scan and mirror. Ideally published to Open VSX, and fully self contained with no runtime network calls for limited/no connectivity environments. The native CLI still serves everyone who wants it, and the same rule logic powers both.
Is a WASM/editor target something you’ve considered, or is CLI the intended scope for now?
Thanks Jeff, this is a really interesting use case, and one I hadn’t considered in that form.
I agree that the static core makes a WASM/editor target plausible without changing the rule engine itself.
I’m curious about two points though.
In the corporate environments you have in mind, are VS Code extensions actually easier to get approved than a standalone executable? “astl” itself doesn’t require elevated privileges and only reads the files it analyzes, so I assume the real constraint is more about execution policies / EDR blocking unknown executables than privileges themselves.
I’m also wondering about the filesystem side. “astl” currently operates directly on files and configuration in the workspace. With WASM sandboxing, I assume the VS Code host would need to expose that filesystem access or feed the relevant contents to the WASM module. Have you experimented with this pattern in VS Code?
If a self-contained VSIX is significantly easier to deploy in those environments, and the filesystem boundary remains reasonably simple, then I agree this could be a very interesting target for “astl”.
One thing to note is that ansible-static-lint is also a lot more secure to run on not 100% trusted collections, since it doesn’t execute code from that collection (see for example #2681 or #4643).
Especially the fact that it does not execute ansible-playbook --syntax-check which causes issues with files encrypted by ansible-vault.
It will probably also not fail when used Collections are not installed. (which is annoying in CI environments)
A small update, and mostly a thank you to everyone who tested ansible-static-lint and shared feedback after my first posts here.
A surprising amount has changed in just a few weeks, much of it directly driven by real-world usage:
support for .ansible-lint-ignore
several compatibility fixes found on real repositories
proper exclude_paths, .gitignore and builtin exclude semantics
richer SARIF output for CI / code scanning
smoother pre-commit / prek integration
fixes specifically uncovered while testing against larger projects such as Kubespray
The project is now at v0.5.1.
The part I really didn’t expect this early: Kubespray has now adopted astl in its pre-commit/CI workflow, with astl running as a normal hook while the full ansible-lint check is kept for the slower/manual stage.
For a project that was only made public a few weeks ago, that’s quite a milestone , certainly earlier than I expected myself.
So, thank you. The reports, edge cases and discussions here have materially improved the tool.
Please keep sending repositories where it behaves differently from ansible-lint, produces false positives, or simply doesn’t fit your workflow. Those cases are still the most useful feedback I can get.
I don’t currently have plans for user-defined custom rules. One of the design constraints of astl is that it deliberately stays within the subset of ansible-lint rules that can be decided statically from the YAML source, without loading or executing Ansible.
That said, the set of static checks can still grow. For example, I’m currently looking at adding support for jinja[spacing], which looks like a good fit for that model.
If you have a specific custom rule, feature, or static check in mind, I’d be very interested to hear the use case. If it can be evaluated from the source alone, it might fit astl quite naturally.
I prefer not to see set_fact inside ansible yaml content and some other plugins. So first custom rule I can think of is to disallow certain plugins/ modules.
That’s a good example, and it is fully statically decidable.
I’m considering keeping the default astl behaviour strictly compatible with the static subset of ansible-lint, while allowing additional astl-specific checks behind an explicit opt-in. Something like:
Findings would carry their own namespace so they’re never confused with an upstream rule:
tasks/main.yml:12:3: astl[forbidden-action] Use of ansible.builtin.set_fact is forbidden
Each extended rule stays individually opt-in: if you don’t configure it, nothing changes. That way projects can add static policy checks without touching the compatibility contract of the default mode, and upgrading astl never silently adds findings to an existing CI run.
One question before I build it: would a flat deny-list be enough for your projects, or do you need exceptions by path (e.g. set_fact forbidden everywhere except under roles/bootstrap/)? The first is trivial, the second changes the shape of the config, and I’d rather get it right the first time than break it later.
- name: test
ansible.builtin.set_fact: # noqa: ASTL<rulenumber>
I was thinking more about creating own rules, similar to what ansible-lint now allows. With actual code that is plugged in, not just configuration. Not sure how it is done in go, in python it is relatively simple.
For suppressions, I agree that an inline # noqa is the better fit. It keeps the exception local and stays consistent with the existing ansible-lint model.
On custom rules, I understand now that you mean actual user-provided rule code.
I’m a bit cautious about that direction, because one of the things I want astl to preserve is a fairly strong set of guarantees: purely static analysis, predictable performance, deterministic behavior, no arbitrary code execution, and a small trust/security surface.
Once external rule code can be loaded, those guarantees become much harder to keep. A plugin could potentially do I/O, execute commands, add significant latency, or introduce non-deterministic behavior.
So I’m more inclined to explore configurable static policies that astl can still fully control, rather than arbitrary executable plugins.
Your use case is useful though, because it helps define where that boundary should be.
Have you thought about providing a PyPI package to install astl? Since most Ansible tooling is written in Python and thus can be installed through pip/pipx/…, having a PyPI package for astl would simplify using it in Python-oriented workflows.
That’s a good point. I hadn’t initially considered PyPI since astl is a Go binary, but “pip”/“pipx” is obviously a very natural distribution channel for the Ansible ecosystem.
I’ll look into providing a thin PyPI package that installs the appropriate astl binary, while keeping astl itself dependency-free and statically compiled.
Following up on this: ansible-static-lint is now on PyPI as of v0.6.0.
pipx install ansible-static-lint
astl .
Better than the “thin package that downloads a binary” I described: the wheels carry the binary directly, in .data/scripts/, as ruff does. No download at install time, no interpreter at runtime, cold start still ~2 ms.
Eight wheels cover Linux, macOS and Windows on amd64 and arm64, glibc and musl. Requires-Python is >=3.8, since astl needs none of it.
They are packed from the signed release artifacts, not rebuilt: the workflow verifies the cosign-signed checksums before packing, so a wheel’s binary is byte-for-byte the signed one. Trusted publishing, PEP 740 attestations, SBOM in dist-info/sboms/. Steps in docs/supply-chain.md.
The same release adds latest (latest[git], latest[hg]), part of the safety profile, bringing astl to 39 of the 51 default rules. All 17 findings on ansible-lint’s examples corpus are reproduced byte for byte. It was the last rule in my scope analysis needing nothing but the work: what astl still misses now depends on a runtime, the plugin loader, Jinja or the schema bundle. Details in docs/scope.md.
The pre-commit hook still uses language: golang. I would rather let the wheels prove themselves for a release before switching the default, since a broken hook breaks every consumer’s CI.
Thanks again for the suggestion, it was the right call. A much more natural entry point for anyone already managing ansible-lint with pip or pipx.