Skip to main content

Engineering safety lint rules and pre-commit integration for modern Python, JavaScript, and TypeScript codebases

Project description

SafeLint logo

CI PyPI Python Docs

📖 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.

Languages supported:

Language Extensions Notes
Python .py, .pyw
JavaScript .js, .mjs, .cjs Runtime-agnostic source analysis covering Node.js, browser, Deno, Cloudflare Workers, Bun, and any WASM-hosted JS engine. Per-runtime defaults are selectable via [tool.safelint.javascript] runtime = "...".
TypeScript (incl. TSX + AssemblyScript) .ts, .tsx, .as Reuses the JS rule implementations end-to-end with TS-specific handling for generics, as casts, non-null assertions, declare global blocks, etc. Shares JS runtime presets since TS compiles to JS.

Rule coverage: 17 rules apply to all three languages, 2 are Python-only (bare_except, global_state), and 1 is JavaScript-family-only (wide_scope_declaration for var).

Planned future languages (working-priority order, no timelines committed): Go, Rust, Java, C, C++, PHP. SafeLint's registry-driven design makes each addition incremental — see the language-coverage roadmap, 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, JavaScript, and TypeScript 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 SAFE401adapted: 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 — varlet / 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

SafeLint ships every per-language grammar as an opt-in extra. The base install includes only the engine — no grammars — so a Python-only project doesn't pay for JS/TS grammars, a Go/JS project doesn't pay for the Python grammar, and so on. Pick the extras that match the languages you actually lint:

v2.0.0rc1 (pre-release): Until v2.0.0 GA, pin the version explicitly — e.g. pip install 'safelint[python]==2.0.0rc1' — or pass --pre to any of the commands below. An unpinned pip install 'safelint[...]' will resolve to the latest 1.x release, which doesn't define these per-language extras and so wouldn't install any grammar at all.

pip install 'safelint[python]'         # adds .py, .pyw
pip install 'safelint[javascript]'     # adds .js, .mjs, .cjs
pip install 'safelint[typescript]'     # adds .ts, .tsx, .as (and bundles JS too)
pip install 'safelint[all]'            # every supported language

# Multiple extras compose — perfect for monorepos:
pip install 'safelint[python,javascript]'
pip install 'safelint[python,typescript]'

pip install safelint (no extras) installs only the engine. If you run it that way, safelint will emit a one-line hint on first run telling you which extra to add for the files it found. Same story if you mix-and-match — e.g. running safelint with [python] installed on a directory containing .ts files will skip the .ts files with a clear pip install hint while continuing to lint the supported (.py) files. If every candidate file ends up skipped (the typical TS-only / JS-only run against a bare base install), safelint exits with code 2 as a setup error instead so CI / pre-commit can't silently report clean on an un-linted run.


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 — pick the additional_dependencies line that matches the languages your repo contains:

repos:
  - repo: https://github.com/shelkesays/safelint
    rev: v2.0.0rc1  # replace with the latest release tag (use the GA tag once v2.0.0 ships)
    hooks:
      - id: safelint
        # Required in v2.0.0+ — pick the line for the language(s) your repo lints:
        additional_dependencies: ['safelint[python]']               # Python-only repo
        # additional_dependencies: ['safelint[javascript]']         # JS-only repo (Node / browser / Deno / Cloudflare Workers / Bun)
        # additional_dependencies: ['safelint[typescript]']         # TypeScript repo (bundles tree-sitter-javascript too)
        # additional_dependencies: ['safelint[python,javascript]']  # mixed monorepo
        # additional_dependencies: ['safelint[all]']                # kitchen-sink

        args: [--fail-on=error]   # or --fail-on=warning for stricter CI
        files: ^src/              # optional — scope to a directory

One hook, every language

The same id: safelint handles Python, JavaScript, and TypeScript — there's no safelint-python / safelint-js / safelint-ts split. The published hook spec sets types_or: [python, javascript, ts, tsx] so pre-commit automatically routes the right files to safelint; the engine then dispatches each file to its language-specific rule implementations based on extension. AssemblyScript .as files are not matched by that default types_or list — pre-commit's identify library has no as filetype tag, so AS users must override types_or with a permissive tag .as files actually carry (e.g. types_or: [text]) and use files: \.(ts|tsx|as)$ to scope the match. (types_or: [] doesn't work — pre-commit treats an empty list as "no tag matches", not "filter disabled".) Every flag (--fail-on, --mode, --ignore, --format, --statistics) and every config option behaves identically across languages. The only per-project lever is the additional_dependencies extra — that's what tells pre-commit which tree-sitter grammar(s) to install into the hook's isolated environment.

What happens if you forget the extra

The additional_dependencies line is genuinely required in v2.0.0+ — including for Python projects, which used to work without it. Forgetting it doesn't silently pass: safelint emits one stderr line per missing grammar with the exact fix and exits with code 2, which pre-commit reports as Failed (red):

safelint: warning: skipping .py files — add 'safelint[python]' to additional_dependencies in your .pre-commit-config.yaml
safelint: error: no files linted — every file pre-commit passed had a grammar that isn't installed — add 'safelint[python]' to additional_dependencies in your .pre-commit-config.yaml

See Exit codes for the full table.

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

SafeLint ships 20 rules across the Holzmann safety categories. 14 are on by default; 6 are opt-in (the dataflow trio is opt-in for performance reasons; the test-discipline and assertion rules are opt-in because they only make sense in projects that follow paired-test conventions).

Default-on rules (14)

Code Rule Severity What it flags
SAFE101 function_length error Functions longer than 60 lines
SAFE102 nesting_depth error Control flow nested more than 2 levels deep
SAFE103 max_arguments error Functions with more than 7 parameters
SAFE104 complexity error Functions with high cyclomatic complexity
SAFE201 bare_except error except: with no exception type (Python-only)
SAFE202 empty_except error except / catch blocks that do nothing
SAFE203 logging_on_error warning Except / catch blocks that swallow errors silently
SAFE301 global_state warning Use of the global keyword inside functions (Python-only)
SAFE302 global_mutation error Writing to global variables inside functions
SAFE303 side_effects_hidden error Pure-looking functions that secretly do I/O
SAFE304 side_effects warning Functions that call print, open, etc. without signalling intent
SAFE305 wide_scope_declaration warning var declarations — prefer let / const (JavaScript / TypeScript only)
SAFE401 resource_lifecycle error Files or connections opened outside a with block (Python) or without paired cleanup (JS / TS)
SAFE501 unbounded_loops warning while True loops with no break

Opt-in rules (6) — enable via [tool.safelint.rules.<name>] enabled = true

Code Rule Severity What it flags
SAFE601 missing_assertions warning Functions with fewer than 2 assertions (Holzmann rule #5)
SAFE701 test_existence warning Source files without a paired test file
SAFE702 test_coupling warning Source changes in a commit without a matching test change
SAFE801 tainted_sink error User input flowing into eval, exec, subprocess, etc. without sanitization (dataflow)
SAFE802 return_value_ignored warning Discarding the return value of calls like subprocess.run or file.write (dataflow)
SAFE803 null_dereference error Chaining methods directly on calls that can return None, e.g. d.get("key").strip() (dataflow)

Plus SAFE004 unused_suppression (engine meta-check, on by default) — flags stale # nosafe directives that no longer suppress anything. Disable globally via ignore = ["SAFE004"] if undesired.

For per-rule defaults, configuration knobs, and full examples, 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_ignores to grow the defaults instead of replacing them (mirrors ruff's extend-select ergonomics).
  • --statistics flag — print a per-rule violation-count table at the end of a run (safelint check src/ --statistics).
  • SAFE004 unused_suppression — automatically warns about stale # nosafe directives that no longer suppress anything. Disable globally via ignore = ["SAFE004"] if undesired.
  • No --fix flag — SafeLint is review-only by design. Editor integrations may surface suggestions as code actions, but every edit is user-confirmed.

Ready-to-copy samples:


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 modesafelint --stdin --stdin-filename PATH --format json lints unsaved buffer contents fed via stdin. Designed for editor extensions (VSCode plugin, custom LSP wrappers).
  • JSON / SARIF output--format json and --format sarif emit 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 / VSCode Range and SARIF region.endColumn. Synthetic violations (e.g. test_existence) leave column fields null; editors should treat that as "underline the whole line".
  • Advisory suggestions — every violation may carry a suggestions array with one-line descriptions and TextEdit ranges. Editor integrations must never apply these automatically — every edit goes through user confirmation. SARIF output uses the spec's native fixes[] block for the same data. SafeLint will never ship a --fix flag.

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:

  1. In PyPI, open your project → ManagePublishingAdd a trusted publisher.
  2. Use:
    • Owner: shelkesays
    • Repository: safelint
    • Workflow: publish.yml
    • Environment: pypi
  3. In GitHub, create an environment named pypi in 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

safelint-2.0.0rc1.tar.gz (237.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

safelint-2.0.0rc1-py3-none-any.whl (271.5 kB view details)

Uploaded Python 3

File details

Details for the file safelint-2.0.0rc1.tar.gz.

File metadata

  • Download URL: safelint-2.0.0rc1.tar.gz
  • Upload date:
  • Size: 237.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for safelint-2.0.0rc1.tar.gz
Algorithm Hash digest
SHA256 bc1c985e399a97ca0bd6a19ab5dadf79d5676770cd0845e957288e9fe34fe2f3
MD5 f545afa5954630f443cc057b31905514
BLAKE2b-256 fa46c96dd12a483b02d17ce1ad4c2ba15e6136bf894134e4c076d2e225f8c337

See more details on using hashes here.

Provenance

The following attestation bundles were made for safelint-2.0.0rc1.tar.gz:

Publisher: publish.yml on shelkesays/safelint

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file safelint-2.0.0rc1-py3-none-any.whl.

File metadata

  • Download URL: safelint-2.0.0rc1-py3-none-any.whl
  • Upload date:
  • Size: 271.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for safelint-2.0.0rc1-py3-none-any.whl
Algorithm Hash digest
SHA256 3d4433ffd44656d8caa991d9ba21475fc7e21836f51cbc393020cde515a4ca89
MD5 603a3af88c9b93900d5596be6d241440
BLAKE2b-256 f38878ec20d9440aaaea48d987d79c90ef84cf5c7ddf8d3b82b01b3782f9c3cc

See more details on using hashes here.

Provenance

The following attestation bundles were made for safelint-2.0.0rc1-py3-none-any.whl:

Publisher: publish.yml on shelkesays/safelint

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page