Engineering safety lint rules and pre-commit integration for modern Python and JavaScript codebases
Project description
SafeLint
📖 Full documentation: https://shelkesays.github.io/safelint/ — searchable, navigable, with a per-client install guide for each of the twelve supported AI agents. The README below is the repo home; the docs site is the user reference.
SafeLint is a configurable static analysis tool that enforces safety-critical coding practices inspired by Gerard J. Holzmann's "Power of Ten" rules at NASA/JPL.
Originally designed for mission-critical systems, these principles apply to any modern codebase - and are especially valuable when code is written fast, reviewed quickly, or generated by AI.
Currently supported: Python (.py, .pyw) and JavaScript (.js, .mjs, .cjs — runtime-agnostic; runs identically against Node.js, browser, Deno, Cloudflare Workers, Bun, and any WASM-hosted JS engine, with per-runtime defaults selectable via config).
Planned (working-priority order, no timelines committed): TypeScript / AssemblyScript, Go, Rust, Java, C, C++, PHP. SafeLint's registry-driven design makes each addition incremental — see the language-coverage roadmap for the current ordering, and Adding a language if you'd like to help.
SafeLint integrates with pre-commit and CI pipelines to prevent unsafe code from entering your codebase.
Why SafeLint?
Fast-moving codebases - whether written by humans under pressure or generated by AI tools - tend to drift toward the same failure patterns:
- Unbounded loops
- Silent error handling
- Hidden side effects
- Poor resource management
SafeLint catches these early, automatically, regardless of who wrote the code.
Philosophy
"When it really counts, it may be worth going the extra mile and living within stricter limits than may be desirable."
- Gerard J. Holzmann, NASA/JPL
Power of Ten - adapted for modern languages
In 1987, Holzmann wrote ten rules for spacecraft software at NASA/JPL. Nearly four decades later, the same failure patterns appear in every fast-moving codebase. SafeLint is those ten rules, adapted for modern languages (Python and JavaScript today; further languages in future releases) and automated.
| # | Holzmann's Rule | SafeLint Rule | Code |
|---|---|---|---|
| 1 | No complex control flow - no goto, no deep recursion |
nesting_depth, complexity |
SAFE102, SAFE104 |
| 2 | All loops must have a fixed upper bound | unbounded_loops |
SAFE501 |
| 3 | No dynamic memory allocation after startup | resource_lifecycle |
SAFE401 — adapted: managed runtimes allocate dynamically by default; the rule becomes "acquired resources must have guaranteed cleanup" |
| 4 | Functions must fit on one printed page | function_length |
SAFE101 |
| 5 | Use at least two assertions per function | missing_assertions |
SAFE601 |
| 6 | Declare variables at the smallest scope | wide_scope_declaration |
SAFE305 (JavaScript — var → let / const; Python's lexical scoping handles this natively) |
| 7 | Check the return value of every non-void function | return_value_ignored, bare_except, empty_except, logging_on_error |
SAFE802, SAFE201, SAFE202, SAFE203 |
| 8 | Limit preprocessor use | - | (not applicable to Python or JavaScript) |
| 9 | Restrict pointer use - no chained indirection | null_dereference |
SAFE803 |
| 10 | Compile with all warnings; use static analysis | SafeLint itself | - |
Original paper: spinroot.com/gerard/pdf/P10.pdf
SafeLint also ships several rules that go beyond Holzmann's original ten — modern additions for state purity (global_state / global_mutation for shared-state writes), hidden side effects (side_effects_hidden / side_effects for I/O lurking behind pure-sounding names), dataflow taint (tainted_sink for unsanitised input reaching eval / exec / equivalents), and test discipline (test_existence / test_coupling for paired-test enforcement). See the full rules reference for every rule with examples and configuration.
Installation
pip install safelint
Usage
Discover the CLI surface (ruff-style help and version):
safelint --help # or: safelint help, safelint -h
safelint help check # subcommand-specific help
safelint --version # or: safelint version, safelint -V
Check modified files (default — only files changed since last commit):
safelint check src/
Check all files (full scan, e.g. in CI):
safelint check src/ --all-files
Check specific files (pre-commit style):
safelint src/mymodule.py src/utils.py
Fail on warnings too (useful in CI):
safelint check src/ --all-files --fail-on=warning
Run in CI mode (warnings become blocking):
safelint check src/ --all-files --mode=ci
Ignore specific rules for one run:
safelint check src/ --ignore SAFE203 --ignore side_effects
Machine-readable output for tooling consumers (editors, CI, the Claude Code skill):
safelint check src/ --format=json # stable JSON schema
safelint check src/ --format=sarif # SARIF 2.1.0 (GitHub code scanning, etc.)
Lint un-saved buffer contents from stdin (editor mode):
cat my_module.py | safelint --stdin --stdin-filename my_module.py --format=json
--stdin-filename drives language detection by extension and is shown as the violation file path. Combine with --format=json so the editor can parse the result.
Disable the lint-result cache:
safelint check src/ --no-cache # otherwise: ~instant re-runs on unchanged files
By default safelint memoises rule output keyed on sha256(source + engine config + filepath) in a .safelint_cache/ directory next to your config file (mirroring .pytest_cache). The filepath is folded in so two files with identical contents under different paths get separate entries, and Violation.filepath always reflects the current call. Add .safelint_cache/ to .gitignore.
Pre-commit integration
Add this to your .pre-commit-config.yaml:
repos:
- repo: https://github.com/shelkesays/safelint
rev: v1.9.0 # replace with the latest release tag
hooks:
- id: safelint
args: [--fail-on=error] # use --fail-on=warning for stricter CI
files: ^src/
Then install the hooks:
pre-commit install
SafeLint will now run on every git commit and block the commit if it finds errors.
What it checks
| Code | Rule | What it flags |
|---|---|---|
| SAFE101 | function_length |
Functions longer than 60 lines |
| SAFE102 | nesting_depth |
Control flow nested more than 2 levels deep |
| SAFE103 | max_arguments |
Functions with more than 7 parameters |
| SAFE104 | complexity |
Functions with high cyclomatic complexity |
| SAFE201 | bare_except |
except: with no exception type |
| SAFE202 | empty_except |
except blocks that do nothing (pass) |
| SAFE203 | logging_on_error |
Except blocks that swallow errors silently |
| SAFE301 | global_state |
Use of the global keyword inside functions |
| SAFE302 | global_mutation |
Writing to global variables inside functions |
| SAFE303 | side_effects_hidden |
Pure-looking functions that secretly do I/O |
| SAFE304 | side_effects |
Functions that call print, open, etc. without signalling intent |
| SAFE401 | resource_lifecycle |
Files or connections opened outside a with block |
| SAFE501 | unbounded_loops |
while True loops with no break |
Dataflow rules (opt-in, disabled by default):
| Code | Rule | What it flags |
|---|---|---|
| SAFE801 | tainted_sink |
User input flowing into eval, exec, subprocess, etc. without sanitization |
| SAFE802 | return_value_ignored |
Discarding the return value of calls like subprocess.run or file.write |
| SAFE803 | null_dereference |
Chaining methods directly on calls that can return None, e.g. d.get("key").strip() |
For opt-in rules (SAFE601, SAFE701, SAFE702) and full configuration options for every rule, see the Rules reference.
Suppressing violations inline
Add a # nosafe comment to suppress a violation on a specific line without changing global config.
Suppress all violations on a line:
result = eval(user_input) # nosafe
Suppress a specific rule by code:
while True: # nosafe: SAFE501
...
Suppress by rule name:
while True: # nosafe: unbounded_loops
...
Suppress multiple rules at once:
def get_data(conn, q, p1, p2, p3, p4, p5, p6): # nosafe: SAFE101, SAFE103
...
When at least one violation is suppressed, the CLI summary reports a per-code breakdown (e.g. (2 SAFE501, 1 SAFE304 suppressed)) so suppressions remain visible and auditable. Use # nosafe sparingly — it's for line-level exceptions only. For broader suppression use the config-level options:
# pyproject.toml
[tool.safelint]
ignore = ["SAFE203", "side_effects"] # suppress project-wide
[tool.safelint.per_file_ignores]
"tests/**" = ["SAFE101", "SAFE103"] # suppress only for matching files
See Inline suppression, Global ignore list, and Per-file ignore list for full reference.
Configuration
SafeLint is configured via [tool.safelint] in your pyproject.toml, or a standalone safelint.toml file at your project root. When both exist in the same directory, safelint.toml wins — its values override anything in [tool.safelint] — matching ruff's ruff.toml / pyproject.toml precedence. See the Configuration reference for all options, defaults, and examples.
Highlights:
- Incremental ignore lists — use
extend_ignore/extend_per_file_ignoresto grow the defaults instead of replacing them (mirrors ruff'sextend-selectergonomics). --statisticsflag — print a per-rule violation-count table at the end of a run (safelint check src/ --statistics).SAFE004 unused_suppression— automatically warns about stale# nosafedirectives that no longer suppress anything. Disable globally viaignore = ["SAFE004"]if undesired.- No
--fixflag — SafeLint is review-only by design. Editor integrations may surface suggestions as code actions, but every edit is user-confirmed.
Ready-to-copy samples:
- examples/sample.pyproject.toml —
[tool.safelint]block for an existing pyproject.toml - examples/sample.safelint.toml — standalone
safelint.toml(no[tool.safelint]wrapper)
Editor / agent integrations
AI-client skills (12 clients supported)
pip install safelint
safelint skill install # auto-detects which AI client(s) you use
Twelve AI clients are supported today — every one of them runs through the same safelint skill command surface, so you only need to learn one workflow:
| Client | Marker safelint looks for | Where the skill lands |
|---|---|---|
| Claude Code | CLAUDE.md, .claude/, .claude.json |
~/.claude/skills/safelint/ (or <cwd>/...) |
| Cursor | .cursor/, .cursorrules |
~/.cursor/rules/safelint.mdc (or <cwd>/...) |
| GitHub Copilot | .github/copilot-instructions.md, .github/copilot/, .github/instructions/ |
.github/copilot-instructions.md |
| Gemini | GEMINI.md, .gemini/ |
GEMINI.md at repo root |
| Windsurf | .windsurfrules, .codeium/ |
.windsurfrules at repo root |
| codex | .codex/, AGENTS.md |
.codex/instructions.md (and a section in AGENTS.md if present) |
| Continue.dev | .continue/, .continuerc, .continuerc.json |
.continue/rules/safelint.md |
| Cline | .clinerules/ |
.clinerules/safelint.md |
| aider | .aider.conf.yml, CONVENTIONS.md |
CONVENTIONS.md (you wire it in via read: in .aider.conf.yml) |
| Trae | .trae/ |
.trae/rules/safelint.md |
| Antigravity | .antigravity/ |
.antigravity/rules/safelint.md |
| Zed | .rules, .zed/ |
.rules at repo root |
safelint skill install (with no flags) is --client auto under the hood: it looks for any of the markers above in your current directory, falls back to your home directory if cwd has none, and installs whatever it finds. If your project uses two clients (e.g. Claude and Cursor), it installs both — no flag needed.
After install, restart the AI client (or reload its window) and ask things like "run safelint", "lint my changes with safelint", or "do a Power-of-Ten review on src/api/auth.py". The skill takes care of the rest: it invokes safelint with structured JSON output, groups violations by file, and offers to walk through fixes.
After pip install --upgrade safelint, your installed skill files are still the old version — the wheel's bundled files moved on without them. To catch up:
safelint skill status # shows fresh / differs per detected install (exit 1 if anything differs)
safelint skill update # re-installs only the ones that drifted (no-op if everything is fresh)
safelint skill remove # auto-detects and removes every install
safelint skill remove accepts a few filter flags: --symlink keeps copy installs and only removes the ones created with --symlink (i.e., the skill file is a symlink pointing back at the bundled wheel — handy for skill developers); --path PATH removes one specific install location safelint's auto-detect didn't find; --dry-run previews everything without touching disk.
For explicit control (--client <name> for any of the twelve), per-client setup, project-vs-user-scope semantics, symlink mode for skill development, post-upgrade workflow, and troubleshooting, see the AI client integrations guide. To add support for a new AI client (the registry is open-ended), follow the contributor walkthrough in Adding a new AI client.
Other integration points
- Stdin mode —
safelint --stdin --stdin-filename PATH --format jsonlints unsaved buffer contents fed via stdin. Designed for editor extensions (VSCode plugin, custom LSP wrappers). - JSON / SARIF output —
--format jsonand--format sarifemit stable, machine-readable documents. The JSON schema is documented in the JSON output schema. SARIF output is GitHub code-scanning compatible. - Column-precise positions — every violation carries
lineno,end_lineno,column_start,column_end(1-based, half-open). Maps directly to LSP / VSCodeRangeand SARIFregion.endColumn. Synthetic violations (e.g.test_existence) leave column fieldsnull; editors should treat that as "underline the whole line". - Advisory suggestions — every violation may carry a
suggestionsarray with one-line descriptions andTextEditranges. Editor integrations must never apply these automatically — every edit goes through user confirmation. SARIF output uses the spec's nativefixes[]block for the same data. SafeLint will never ship a--fixflag.
Development
# Install with dev dependencies
pip install -e ".[dev]"
# Run tests
pytest
# Run the linter on itself
safelint check src/
Releasing to PyPI (Trusted Publishing)
This project publishes to PyPI via GitHub Actions using PyPI Trusted Publishing (OIDC). Do not use local uv publish username/password auth.
One-time setup:
- In PyPI, open your project → Manage → Publishing → Add a trusted publisher.
- Use:
- Owner:
shelkesays - Repository:
safelint - Workflow:
publish.yml - Environment:
pypi
- Owner:
- In GitHub, create an environment named
pypiin Settings → Environments.
Release flow:
# 1) bump version in pyproject.toml
# 2) commit and push
git tag vX.Y.Z
git push origin vX.Y.Z
Pushing the version tag triggers .github/workflows/publish.yml, which builds and publishes to PyPI.
Getting help
If you hit a bug, want a feature, or just don't know which flag to reach for, see SUPPORT.md — it lists where to ask each kind of question and what to include in a bug report so we can help quickly.
Contributing
See CONTRIBUTING.md for guidelines on bug reports, adding new rules, AI clients, or languages, and opening pull requests. By participating in this project you agree to abide by the Code of Conduct.
Citing
If you use safelint in academic work, see CITATION.cff for the canonical citation metadata. GitHub renders this file as a "Cite this repository" button on the repo home page.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file safelint-1.13.0rc1.tar.gz.
File metadata
- Download URL: safelint-1.13.0rc1.tar.gz
- Upload date:
- Size: 201.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5814d73b878063a4a0ec606869ff8fe65241673f013fc1672533572a60ae4875
|
|
| MD5 |
59b8a7fbfc95086dbd2b1b7f5eb1c86a
|
|
| BLAKE2b-256 |
f29df9d47404187fea1f2dcd6fa997df311d08141650fbb2a8e6ecf34ef09793
|
Provenance
The following attestation bundles were made for safelint-1.13.0rc1.tar.gz:
Publisher:
publish.yml on shelkesays/safelint
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
safelint-1.13.0rc1.tar.gz -
Subject digest:
5814d73b878063a4a0ec606869ff8fe65241673f013fc1672533572a60ae4875 - Sigstore transparency entry: 1507236149
- Sigstore integration time:
-
Permalink:
shelkesays/safelint@21a4f6936a93298ab8a7c108f367f81190df94ba -
Branch / Tag:
refs/tags/v1.13.0rc1 - Owner: https://github.com/shelkesays
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@21a4f6936a93298ab8a7c108f367f81190df94ba -
Trigger Event:
push
-
Statement type:
File details
Details for the file safelint-1.13.0rc1-py3-none-any.whl.
File metadata
- Download URL: safelint-1.13.0rc1-py3-none-any.whl
- Upload date:
- Size: 232.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f583ae2bddf659e248e82f23569ed19048830770566a5986c7ebc81d9b1c05df
|
|
| MD5 |
49b15b05540e96605d47c10017dec720
|
|
| BLAKE2b-256 |
9755b406826d14345f564bf7db1513bec1411e98627e9c0aba0ba2e99c06922b
|
Provenance
The following attestation bundles were made for safelint-1.13.0rc1-py3-none-any.whl:
Publisher:
publish.yml on shelkesays/safelint
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
safelint-1.13.0rc1-py3-none-any.whl -
Subject digest:
f583ae2bddf659e248e82f23569ed19048830770566a5986c7ebc81d9b1c05df - Sigstore transparency entry: 1507236215
- Sigstore integration time:
-
Permalink:
shelkesays/safelint@21a4f6936a93298ab8a7c108f367f81190df94ba -
Branch / Tag:
refs/tags/v1.13.0rc1 - Owner: https://github.com/shelkesays
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@21a4f6936a93298ab8a7c108f367f81190df94ba -
Trigger Event:
push
-
Statement type: