Rules reference#
Each rule has:
- A code, short identifier like
SAFE101, shown in the output. Use this to search docs or issues. - A name, the key used in config files.
- An enabled flag, set to
falseto turn the rule off. - A severity,
"error"blocks the commit;"warning"is informational. - A language scope, most rules apply to Python, JavaScript, and TypeScript; a few are language-specific (see below).
- Rule-specific options documented below.
For top-level config keys (mode, ignore, per_file_ignores, …) see the Configuration file. For inline / file-level suppression see Suppression mechanisms. JavaScript projects may also want to set a runtime preset so rule defaults match the deployment target (browser / Deno / Cloudflare Workers / Bun).
Language coverage#
Currently supported#
- Python (
.py,.pyw). Per-framework defaults (taint sinks, nullable methods, and which of the sharedSAFE905-907framework rules are active) are switchable via the[tool.safelint.python] framework = "..."preset (vanilla/django/flask/fastapi), with an orthogonalpydantic = trueaxis. - JavaScript (
.js,.mjs,.cjs), source analysis is runtime-agnostic and runs identically against Node.js, browser, Deno, Cloudflare Workers, Bun, and any WASM-hosted JS engine (QuickJS-WASM, Boa, etc.). Per-runtime defaults (the lists of tracked acquirers, sinks, sources, global namespaces, etc.) are switchable via the[tool.safelint.javascript] runtime = "..."preset, the source-language rules themselves don't change. - TypeScript (
.ts,.tsx), and AssemblyScript (.as, TypeScript-syntax language compiling to WebAssembly, parsed by the same grammar). Reuses the JavaScript rule implementations end-to-end (TS compiles to JS at runtime; AST is a superset), with TS-specific handling for type-only constructs the JS rules wouldn't otherwise recognise (generic type parameters,ascasts, non-null assertions,declare globalambient declarations, etc.). Shares the JavaScript runtime presets, TS doesn't get its own runtime config because TS source executes in the same runtimes JS does. See TypeScript for the full language reference. - Java (
.java), new in v2.1.0. 20 rules apply (the 15 cross-language core plus the 5 also registered for Python / JS / TS) plus 4 Spring Boot framework-specific structural rules (SAFE901-904) target Spring annotation patterns. Per-framework defaults (sinks, nullable methods, structural rule enablement) are switchable via the[tool.safelint.java] framework = "..."preset (vanilla/spring-boot). See Java for the full language reference. - Rust (
.rs), new in v2.2.0. 15 of the cross-language rules port cleanly plus 11 Rust-only rules cover Rust-idiom-specific patterns (panic-in-non-test, lock poisoning,unsafeblock documentation, truncatingascasts, silentErrarms, dangerousmem::*ops, needlessmut, unchecked arithmetic on integer parameters, broad.unwrap()outside tests, interior-mutablestatics, plus the empty-Err/ unlogged-ErrRust analogues ofempty_except/logging_on_error). 7 rules deliberately skipped for Rust because their semantics don't translate cleanly (Rust has no try/catch /globalkeyword, RAII / Drop covers resource cleanup, and macros are opaque to the rule-8 dynamic-execution check). Recognises both inline#[cfg(test)] mod testsand Cargotests/<stem>.rsintegration-test conventions. See Rust for the full language reference. - Go (
.go), new in v2.5.0. 16 cross-language rules apply (the 13 all-language core plus SAFE302 / SAFE309 / SAFE401, which Go shares with Python / JS / TS / Java / PHP but Rust skips) and 2 Go-only rules cover Go-idiom patterns: SAFE209 (empty_error_check, the emptyif err != nil {}swallow) and SAFE211 (panic_calls_outside_tests). 7 rules deliberately skipped for Go because their semantics don't translate cleanly (no try/catch, noglobalkeyword, novarhoisting, no production assertion idiom, no chained-nullable idiom). Headline Go adaptations: the barefor {}infinite loop (SAFE501), the siblingfoo_test.goconvention (SAFE701 / SAFE702), the_ = f()explicit-discard exemption (SAFE802), and thedefer x.Close()resource form (SAFE401). See Go for the full language reference. -
PHP (
.php), new in v2.6.0. 21 rules apply and only 2 are skipped (SAFE201bare_exceptand SAFE305wide_scope_declaration), the widest rule coverage of any non-Python language because PHP ports the largest share of the existing rule set. PHP is the first non-Python home for SAFE301 (global_state): PHP has a literalglobalkeyword, so the rule fires onglobal $config;-style declarations exactly as it does on Python. PHP also has try/catch (SAFE202 / SAFE203 apply),evaland dynamic-call surfaces (SAFE309), and resource lifecycles (SAFE401). Headline PHP highlights: the@-operator error-suppression idiom, superglobal taint sources ($_GET/$_POST/$_REQUEST/ etc.) feeding SAFE801, and thebreak N;/continue N;multi-level loop forms. Per-framework defaults are switchable via the[tool.safelint.php] framework = "..."preset (vanilla/laravel), which enables the sharedSAFE905-907framework rules. See PHP for the full language reference. -
C (
.c,.h), new in v2.7.0. Holzmann's original target language. 21 rules apply: the 16 cross-language ports plus 5 new C-family rules (the "homecoming", shared with C++) that express clauses every other language adapts away - SAFE106 (nonlocal_jumps,goto/setjmp), SAFE310 (dynamic_allocation, themallocfamily), SAFE311 (complex_macro) and SAFE312 (conditional_compilation) for the preprocessor, and SAFE313 (restricted_pointers). SAFE106 is the only one enabled by default (warning severity, becausegoto errcleanup is idiomatic); the other four are opt-in..hheaders are linted as C. 7 rules are skipped: SAFE201/202/203, SAFE301 and SAFE305 (semantics don't translate) plus SAFE401 and SAFE803 (documented gaps - C cleanup and nil analysis need flow analysis). See C for the full language reference. - C++ (
.cpp,.cxx,.cc,.hpp,.hxx,.hh), new in v2.8.0. Builds on C: the five C-family rules widen to C and C++, plus C++ gains itstry/catch/throwrules (SAFE201 catch-all, SAFE202, SAFE203) and two new C++-only rules - SAFE315 (raw_new_delete) and SAFE316 (dangerous_casts). 26 rules apply. Plain.hheaders are linted as C; use.hpp/.hxx/.hhfor C++ headers. See C++ for the full language reference.
Planned#
No languages are currently on the near-term roadmap. SafeLint's registry-driven architecture (see Adding a language) makes each new language incremental, community contributions are welcome.
Rule scope (current languages)#
| Scope | Count | Codes |
|---|---|---|
| Cross-language (all nine: Python, JavaScript, TypeScript, Java, Rust, Go, PHP, C, C++) | 13 | SAFE101, SAFE102, SAFE103, SAFE104, SAFE105 (no_recursion), SAFE303, SAFE304, SAFE501, SAFE603 (blanket_suppression), SAFE701, SAFE702, SAFE801, SAFE802 (apply to all nine). |
| Python / JS / TS / Java / Rust / PHP / C / C++ (not Go) | 1 | SAFE601 (missing_assertions); Go has no production assertion idiom. C / C++ have the literal assert macro. |
| Python / JS / TS / Java / Rust / PHP (not Go, not C, not C++) | 1 | SAFE803 (null_dereference); no chained-nullable idiom in Go, and C / C++ nil analysis needs flow analysis (documented gap). |
| Python / JS / TS / Java / Go / PHP / C / C++ (not Rust) | 2 | SAFE302 (global_mutation), SAFE309 (dynamic_code_execution). Rust's analogues are SAFE307 + SAFE602 (mutable statics) and an opaque token-tree limitation for rule 8. C / C++ fire on file-scope (and, for C++, namespace-scope) mutable declarations (SAFE302) and dlopen / dlsym (SAFE309). |
| Python / JS / TS / Java / Go / PHP (not Rust, not C, not C++) | 1 | SAFE401 (resource_lifecycle). Rust and C++ use Drop / RAII; C cleanup (goto err, explicit fclose / free) needs flow analysis the rule does not do (documented gap - allocation discipline is C's SAFE310, C++'s is SAFE310 / SAFE315). |
| Python / JS / TS / Java / PHP / C++ (not Rust, not Go, not C) | 2 | SAFE202 (empty_except), SAFE203 (logging_on_error). C++ gains try / catch; C has no try/catch. Neither Rust nor Go has try/catch; Rust's analogues are SAFE206 / SAFE207, and Go's empty-if err != nil swallow is covered by SAFE209. |
| Python + PHP | 1 | SAFE301 (global_state); both have a literal global keyword, JS / TS / Java / Rust / Go / C / C++ do not. |
| Python + C++ | 1 | SAFE201 (bare_except); Python's bare except: and C++'s catch (...) catch-all. JS / TS / Java catches always bind the error, and Rust / Go / PHP / C have no bare-catch equivalent. |
| JavaScript-family-only (JS and TS) | 1 | SAFE305 (wide_scope_declaration); Python / Java / Rust / Go / PHP have no var / let / const distinction. |
| Java + Spring Boot only | 4 | SAFE901 (spring_field_injection), SAFE902 (spring_missing_transactional), SAFE903 (spring_unvalidated_input), SAFE904 (spring_async_checked_exception); all default-disabled under vanilla, default-enabled by the spring-boot framework preset. |
| Python / PHP framework presets only | 3 | SAFE905 (debug_mode_enabled), SAFE906 (mass_assignment), SAFE907 (unvalidated_request_input); all default-disabled, enabled by the Python / PHP framework presets (Django / FastAPI / Laravel enable all three; Flask enables SAFE905 + SAFE907; pydantic = true enables SAFE906). The non-Java analogue of the Spring SAFE9xx rules. |
| Rust-only | 11 | SAFE110 (needless_mut), SAFE112 (unchecked_arithmetic_on_input), SAFE204 (panic_macros_outside_tests), SAFE205 (lock_poisoning_ignored), SAFE206 (silent_result_discard, the Rust analogue of SAFE202), SAFE207 (unlogged_error_branch, the Rust analogue of SAFE203), SAFE208 (result_unwrap_outside_tests), SAFE306 (dangerous_mem_ops), SAFE307 (interior_mutable_static), SAFE308 (truncating_as_cast), SAFE602 (undocumented_unsafe); all default-disabled. |
| Go-only | 2 | SAFE209 (empty_error_check, the Go analogue of SAFE206), SAFE211 (panic_calls_outside_tests, the Go analogue of SAFE204); both default-disabled. |
| C-family (C and C++) | 5 | SAFE106 (nonlocal_jumps, goto / setjmp; enabled at warning severity), SAFE310 (dynamic_allocation; on C++ also new / delete), SAFE311 (complex_macro), SAFE312 (conditional_compilation), SAFE313 (restricted_pointers; smart pointers exempt on C++); the last four default-disabled. The Power-of-Ten clauses (rules 1, 3, 8, 9) every other language adapts away. |
| C++-only | 2 | SAFE315 (raw_new_delete), SAFE316 (dangerous_casts); both default-disabled. Modern-C++ ownership / type-safety idioms (3xx band). |
The engine's per-language dispatch automatically skips rules whose language tuple doesn't include the active file's language. There's no manual configuration to do, drop a .py file in a JS / TS project (or vice versa) and the right rules fire on each.
At a glance#
The table below is generated from the live rule registry (safelint.rules.ALL_RULES) and the per-rule defaults in safelint.core.config.DEFAULTS, it can't drift from the implementation. Click any code to jump to the detailed section below.
| Code | Name | Default severity | Enabled by default |
|---|---|---|---|
SAFE101 |
function_length |
error | yes |
SAFE102 |
nesting_depth |
error | yes |
SAFE103 |
max_arguments |
error | yes |
SAFE105 |
no_recursion |
warning | yes |
SAFE201 |
bare_except |
error | yes |
SAFE202 |
empty_except |
error | yes |
SAFE301 |
global_state |
warning | yes |
SAFE302 |
global_mutation |
error | yes |
SAFE305 |
wide_scope_declaration |
warning | yes |
SAFE501 |
unbounded_loops |
warning | yes |
SAFE104 |
complexity |
error | yes |
SAFE303 |
side_effects_hidden |
error | yes |
SAFE304 |
side_effects |
warning | yes |
SAFE309 |
dynamic_code_execution |
warning | no |
SAFE203 |
logging_on_error |
warning | yes |
SAFE401 |
resource_lifecycle |
error | yes |
SAFE702 |
test_coupling |
warning | no |
SAFE701 |
test_existence |
warning | no |
SAFE601 |
missing_assertions |
warning | no |
SAFE603 |
blanket_suppression |
warning | no |
SAFE801 |
tainted_sink |
error | no |
SAFE802 |
return_value_ignored |
warning | no |
SAFE803 |
null_dereference |
error | no |
SAFE901 |
spring_field_injection |
warning | no |
SAFE902 |
spring_missing_transactional |
error | no |
SAFE903 |
spring_unvalidated_input |
error | no |
SAFE904 |
spring_async_checked_exception |
warning | no |
SAFE905 |
debug_mode_enabled |
warning | no |
SAFE906 |
mass_assignment |
error | no |
SAFE907 |
unvalidated_request_input |
warning | no |
SAFE110 |
needless_mut |
warning | no |
SAFE112 |
unchecked_arithmetic_on_input |
warning | no |
SAFE204 |
panic_macros_outside_tests |
warning | no |
SAFE205 |
lock_poisoning_ignored |
warning | no |
SAFE206 |
silent_result_discard |
warning | no |
SAFE207 |
unlogged_error_branch |
warning | no |
SAFE208 |
result_unwrap_outside_tests |
warning | no |
SAFE306 |
dangerous_mem_ops |
error | no |
SAFE308 |
truncating_as_cast |
warning | no |
SAFE602 |
undocumented_unsafe |
warning | no |
SAFE307 |
interior_mutable_static |
warning | no |
SAFE209 |
empty_error_check |
warning | no |
SAFE211 |
panic_calls_outside_tests |
warning | no |
SAFE106 |
nonlocal_jumps |
warning | yes |
SAFE310 |
dynamic_allocation |
warning | no |
SAFE311 |
complex_macro |
warning | no |
SAFE312 |
conditional_compilation |
warning | no |
SAFE313 |
restricted_pointers |
warning | no |
SAFE315 |
raw_new_delete |
warning | no |
SAFE316 |
dangerous_casts |
warning | no |
Engine-internal codes#
A few codes are emitted by the engine directly rather than by registered BaseRule subclasses. They don't have their own config section and follow the global ignore list. Inline # nosafe: SAFE0xx works for codes emitted after parsing (such as SAFE004, see below) but not for SAFE000, because parse errors are raised before the engine has a chance to read suppression directives off the tree.
SAFE000: parse#
What it flags: Tree-sitter parse errors (syntax errors, broken indentation, missing tokens). The violation carries the offending token's column as a zero-width caret so editors can mark the precise location.
Always severity error. Cannot be configured per-rule.
Inline # nosafe: SAFE000 does not work. Parse errors are raised by SafetyEngine._lint_parsed_source before it parses inline suppression directives off the Tree-sitter tree (see the early-return at the parse-error check). The only way to silence SAFE000 is the global ignore list, which is read at engine init from your config file:
Use this when you genuinely don't want parse errors surfaced (rare, usually you do want to know when a file failed to parse).
SAFE004: unused_suppression (added in 1.8.0)#
What it flags: A # nosafe directive on a line where no violation actually fired, i.e. the suppression is stale (e.g. left over after a refactor that removed the offending code).
Severity is fixed at warning. Disable globally via ignore = ["SAFE004"] if your workflow involves many transient suppressions you'd rather not police. Per-file ignores do not apply to SAFE004: like SAFE000, it's an engine-internal code gated solely on the global ignore list (configuring it inside per_file_ignores will surface a typo-guard warning and otherwise do nothing). Self-referential # nosafe: SAFE004 is special-cased; a directive that only mentions SAFE004 is always considered "used" to avoid recursion.
Structural rules#
These check the shape of your functions. They are cheap to run and always go first.
SAFE101: function_length#
What it flags: Functions longer than max_lines (interpreted under the configured count_mode). Cross-language.
Long functions are hard to read, test, and reason about. The Holzmann rule says a function should fit on one printed page.
| Option | Default | Description |
|---|---|---|
enabled |
true |
Turn rule on/off |
severity |
"error" |
"error" or "warning" |
max_lines |
60 |
Maximum allowed function size (units depend on count_mode) |
count_mode |
"lines" |
How to measure size: "lines" (raw source lines incl. blanks/comments, Holzmann's original framing), "logical_lines" (lines minus blanks and pure-comment lines, less game-able), or "statements" (count Python statement nodes, robust to formatting, equivalent to ruff's PLR0915). Added in 1.8.0. |
[tool.safelint.rules.function_length]
enabled = true
severity = "error"
max_lines = 60
count_mode = "lines" # default; alternatives: "logical_lines", "statements"
When switching to "statements", lower max_lines accordingly, a function with 60 source lines typically corresponds to ~25–35 statement nodes. Pick a value that matches the spirit of "function fits on a page" for your codebase.
SAFE102: nesting_depth#
What it flags: Functions with control-flow nested more than max_depth levels deep. Cross-language.
Deep nesting (if inside for inside if inside while…) makes code hard to follow and test. Two levels is enough for most real functions.
| Option | Default | Description |
|---|---|---|
enabled |
true |
Turn rule on/off |
severity |
"error" |
"error" or "warning" |
max_depth |
2 |
Maximum allowed nesting depth of if, for, while, with, try |
SAFE103: max_arguments#
What it flags: Functions with more than max_args parameters. Cross-language.
Too many arguments usually means a function is doing too much, or needs a config object. self and cls are excluded from the count. *args and **kwargs each count as one parameter; they bring real callers, just an unbounded number of them, so they cannot be free.
| Option | Default | Description |
|---|---|---|
enabled |
true |
Turn rule on/off |
severity |
"error" |
"error" or "warning" |
max_args |
7 |
Maximum number of parameters (excluding self/cls; *args/**kwargs each count as one) |
SAFE104: complexity#
What it flags: Functions with cyclomatic complexity above max_complexity. Cross-language.
Cyclomatic complexity counts the number of independent paths through a function. It starts at 1 and goes up by 1 for every if, elif, for, while, except, ternary expression, and/or operator, and comprehension condition. A score above 10 means the function has too many possible paths to test reliably.
| Option | Default | Description |
|---|---|---|
enabled |
true |
Turn rule on/off |
severity |
"error" |
"error" or "warning" |
max_complexity |
10 |
Maximum cyclomatic complexity (McCabe score) |
SAFE105: no_recursion#
What it flags: Functions that call themselves directly. Cross-language.
Holzmann's Power of Ten rule 1 ("restrict all code to very simple control flow constructs") bans recursion outright: recursion without a guaranteed bound makes the call stack an unbounded resource, so worst-case depth (and therefore termination and memory behaviour) cannot be proven by inspection. An explicit loop with a worklist makes the bound visible.
The rule fires on direct self-recursion - a function whose body contains a call to its own name, either bare (fact(n - 1)) or self-qualified (self.walk(...) in Python / Rust, this.walk(...) in JS / TS / Java). A call on a different receiver (other.walk(...)) does not fire. Two cases are intentionally out of scope and documented as blind spots: indirect / mutual recursion (a calls b calls a), which needs a call graph, and anonymous-function recursion through a binding (const f = () => f()), since the function has no name to match.
Enabled by default at warning severity (mirrors unbounded_loops), so intentional recursion (tree walks, divide-and-conquer) does not block a local run. Annotate deliberate recursion with # nosafe: SAFE105 (or the language's comment form) and a one-line justification.
| Option | Default | Description |
|---|---|---|
enabled |
true |
Turn rule on/off |
severity |
"warning" |
"error" or "warning" |
SAFE106: nonlocal_jumps#
What it flags: goto statements and setjmp / longjmp family calls. C and C++, new in v2.7.0 (widened to C++ in v2.8.0). This is Holzmann's rule 1 ("restrict all code to very simple control flow constructs") expressed literally - C and C++ are the registered languages with goto / setjmp.
goto and the setjmp / longjmp non-local-jump pair bypass structured control flow, so worst-case control paths cannot be reasoned about by inspection. The rule fires on every goto_statement and every call to a configured non-local-jump function (setjmp / longjmp / sigsetjmp / siglongjmp).
Enabled by default at warning severity. The paper bans goto outright, but the goto err cleanup chain is pervasive, idiomatic C; shipping it as a non-blocking warning surfaces every jump without breaking --fail-on=error builds. Annotate a sanctioned cleanup with // nosafe: SAFE106.
| Option | Default | Description |
|---|---|---|
enabled |
true |
Turn rule on/off |
severity |
"warning" |
"error" or "warning" |
nonlocal_jump_calls_c |
["setjmp", "longjmp", "sigsetjmp", "siglongjmp"] |
Call names treated as non-local jumps alongside goto (C, .c / .h) |
nonlocal_jump_calls_cpp |
["setjmp", "longjmp", "sigsetjmp", "siglongjmp"] |
Same, for C++ (.cpp / .cxx / .cc / .hpp / .hxx / .hh) - resolved independently of the C key |
# pyproject.toml
[tool.safelint.rules.nonlocal_jumps]
nonlocal_jump_calls_c = ["setjmp", "longjmp", "sigsetjmp", "siglongjmp"]
nonlocal_jump_calls_cpp = ["setjmp", "longjmp", "sigsetjmp", "siglongjmp"] # C++ uses its own key
# safelint.toml
[rules.nonlocal_jumps]
nonlocal_jump_calls_c = ["setjmp", "longjmp", "sigsetjmp", "siglongjmp"]
nonlocal_jump_calls_cpp = ["setjmp", "longjmp", "sigsetjmp", "siglongjmp"] # C++ uses its own key
Error handling rules#
These check that exceptions are handled clearly and not swallowed silently.
SAFE201: bare_except#
What it flags: except: clauses with no exception type. Python and C++ - Python's except: and C++'s catch (...) catch-all (its first non-Python home). JavaScript catch clauses always bind the caught error (and don't have the KeyboardInterrupt / SystemExit hijack hazard), so there's no equivalent hazard to flag on JS files; SAFE202 + SAFE203 cover the related JS concerns.
A bare except: catches everything including KeyboardInterrupt and SystemExit, which are signals, not bugs. Always specify the exception type you expect.
| Option | Default | Description |
|---|---|---|
enabled |
true |
Turn rule on/off |
severity |
"error" |
"error" or "warning" |
Bad:
Good:
SAFE202: empty_except#
What it flags: except / catch blocks whose body is effectively a no-op. Cross-language.
except E: passexcept E: continueexcept E: ...(Ellipsis)except E: 0/None/True/False(constant literals)except E: "TODO"/""(string-as-comment idiom)
An empty except block silently swallows the error. The caller has no idea something went wrong. Broadened in 1.8.0, earlier versions only matched a literally empty body which Tree-sitter doesn't actually produce for valid Python, so the rule was effectively dead code.
Multi-statement bodies are not flagged even if every statement looks trivial, two consecutive no-ops suggest some intentional structure and would generate false positives.
| Option | Default | Description |
|---|---|---|
enabled |
true |
Turn rule on/off |
severity |
"error" |
"error" or "warning" |
SAFE203: logging_on_error#
What it flags: except / catch blocks that handle an error without any logging call. Cross-language.
If you catch an exception and do something with it but never log it, the error is invisible. This rule requires at least one call to a logger method (debug, info, warning, error, exception, critical, plus the JavaScript console.* family of log / info / warn / error / debug / trace) inside the handler. Blocks that simply re-raise the exact caught binding (Python raise; JavaScript throw e; where e is the catch parameter) are exempt; throwing a different identifier or new Error(...) still requires logging.
| Option | Default | Description |
|---|---|---|
enabled |
true |
Turn rule on/off |
severity |
"warning" |
"error" or "warning" |
Python, Bad:
Python, Good:
JavaScript, Bad:
JavaScript, Good:
State and purity rules#
These check for use of global variables and unexpected side effects in functions.
SAFE301: global_state#
What it flags: Functions that declare the global keyword. Python and PHP (the two languages with a literal global declaration form). JavaScript has no global read-only declaration form; on JS this rule would always be a strict subset of SAFE302 (global_mutation), so it isn't separately registered. JS users get the same protection from SAFE302 alone.
Using global means a function reads or writes shared state outside its own scope. This makes functions hard to test and creates hidden dependencies between parts of your code. Pass values as arguments instead.
| Option | Default | Description |
|---|---|---|
enabled |
true |
Turn rule on/off |
severity |
"warning" |
"error" or "warning" |
SAFE302: global_mutation#
What it flags: shared module / global mutable state. Cross-language (Python, JavaScript, TypeScript, Java), the intent (Holzmann rule 6: declare data at the smallest possible scope) is the same, but the syntactic shape differs per language.
Python: by default, functions that declare global x and then assign to x. With strict = true, any global declaration is flagged regardless of whether a write follows. This is stricter than SAFE301. The default behaviour is more nuanced than ruff's PLW0603 (which fires on any global); set strict = true if your team's policy is to ban the keyword entirely.
JavaScript: function-body writes, assignment_expression, augmented_assignment_expression, or update_expression (++ / --), whose target is a member_expression or subscript_expression rooted in a configured global namespace. The receiver chain is walked leftward, process.env.NODE_ENV = '...', process.env['NODE_ENV'] = '...', and process.exitCode++ all resolve to process and fire. Bracket-notation writes (globalThis['x'] = 1, window["config"] = {}) work the same way as dot access. The default namespace list (global_namespaces_javascript) is ["globalThis", "window", "global", "self", "process"]; runtime presets adjust this (browser drops process, adds document; Deno adds Deno, drops window and process). Module-level (top-of-file) writes do NOT fire, that's setup, not the bug pattern. Reading a global (return globalThis.env;) does NOT fire, only writes.
Java (added in 2.4.0): non-final static field declarations. This is declaration-site detection, not write-site: a mutable static field IS the smallest-scope violation regardless of where it is written, and a single tree walk over field declarations has near-zero false positives (the same shape PMD's MutableStaticState flags). static final fields are clean, even when the referent is interiorly mutable (static final List<String> CACHE = new ArrayList<>()) - detecting interior mutability would need type resolution safelint does not do, so it is a documented exclusion. Instance fields and local variables never fire. Interface fields are implicitly public static final and so are never flagged. This fulfils the Java SAFE302 work previously deferred in the language docs. Rust is not covered by SAFE302: static mut is unsafe-gated (SAFE602's territory) and safe interior-mutable statics are covered by SAFE307 (interior_mutable_static). Go (added in 2.5.0): declaration-site detection on every package-level var, including sentinel errors (var ErrNotFound = errors.New(...)) - the rule does not special-case the initialiser, so treat sentinels as immutable by suppressing with a per-file ignore or //nosafe if desired. const declarations and block-scoped var / := inside functions are clean.
| Option | Default | Description |
|---|---|---|
enabled |
true |
Turn rule on/off |
severity |
"error" |
"error" or "warning" |
strict |
false |
(Python only.) When true, fire on every global declaration even without a subsequent write, mirrors ruff's PLW0603. Added in 1.8.0. |
global_namespaces_javascript |
see above | (JavaScript only.) Receiver names that count as "global namespace", function-body assignments rooted in any of these fire. Added in 1.13.0. |
[tool.safelint.rules.global_mutation]
enabled = true
severity = "error"
strict = false # Python: ban global keyword outright when true
global_namespaces_javascript = ["globalThis", "window", "process"] # JavaScript: tighten or relax the namespace list
Python, Bad:
COUNTER = 0
def bump():
global COUNTER
COUNTER += 1 # SAFE302 - function-body write to module-level state
Python, Good:
JavaScript, Bad:
// Bad, function-body write to a global namespace
function setupCache() {
globalThis.cache = new Map(); // SAFE302
process.env.READY = "true"; // SAFE302
}
JavaScript, Good:
// Good, encapsulate state, return rather than mutate
function buildCache() {
return new Map();
}
const cache = buildCache(); // module-level setup is fine; not flagged
SAFE303: side_effects_hidden#
What it flags: Functions with "pure-sounding" names that perform I/O. Cross-language.
A function named calculate_total (Python) or calculateTotal (JavaScript) implies it just computes and returns a value. If it secretly calls open() / print() / input() (Python) or console.log / fetch / fs.readFile (JavaScript), it is hiding a side effect. This is a core Holzmann risk, callers cannot reason about the function's behaviour. The prefix-match check is case-insensitive on the lowercased function name, so it works equally on snake_case (Python convention) and camelCase (JavaScript convention).
| Option | Default | Description |
|---|---|---|
enabled |
true |
Turn rule on/off |
severity |
"error" |
"error" or "warning" |
io_functions |
["open", "print", "input", "subprocess"] |
Call names considered I/O |
pure_prefixes |
see below | Function name prefixes that imply purity |
Default pure_prefixes: calculate, compute, get, check, validate, is, has, find, parse, transform, convert, format, build, resolve, detect
[tool.safelint.rules.side_effects_hidden]
enabled = true
severity = "error"
io_functions = ["open", "print", "input", "subprocess"]
pure_prefixes = ["calculate", "compute", "get", "check", "validate", "is", "has"]
SAFE304: side_effects#
What it flags: Any function that calls an I/O primitive and is not named to signal that fact. Cross-language.
Broader than SAFE303, applies to all functions, not just pure-named ones. A function named process_order that calls print() should be renamed to log_order or refactored to use dependency injection.
| Option | Default | Description |
|---|---|---|
enabled |
true |
Turn rule on/off |
severity |
"warning" |
"error" or "warning" |
io_functions |
["open", "print", "input"] |
(Python.) Call names considered I/O |
io_functions_javascript |
see below | (JavaScript.) Call names considered I/O. Runtime presets ([tool.safelint.javascript] runtime) adjust this default. Added in 1.13.0. |
io_name_keywords |
see below | Functions whose names contain these words are exempt (cross-language) |
Default io_name_keywords: print, log, write, read, save, load, send, fetch, export, import. The substring check is case-insensitive, so it matches writeData (camelCase) the same way as write_data (snake_case).
Default io_functions_javascript (Node, the default): ["log", "error", "warn", "info", "debug", "fetch", "readFile", "writeFile", "readFileSync", "writeFileSync"]. The browser / deno / cloudflare-workers presets swap in different verbs, see JavaScript runtime presets.
[tool.safelint.rules.side_effects]
enabled = true
severity = "warning"
io_functions = ["open", "print", "input"] # Python list
io_functions_javascript = ["log", "error", "warn", "fetch", "writeFile"] # JavaScript list (overrides the runtime preset)
io_name_keywords = ["print", "log", "write", "read", "save", "load", "send", "fetch"]
Python, Bad:
def process_order(order):
print(f"processing {order}") # SAFE304 - non-io-named function calls I/O
return order
Python, Good:
JavaScript, Bad:
function processOrder(order) {
console.log(`processing ${order}`); // SAFE304 - non-io-named function calls I/O
return order;
}
JavaScript, Good:
function logOrder(order) { // name contains ``log``, exempt
console.log(`processing ${order}`);
return order;
}
SAFE305: wide_scope_declaration#
What it flags: JavaScript var declarations. JavaScript-only, Python has no var / let / const distinction.
var is function-scoped: a var declared inside one branch of an if is visible throughout the entire enclosing function (and at module top, throughout the module), because the declaration is hoisted to the top of its containing function. let and const are block-scoped: they only exist inside the { ... } they're declared in. The rule's intent matches Holzmann Power-of-Ten Rule 6 ("declare variables at the smallest possible scope") translated to JS's actual scope-control mechanism.
The fix is mechanical: replace var with let (when the binding is reassigned later) or const (when it isn't). The rule fires once per variable_declaration node, a multi-binding form like var x = 1, y = 2; produces a single violation (the line is the unit of fix, not each bound name).
| Option | Default | Description |
|---|---|---|
enabled |
true |
Turn rule on/off |
severity |
"warning" |
"error" or "warning" |
Bad:
function f(items) {
if (items.length > 0) {
var first = items[0]; // SAFE305 - hoists; visible after the if
}
return first; // accidentally accessible, exactly the bug
}
function doubleAndReturnLastIndex(arr) {
for (var i = 0; i < arr.length; i++) { // SAFE305 - i leaks out of the loop
arr[i] = i * 2;
}
return i; // i is still accessible, that's the bug
}
Good:
function f(items) {
if (items.length > 0) {
const first = items[0]; // block-scoped to the if
return first;
}
return undefined;
}
function doubleEach(arr) {
for (let i = 0; i < arr.length; i++) { // i is block-scoped to the loop
arr[i] = i * 2;
}
}
SAFE309: dynamic_code_execution#
What it flags: runtime code generation and reflection. Python, JavaScript, TypeScript, Java. Disabled by default.
Holzmann's rule 8 restricts the preprocessor because textual code generation defeats static analysis: a tool cannot reason about code that does not exist until runtime. The modern equivalent is eval / exec-style execution and reflection. SAFE309 is structural, it flags the construct wherever it appears, with no dataflow. That is the difference from SAFE801 (tainted_sink), which fires only when user input demonstrably reaches one of these sinks. The two are complementary and may both fire on the same line; an untainted eval(config_string) still destroys analysability, which is what rule 8 cares about.
Per-language defaults (call names):
- Python (
dynamic_exec_calls):eval,exec,compile,__import__. Only bare calls andbuiltins.-qualified calls fire, somodel.eval()(a method call) does not.getattr/setattrare deliberately excluded (far too common, and they do not generate code). - JavaScript / TypeScript (
dynamic_exec_calls_javascript):eval,Function(bothnew Function(...)and the bareFunction(...)call),execScript. A bare-identifier callee is required, soobj.eval()does not fire.setTimeout/setIntervalwith a string first argument (the implicit-eval form) are deliberately not in the defaults: flagging everysetTimeoutwould be noise, and detecting only the string-argument form is not worth the complexity for a near-extinct idiom; add them via the config list if your codebase still uses the string form. - Java (
dynamic_exec_calls_java):forName(Class.forName),invoke(Method.invoke),eval(JSR-223ScriptEngine),defineClass,loadClass. Matched by method name regardless of receiver, so a user-definedforNamewould also match (acceptable for an off-by-default rule).
Rust is excluded: its rule-8 analogue is the macro system, whose bodies parse as opaque token trees (a documented limitation shared with SAFE801), and panic-family macros already have SAFE204. Go (added in 2.5.0): Go has no eval; the rule-8 surface is reflection (reflect Call / CallSlice / MethodByName) and plugin loading (plugin Open / Lookup), via dynamic_exec_calls_go. Matching is by bare method name, so Open also matches os.Open - narrow the list if noisy.
| Option | Default | Description |
|---|---|---|
enabled |
false |
Turn rule on/off |
severity |
"warning" |
"error" or "warning" |
dynamic_exec_calls |
see above | (Python) call names that count as dynamic execution |
dynamic_exec_calls_javascript |
see above | (JS / TS) call / constructor names |
dynamic_exec_calls_java |
see above | (Java) reflection method names |
# pyproject.toml
[tool.safelint.rules.dynamic_code_execution]
enabled = true
severity = "warning"
# Per-language call lists (each replaces that language's default):
dynamic_exec_calls = ["eval", "exec", "compile", "__import__"] # Python (bare key)
dynamic_exec_calls_javascript = ["eval", "Function", "execScript"] # JS / TS (TS inherits via fallback)
dynamic_exec_calls_java = ["forName", "invoke", "defineClass", "loadClass"] # Java
# standalone safelint.toml (same keys, no [tool.safelint] prefix)
[rules.dynamic_code_execution]
enabled = true
SAFE310: dynamic_allocation#
What it flags: Calls to the heap-allocation / free family. C and C++, new in v2.7.0 (widened to C++ in v2.8.0). Holzmann's rule 3 ("do not use dynamic memory allocation after initialisation") expressed literally - on C++ it additionally flags new / delete expressions.
Fires on every call to a configured allocator (malloc / calloc / realloc / aligned_alloc / free / strdup). Disabled by default - embedded and safety-critical projects opt in; most application C uses the heap freely. Pre-allocate fixed pools / arenas at init and hand out slots to satisfy the rule.
| Option | Default | Description |
|---|---|---|
enabled |
false |
Turn rule on/off |
allocation_calls_c |
["malloc", "calloc", "realloc", "aligned_alloc", "free", "strdup"] |
Allocator call names (C, .c / .h) |
allocation_calls_cpp |
["malloc", "calloc", "realloc", "aligned_alloc", "free", "strdup"] |
Allocator call names for C++ (.cpp / .cxx / ...) - resolved independently of the C key. C++ additionally flags new / delete expressions structurally (not configurable) |
# pyproject.toml
[tool.safelint.rules.dynamic_allocation]
enabled = true
allocation_calls_c = ["malloc", "calloc", "realloc", "aligned_alloc", "free", "strdup", "xmalloc"]
allocation_calls_cpp = ["malloc", "calloc", "realloc", "aligned_alloc", "free", "strdup", "xmalloc"] # C++ uses its own key; new / delete are always flagged
# safelint.toml
[rules.dynamic_allocation]
enabled = true
allocation_calls_c = ["malloc", "calloc", "realloc", "aligned_alloc", "free", "strdup", "xmalloc"]
allocation_calls_cpp = ["malloc", "calloc", "realloc", "aligned_alloc", "free", "strdup", "xmalloc"] # C++ uses its own key; new / delete are always flagged
SAFE311: complex_macro#
What it flags: Preprocessor macros that are not simple, complete syntactic units. C and C++, new in v2.7.0 (widened to C++ in v2.8.0). Holzmann's rule 8 ("limit the preprocessor to header files and simple macros").
Fires on function-like macros that use token pasting (##) or variadic __VA_ARGS__, and on object-like macros whose replacement text is not a balanced syntactic unit (heuristic: unbalanced () / {} / [], ignoring brackets inside string and character literals). Mutually recursive macro definitions (the paper's third banned construct) are not detected; that needs macro-table analysis. Disabled by default.
SAFE312: conditional_compilation#
What it flags: #if / #ifdef / #ifndef directives beyond the include-guard idiom. C and C++, new in v2.7.0 (widened to C++ in v2.8.0). Holzmann's rule 8 again: each conditional-compilation directive doubles the number of build configurations that must be tested (2^n versions from n flags).
An #ifndef X + #define X pair (a header include guard) is exempt; the matching #define must be the first substantive statement of the block, with comments (e.g. an SPDX / licence header) and #pragma lines (a belt-and-braces #pragma once) allowed in between. Every other #if / #ifdef / #ifndef fires. Disabled by default. Prefer runtime configuration over compile-time flags.
SAFE313: restricted_pointers#
What it flags: Declarators with more than one level of pointer indirection (int **p) and function-pointer declarators (void (*fp)(int)). C and C++, new in v2.7.0 (widened to C++ in v2.8.0; smart pointers exempt on C++). Holzmann's rule 9 ("limit pointer use to a single dereference, and do not use function pointers") expressed literally. The check is syntactic (declarator shape only): a pointer level hidden behind a typedef or a macro is not counted - the paper's no-hidden-dereference clause needs type resolution and is a documented gap.
Disabled by default - it is deliberately strict (char **argv fires too). Opt in for the highest-assurance profiles; collapse multi-level pointers behind a struct or out-parameter, and replace function pointers with tagged dispatch.
C++ idiom rules#
Two C++-only rules capture modern-C++ ownership / type-safety idioms that have no analogue in the other languages. Both are disabled by default. New in v2.8.0.
SAFE315: raw_new_delete#
What it flags: Every new and delete expression. C++-only. The modern-ownership rule: prefer std::make_unique / std::make_shared and RAII so a scoped owner releases memory automatically and cannot be forgotten on an early return or exception. std::make_unique / std::make_shared contain no new expression and never fire; a raw new inside a std::unique_ptr<T>(new T) argument still fires (prefer make_unique).
It overlaps the widened SAFE310 (dynamic_allocation) by design: SAFE310 is the Holzmann no-allocation-after-init posture (embedded / safety-critical), SAFE315 the ownership posture (leak safety). Enabling both double-reports a raw new, the same intentional overlap as SAFE205 / SAFE208.
SAFE316: dangerous_casts#
What it flags: reinterpret_cast and const_cast expressions. C++-only. These defeat the type / const system: reinterpret_cast reinterprets a bit pattern with no checking, const_cast strips const (undefined behaviour if the underlying object is truly const). static_cast and dynamic_cast are compiler-checked and stay clean. The named casts parse as a call_expression whose callee is a template_function, so the rule matches on the template callee name.
The flagged list is configurable via dangerous_casts_cpp - narrow it, or add static_cast if your profile forbids all named casts.
# pyproject.toml
[tool.safelint.rules.dangerous_casts]
enabled = true
dangerous_casts_cpp = ["reinterpret_cast", "const_cast"]
# safelint.toml
[rules.dangerous_casts]
enabled = true
dangerous_casts_cpp = ["reinterpret_cast", "const_cast"]
Resource safety rules#
SAFE401: resource_lifecycle#
What it flags: Resource-acquisition calls that aren't wrapped in a cleanup-guaranteed scope. Cross-language with language-specific scope semantics.
Python: the call must appear inside a with statement (with open(path) as f:). Bare assignments without with fire even when paired with manual f.close(), Python's idiom is context-manager-first.
JavaScript: the call must appear inside a try block whose try_statement has a finally_clause somewhere up the AST ancestor chain. Heuristic-only: the rule doesn't verify that the finally block actually closes the specific resource. Captures the most common "I created a stream and didn't think about cleanup at all" leak. JavaScript's newer using declarations (Stage 3 / Node 22+) aren't yet recognised as a safe form; for now, wrap inside try { ... } finally { ... }.
| Option | Default | Description |
|---|---|---|
enabled |
true |
Turn rule on/off |
severity |
"error" |
"error" or "warning" |
tracked_functions |
(see below) | (Python.) Calls that must be inside a with block. Replaces the default list when set. |
extend_tracked_functions |
[] |
(Python.) Appended to the default list, use this when you want to add custom functions without losing the defaults. Added in 1.8.0. |
cleanup_patterns |
["close", "commit", "rollback", "release", "shutdown"] |
(Python.) Acceptable cleanup method names as an alternative |
tracked_functions_javascript |
(see below) | (JavaScript.) Calls that must be inside a try { ... } finally { ... }. Runtime presets ([tool.safelint.javascript] runtime) adjust this default. Added in 1.13.0. |
Default tracked_functions (Python, expanded in 1.8.0):
tracked_functions = [
"open", "connect", "session", "Session", # files, DBs, HTTP
"Lock", "RLock", "Semaphore", # synchronisation
"Pool", "ThreadPoolExecutor", "ProcessPoolExecutor", # work pools
"socket", "mmap", # network / memory
"TemporaryFile", "NamedTemporaryFile", "TemporaryDirectory",
"ZipFile", "TarFile", # archives
]
Default tracked_functions_javascript (Node, the default runtime):
tracked_functions_javascript = [
"createReadStream", "createWriteStream", "openSync", # fs
"createServer", "createConnection", "connect", # net / DB drivers
"createWorker", # worker pools
]
The browser / deno / cloudflare-workers presets swap in different lists, see JavaScript runtime presets.
# Add custom Python acquirers without losing the defaults
[tool.safelint.rules.resource_lifecycle]
extend_tracked_functions = ["acquire_widget", "rent_db_handle"]
# Replace the JS tracked list entirely (overrides the runtime preset)
[tool.safelint.rules.resource_lifecycle]
tracked_functions_javascript = ["openSync", "createServer", "myCustomAcquirer"]
Python, Bad:
f = open("data.txt") # SAFE401 - not in a with block
data = f.read()
f.close() # won't run if f.read() raises
Python, Good:
JavaScript, Bad:
function readData(path) {
const stream = fs.createReadStream(path); // SAFE401 - not wrapped in try/finally
return processStream(stream);
}
JavaScript, Good:
function readData(path) {
let stream;
try {
stream = fs.createReadStream(path);
return processStream(stream);
} finally {
if (stream) stream.close();
}
}
Loop safety rules#
SAFE501: unbounded_loops#
What it flags: while loops that may run forever. Cross-language.
Two cases are flagged:
- Literal-
truecondition with nobreakinside, applies to bothwhile True:(Python) andwhile (true)(JavaScript). Guaranteed infinite loop unless something inside the body breaks out. - Non-comparison condition, applies to Python only (
while x:wherexisn't a comparison expression). JS idioms likewhile (queue.length)andwhile (token)are commonly bounded, so the heuristic stays Python-only, flagging them on JS files would produce too much noise.
| Option | Default | Description |
|---|---|---|
enabled |
true |
Turn rule on/off |
severity |
"warning" |
"error" or "warning" |
Python, Bad:
Python, Good:
JavaScript, Bad:
JavaScript, Good:
Documentation rules#
SAFE601: missing_assertions#
What it flags: Functions with fewer than min_assertions assertions. Cross-language.
Based on Holzmann rule 5: the paper asks for an assertion density averaging a minimum of two assertions per function. The rule defaults to min_assertions = 1 (any assertion satisfies it) to keep noise low; paper-strict projects set min_assertions = 2. Disabled by default because many functions legitimately have no assertions (e.g. simple data transformations).
Two further clauses of the paper's rule 5 are intentionally out of scope: assertions must be side-effect free (safelint does not analyse assertion expressions for effects), and a trivially-true assertion (assert True) counts toward the threshold even though the paper disallows assertions a static checker can prove never fail. Both need analysis machinery (effect inference, constant propagation) that does not fit a structural rule; review remains the guard there.
Python walks for the AST assert_statement (built-in keyword). JavaScript has no built-in assert keyword, so the rule walks for calls to a configured set of assertion-function names, Node's assert module (assert, ok, equal, strictEqual, deepEqual, match, ...), console.assert, and test-framework idioms (expect for Jest / Chai-via-expect, should for Should.js, vi.expect for Vitest). Configure via assertion_calls_javascript.
| Option | Default | Description |
|---|---|---|
enabled |
false |
Disabled by default, opt-in |
severity |
"warning" |
"error" or "warning" |
min_assertions |
1 |
Minimum assertions per function; set 2 for the paper's density. Added in 2.4.0. |
assertion_calls_javascript |
(see default JS list above) | (JavaScript only.) Call names that satisfy the assertion check. Added in 1.13.0. |
# pyproject.toml
[tool.safelint.rules.missing_assertions]
enabled = true
severity = "warning"
min_assertions = 2 # Holzmann rule 5 density; default is 1
assertion_calls_javascript = ["assert", "expect", "should"]
# standalone safelint.toml (same keys, no [tool.safelint] prefix)
[rules.missing_assertions]
enabled = true
min_assertions = 2
Python, Bad:
def transfer(amount, src, dst): # SAFE601 - no assert statements
src.balance -= amount
dst.balance += amount
Python, Good:
def transfer(amount, src, dst):
assert amount > 0
assert src.balance >= amount
src.balance -= amount
dst.balance += amount
JavaScript, Bad:
function transfer(amount, src, dst) { // SAFE601 - no assertion calls
src.balance -= amount;
dst.balance += amount;
}
JavaScript, Good:
function transfer(amount, src, dst) {
assert(amount > 0);
assert(src.balance >= amount);
src.balance -= amount;
dst.balance += amount;
}
SAFE603: blanket_suppression#
What it flags: un-scoped suppressions of other analysers. Cross-language (all nine registered languages). Disabled by default.
Holzmann's rule 10 ("compile with all warnings enabled and heed every warning") has a modern failure mode: not disabling warnings at the compiler, but silencing an entire analyser from inside the source. SAFE603 flags the blanket forms while leaving scoped suppressions alone, because a scoped suppression is a deliberate, auditable decision about one rule.
Per language, the blanket forms that fire (and the scoped forms that stay clean):
- Python (comments): bare
noqa(no: codelist),type: ignorewith no[code]qualifier,pylint: disable=all. Scoped (noqa: E501,type: ignore[arg-type],pylint: disable=line-too-long) is clean. - JavaScript / TypeScript (comments):
eslint-disable/eslint-disable-line/eslint-disable-next-linewith no rule list,@ts-nocheck,@ts-ignore.@ts-expect-erroris clean (it self-polices: it errors when the suppressed error no longer occurs). A rule-listedeslint-disable no-consoleis clean. - Java (annotations):
@SuppressWarnings("all")and@SuppressWarnings({..., "all"}). Scoped (@SuppressWarnings("unchecked")) is clean. - Rust (attributes):
#[allow(clippy::all)],#[allow(warnings)], and their inner#![...]forms. Scoped (#[allow(dead_code)],#[allow(clippy::too_many_arguments)]) is clean. - Go (comments): golangci-lint's bare
//nolint(all linters) and Staticcheck's bare//lint:ignore(no check list). Both are machine-readable directives recognised only in the tight form with no space after//(//nolint, not// nolint), so a prose// nolint herecomment is left alone. Scoped (//nolint:errcheck,//lint:ignore SA1000 reason) is clean. - PHP: the
@error-suppression operator (@file_get_contents(...)) - PHP's headline blanket-suppression hazard - plus barephpcs:ignore/phpcs:disable,@phpstan-ignore-line/@phpstan-ignore-next-line, and@psalm-suppress all. Scoped forms (phpcs:ignore Squiz.Foo,@phpstan-ignore <id>,@psalm-suppress SomeIssue) are clean. - C / C++ (comments): clang-tidy's bare
// NOLINT/// NOLINTNEXTLINE. Scoped// NOLINT(bugprone-foo)is clean.
SAFE603 never flags safelint's own # nosafe / # safelint: ignore directives, those are policed by SAFE004 (unused suppression). Directive-looking text inside a string literal is not flagged either, the detectors scan comment / annotation / attribute nodes, not string contents.
| Option | Default | Description |
|---|---|---|
enabled |
false |
Disabled by default, opt-in |
severity |
"warning" |
"error" or "warning" |
Test coverage rules#
These are disabled by default. Enable them in CI to enforce test discipline.
SAFE701: test_existence#
What it flags: Source files that have no corresponding test file. Cross-language.
The expected test filename pattern is language-aware:
- Python, looks for
test_<stem>.py(e.g.src/mymodule/foo.pypairs withtest_foo.py). - JavaScript, looks for
<stem>.test.<ext>(Jest convention) or<stem>.spec.<ext>(Mocha / Karma convention) across all registered JS extensions (.js/.mjs/.cjs). For examplesrc/app/foo.jspairs withfoo.test.jsorfoo.spec.js.
The rule searches under the configured test_dirs for any of these patterns. Test files themselves (files under a test_dirs entry, or files whose names already match the pattern) are skipped; the rule doesn't ask a test to have its own test.
| Option | Default | Description |
|---|---|---|
enabled |
false |
Disabled by default, opt-in |
severity |
"warning" |
"error" or "warning" |
test_dirs |
["tests"] |
Directories to search for test files |
[tool.safelint.rules.test_existence]
enabled = true
severity = "warning"
test_dirs = ["tests", "test"]
SAFE702: test_coupling#
What it flags: Source files that were changed without a corresponding change to their test file. Cross-language.
If you modify src/foo.py, you must also modify tests/test_foo.py in the same commit. For JavaScript, modifying src/foo.js requires updating foo.test.js or foo.spec.js. This enforces the discipline that source changes come with test updates. Same filename patterns as SAFE701. Unlike SAFE701, this requires the test file to exist; if it does not, SAFE701 fires instead.
| Option | Default | Description |
|---|---|---|
enabled |
false |
Disabled by default, opt-in |
severity |
"warning" |
"error" or "warning" |
test_dirs |
["tests"] |
Directories to search for test files |
Dataflow rules#
These combine AST analysis with intra-procedural taint tracking. They are more expensive than structural rules and disabled by default. Enable them when you need deeper security or correctness guarantees.
SAFE801: tainted_sink#
What it flags: User-controlled input (function parameters, input() calls in Python, prompt() / confirm() / getItem() in JS, etc.) flowing into dangerous functions like eval, exec, subprocess (Python) or eval / Function / child_process (JavaScript) without being sanitized first. Cross-language.
The rule tracks data flow through assignments: if x = user_data then x is tainted. If y = x + "_suffix" then y is tainted too. Calling eval(y) then triggers a violation. Passing the value through a configured sanitizer (e.g. escape(x)) clears the taint.
| Option | Default | Description |
|---|---|---|
enabled |
false |
Disabled by default, opt-in |
severity |
"error" |
"error" or "warning" |
sinks |
see below | Call names considered dangerous |
sanitizers |
see below | Call names that clear taint |
sources |
see below | Call names that inject taint (in addition to parameters) |
assume_taint_preserving |
true |
How unknown calls (neither sanitizer nor source) propagate taint. Added in 1.8.0. |
Default sinks: eval, exec, compile, system, popen, Popen, run, call, check_output, execute
Default sanitizers: escape, sanitize, clean, validate, quote, encode, bleach
Default sources: input, readline, recv, recvfrom, read
[tool.safelint.rules.tainted_sink]
enabled = true
severity = "error"
sinks = ["eval", "exec", "system", "execute"]
sanitizers = ["escape", "sanitize", "quote"]
sources = ["input", "readline"]
assume_taint_preserving = true # default; set false for taint-dropping mode
assume_taint_preserving modes (1.8.0)#
Most real codebases pass tainted data through internal helper functions before it reaches a sink. The assume_taint_preserving config flag controls how those unknown calls (i.e. calls whose name isn't in sources or sanitizers) are analysed.
The naming says it directly: when assume_taint_preserving = true, the analyser assumes any unknown call preserves the taint of its arguments, the more conservative stance, fewer false negatives, more false positives:
true(default), conservative / taint-preserving. An unknown call's result is tainted iff any of its arguments are tainted.eval(user_input)fires (direct flow).eval(wrap(user_input))also fires (taint flows through the unknownwrap). Cost: false positives whenwrapis in fact safe.false, taint-dropping (less conservative, weaker detection). Unknown calls always drop taint.eval(user_input)still fires (direct flow).eval(wrap(user_input))does not fire, the unknownwrapresets taint, even if it does in fact pass user input through. Use when your codebase has many internal-only wrappers and you'd rather miss a flow than chase down false positives.
Note the asymmetry: false is the less conservative setting (fewer reports, more chance of missing real issues), not "stricter". The trade-off is fundamental to intra-procedural analysis, there's no way to know whether wrap actually preserves the taint without inlining it. Switch modes based on which failure mode hurts more in your codebase.
Python, Bad:
Python, Good:
def run_query(user_input):
safe = sanitize(user_input)
cursor.execute(safe) # sanitizer clears taint - no violation
JavaScript, Bad:
function runQuery(userInput) {
eval(userInput); // SAFE801 - tainted param reaches eval()
}
function buildFn(userInput) {
return new Function(userInput); // SAFE801 - Function constructor is a sink too
}
JavaScript, Good:
function runQuery(userInput) {
const safe = sanitize(userInput);
someApi.run(safe); // sanitizer clears taint - no violation
}
SAFE802: return_value_ignored#
What it flags: Calls to functions whose return value signals success or failure, where the return value is discarded. Cross-language.
Calling subprocess.run(["rm", "-rf", path]) as a bare statement (not assigning the result) means you never check whether the command succeeded. Same with file.write(), it returns the number of bytes written, and silently ignoring it means you may have written nothing.
| Option | Default | Description |
|---|---|---|
enabled |
false |
Disabled by default, opt-in |
severity |
"warning" |
"error" or "warning" |
flagged_calls |
see below | Call names whose return value must not be discarded |
Default flagged_calls: run, call, check_output, write, send, sendall, sendfile, seek, truncate, remove, unlink, rename, replace, makedirs, mkdir, rmdir
[tool.safelint.rules.return_value_ignored]
enabled = true
severity = "warning"
flagged_calls = ["run", "write", "send", "remove", "unlink"]
Python, Bad:
subprocess.run(["deploy.sh"]) # SAFE802 - return value discarded
f.write(data) # SAFE802 - bytes written not checked
Python, Good:
result = subprocess.run(["deploy.sh"])
if result.returncode != 0:
raise RuntimeError("Deploy failed")
JavaScript, Bad:
fs.writeFile("out.txt", data, cb); // SAFE802 - the returned Promise is discarded
stream.write(buf); // SAFE802 - backpressure signal ignored
JavaScript, Good:
SAFE803: null_dereference#
What it flags: Chained attribute access or subscript directly on a call that can return None (Python) / null or undefined (JavaScript), without a guard. Cross-language.
dict.get() returns None when the key is absent. Calling .strip() on the result without checking for None first will raise AttributeError at runtime. Same with ORM methods like session.scalar() or cursor.fetchone().
| Option | Default | Description |
|---|---|---|
enabled |
false |
Disabled by default, opt-in |
severity |
"error" |
"error" or "warning" |
nullable_methods |
see below | Method names whose return value may be None |
Default nullable_methods: get, pop, find, next, first, one_or_none, scalar, scalar_one_or_none, fetchone
[tool.safelint.rules.null_dereference]
enabled = true
severity = "error"
nullable_methods = ["get", "pop", "find", "fetchone", "first"]
Python, Bad:
name = config.get("username").strip() # SAFE803 - .get() can return None
row = cursor.fetchone().value # SAFE803 - fetchone() can return None
Python, Good:
JavaScript, Bad:
const text = document.getElementById("title").textContent; // SAFE803 - getElementById can return null
const first = users.find(u => u.id === id).name; // SAFE803 - .find() can return undefined
JavaScript, Good:
// Optional chaining, the modern guard
const text = document.getElementById("title")?.textContent;
const first = users.find(u => u.id === id)?.name;
// Or explicit check (catches both null and undefined via loose !=)
const el = document.getElementById("title");
if (el != null) {
process(el.textContent);
}
Java + Spring Boot rules#
SAFE9xx rules are Java-only structural checks for common Spring Boot misuses. All four are disabled by default and enabled together by the spring-boot framework preset ([tool.safelint.java] framework = "spring-boot"). They do not fire on Python / JavaScript / TypeScript files even if explicitly enabled; rule dispatch is gated on file language.
SAFE901: spring_field_injection#
What it flags: A class field annotated with @Autowired (or the fully-qualified @org.springframework.beans.factory.annotation.Autowired). Java only.
Spring's reference documentation recommends constructor injection over field injection: constructor-injected dependencies are immutable (final), testable without reflection, fail fast on missing beans at construction time, and surface obvious circular dependencies as compile errors. Field-injected dependencies hide all of those properties.
| Option | Default | Description |
|---|---|---|
enabled |
false (vanilla) / true (spring-boot preset) |
Toggle the rule |
severity |
"warning" |
"error" or "warning" |
Bad:
Good:
@Service
public class OrderService {
private final InventoryClient inventory;
public OrderService(InventoryClient inventory) {
this.inventory = inventory;
}
}
SAFE902: spring_missing_transactional#
What it flags: A @Service or @Component method that performs two or more Spring Data repository writes (save / saveAll / saveAndFlush / delete / deleteAll / deleteAllInBatch / deleteAllById / deleteAllByIdInBatch / deleteById / update) without @Transactional on the method or the enclosing class. Java only.
Multi-write methods without @Transactional run each write in its own short-lived transaction; a failure between writes leaves the database in a partially-updated state. Single-write methods are exempt because the implicit per-statement transaction is sufficient.
Receiver-name heuristic: detection is constrained to method invocations whose receiver name (lowercased) contains repo / dao / jdbctemplate, e.g. userRepo.save(...), productDao.update(...), jdbcTemplate.update(...). Without this guard, call_name() strips the receiver and unrelated calls like file.delete() / cache.delete() / restTemplate.delete(...) would be counted. Rename a service-managed field if your convention is userStore / userManager / etc., or add the matching pattern via [tool.safelint.rules.spring_missing_transactional] configuration in a future release (currently the pattern set is fixed at the source level).
| Option | Default | Description |
|---|---|---|
enabled |
false (vanilla) / true (spring-boot preset) |
Toggle the rule |
severity |
"error" |
"error" or "warning" |
Bad:
@Service
public class OrderService {
public void placeOrder(Order order) {
orderRepo.save(order);
inventoryRepo.update(order.itemId(), -1); // SAFE902: 2 writes, no @Transactional
}
}
Good:
@Service
public class OrderService {
@Transactional
public void placeOrder(Order order) {
orderRepo.save(order);
inventoryRepo.update(order.itemId(), -1);
}
}
SAFE903: spring_unvalidated_input#
What it flags: A @RestController or @Controller method parameter annotated with @RequestBody or @ModelAttribute that is NOT also annotated with @Valid or @Validated. Java only.
Without @Valid / @Validated, Bean Validation constraints declared on the DTO (@NotNull, @Size, @Email, etc.) are silently ignored. Malformed or hostile input reaches the controller body. @PathVariable and @RequestParam are deliberately NOT covered because they typically bind to primitives or simple strings where bean validation is rarely declared.
| Option | Default | Description |
|---|---|---|
enabled |
false (vanilla) / true (spring-boot preset) |
Toggle the rule |
severity |
"error" |
"error" or "warning" |
Bad:
@RestController
public class UserController {
@PostMapping("/users")
public User create(@RequestBody UserDto dto) { ... } // SAFE903: no @Valid
}
Good:
@RestController
public class UserController {
@PostMapping("/users")
public User create(@Valid @RequestBody UserDto dto) { ... }
}
SAFE904: spring_async_checked_exception#
What it flags: A method annotated @Async that declares a throws clause. Java only.
The rule's name and historical framing emphasised checked exceptions, but the implementation flags any throws clause (checked or unchecked) because distinguishing the two requires class-resolution / type-inference we don't do. The conservative behaviour is justified: Spring's executor swallows whatever the method throws regardless of checked-vs-unchecked, so the throws clause is always misleading - it implies the caller can observe the exception when in fact they cannot. Fix by either catching inside the method body or returning a CompletableFuture whose failure state carries the exception (CompletableFuture.failedFuture(ex)).
If you have a deliberate throws RuntimeException on an @Async method (rare; the JLS doesn't require it), suppress with // nosafe: SAFE904 on the method declaration.
| Option | Default | Description |
|---|---|---|
enabled |
false (vanilla) / true (spring-boot preset) |
Toggle the rule |
severity |
"warning" |
"error" or "warning" |
Bad:
@Service
public class IngestService {
@Async
public void process(File f) throws IOException { ... } // SAFE904
}
Good:
@Service
public class IngestService {
@Async
public CompletableFuture<Void> process(File f) {
try {
// ... I/O work
return CompletableFuture.completedFuture(null);
} catch (IOException e) {
return CompletableFuture.failedFuture(e);
}
}
}
Framework preset rules (Python / PHP)#
The following three rules generalise across the Python (Django / Flask / FastAPI) and PHP (Laravel) framework presets. Each serves multiple frameworks and is gated purely by its enabled flag - a framework preset flips it on. Detection is language-family aware (Python vs PHP node shapes), not tied to one specific framework. All ship disabled by default; the [tool.safelint.python] framework / [tool.safelint.php] framework presets enable the applicable ones (see the Python and PHP language pages). They are the non-Java analogue of the Spring SAFE9xx rules.
SAFE905: debug_mode_enabled#
What it flags: A framework debug / reload flag hard-enabled in code. Python + PHP.
Debug mode in production leaks stack traces, settings, and (Flask / Werkzeug) an interactive console; auto-reload is a development-only convenience. Detected patterns:
- Python:
DEBUG = True(Django settings),app.debug = True(Flask; the receiver must look like an app object), and adebug=True/reload=Truekeyword on a framework runner call -app.run(debug=True)(Flask),uvicorn.run(..., reload=True)(FastAPI / ASGI). Adebug=Truekeyword on an unrelated call (e.g.client.connect(debug=True)) is not flagged. - PHP: a config-array entry whose key ends in
debugmapped totrue('app.debug' => true,'debug' => true)..envfiles are not parsed, so this is code-only.
| Option | Default | Description |
|---|---|---|
enabled |
false (vanilla) / true (framework preset) |
Toggle the rule |
severity |
"warning" |
"error" or "warning" |
Bad:
DEBUG = True # Django settings.py - SAFE905
app.run(debug=True) # Flask - SAFE905
uvicorn.run("main:app", reload=True) # FastAPI - SAFE905
Good:
The framework presets enable this rule automatically; enable it directly with either TOML layout:
SAFE906: mass_assignment#
What it flags: Unbounded attribute binding from request data. Python + PHP.
"Bind everything the client sent" defeats the point of an allow-list and lets a caller set fields they should not. Detected patterns:
- Python (Django): a
ModelForm/ serializerMeta.fields = "__all__". - Python (Pydantic): an input model declaring
extra = "allow"- asclass Config: extra = "allow"(v1),model_config = ConfigDict(extra="allow"), ormodel_config = {"extra": "allow"}(v2). Enabled bypydantic = trueas well as the Django / FastAPI presets. - PHP (Laravel): an Eloquent
$guarded = [](guards nothing, so every attribute is mass-assignable).$fillableallow-lists are safe.
Flask does not enable this rule (it has no comparable mass-assignment idiom).
| Option | Default | Description |
|---|---|---|
enabled |
false (vanilla) / true (Django / FastAPI / pydantic / Laravel) |
Toggle the rule |
severity |
"error" |
"error" or "warning" |
Bad:
Good:
The django / fastapi / laravel presets and pydantic = true enable this rule automatically; enable it directly with either TOML layout:
SAFE907: unvalidated_request_input#
What it flags: Request data consumed whole with no validation layer. Python + PHP. The cross-framework generalisation of Spring's SAFE903.
Per function / method: a whole-object request-data read with no validation call in the same scope. Detected patterns:
- Python:
request.POST/request.data/request.json/request.form/request.body/request.query_paramsconsumed whole, with no concrete validation call (is_valid/full_clean/validate/model_validate/parse_obj) in the function (Django / Flask / FastAPI). A bareSerializer/Schemaname reference is not accepted as validation. Single-field access (request.POST.get('x'),request.GET['q']) is targeted and NOT flagged. - PHP (Laravel):
$request->all()/ bare$request->input()with no$request->validate(...)call in the method ($request->input('field')is a targeted read and is not flagged).
The rule is conservative and heuristic: a validation call anywhere in the scope clears the whole function.
| Option | Default | Description |
|---|---|---|
enabled |
false (vanilla) / true (framework preset) |
Toggle the rule |
severity |
"warning" |
"error" or "warning" |
Bad:
Good:
def create(request):
serializer = ItemSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
return serializer.save()
The framework presets enable this rule automatically; enable it directly with either TOML layout:
# pyproject.toml
[tool.safelint.rules.unvalidated_request_input]
enabled = true
severity = "warning"
Rust-only rules#
The following 10 rules apply only to Rust source. They cover patterns the cross-language rules don't translate to cleanly (Rust has no try/catch, no global keyword, RAII handles resource cleanup), or that are uniquely valuable in Rust idiom (unsafe documentation, panic placement, lock poisoning, etc.). All ship disabled by default; opt in via [tool.safelint.rules.<name>] enabled = true. See Rust for the full language reference including idiomatic fix patterns.
SAFE110: needless_mut#
What it flags: let mut x = ... where x is never reassigned, never has &mut x taken, and is never used as a method receiver / field-access target / index target. Rust only.
Rust's default-immutable design encourages declaring mut only when truly needed. Needless mut widens the surface for accidental mutation and obscures which variables are actually meant to change. The rule is conservative - skips when usage is ambiguous (method call, field expression, index expression) to keep false-positive rate low. Mirrors clippy::needless_mut for projects without a clippy run. Holzmann rule 6 (smallest scope).
| Option | Default | Description |
|---|---|---|
enabled |
false |
Toggle the rule |
severity |
"warning" |
"error" or "warning" |
Bad:
Good:
SAFE112: unchecked_arithmetic_on_input#
What it flags: + / - / * (NOT / or %) where at least one operand is an identifier matching an integer-typed function parameter. Rust only.
Rust's bare + / - / * panic on overflow in debug builds and wrap silently in release builds - the worst of both worlds for production reliability. checked_* / wrapping_* / saturating_* make the choice explicit. / and % are excluded - division by zero is a separate panic-on-debug hazard not addressed by the checked_* family the same way. Static-only detection (parameter type annotations); locally-derived integers aren't tracked - cargo clippy covers the type-inference version. Holzmann rule 7 (check return values).
| Option | Default | Description |
|---|---|---|
enabled |
false |
Toggle the rule |
severity |
"warning" |
"error" or "warning" |
Bad:
pub fn total(price: u32, quantity: u32) -> u32 {
price * quantity // silent overflow in release - SAFE112
}
Good:
pub fn total(price: u32, quantity: u32) -> Result<u32, &'static str> {
price.checked_mul(quantity).ok_or("overflow")
}
SAFE204: panic_macros_outside_tests#
What it flags: panic! / todo! / unimplemented! macro invocations in non-test code. Rust only.
Production code should return Result<_, _> instead of crashing. Panics in test code (#[test] / #[cfg(test)] mod) are expected - they're the test framework's failure signal - so test context is exempt. unreachable!() is deliberately excluded from defaults - it's idiomatic for impossible-branch markers in match arms.
| Option | Default | Description |
|---|---|---|
enabled |
false |
Toggle the rule |
severity |
"warning" |
"error" or "warning" |
panic_macros_rust |
["panic", "todo", "unimplemented"] |
Macro names that count as "panicking". Add "unreachable" if you want it flagged. |
Bad:
pub fn parse_config(path: &str) -> Config {
let raw = std::fs::read_to_string(path).unwrap();
if raw.is_empty() {
panic!("config is empty"); // SAFE204
}
serde_yaml::from_str(&raw).unwrap()
}
Good:
pub fn parse_config(path: &str) -> Result<Config, ConfigError> {
let raw = std::fs::read_to_string(path)?;
if raw.is_empty() {
return Err(ConfigError::Empty);
}
serde_yaml::from_str(&raw).map_err(ConfigError::Parse)
}
SAFE205: lock_poisoning_ignored#
What it flags: mutex.lock().unwrap() / rwlock.read().unwrap() / .write().unwrap() / try_lock().unwrap() / try_read().unwrap() / try_write().unwrap() and the .expect("...") variants. Rust only.
When a thread panics while holding a Mutex / RwLock guard, the lock becomes poisoned: subsequent acquisitions return Err(PoisonError). .unwrap() cascades the panic to every other lock holder, often masking the original failure. Safer alternatives: match on PoisonResult, .into_inner() to recover explicitly, or parking_lot::Mutex which has no poison state.
| Option | Default | Description |
|---|---|---|
enabled |
false |
Toggle the rule |
severity |
"warning" |
"error" or "warning" |
Bad:
Good:
SAFE206: silent_result_discard#
What it flags: Empty Err arms in match (Err(_) => {}) and empty if let Err(_) = ... { } bodies. Rust only.
The Rust spiritual analogue of SAFE202 empty_except - "I caught the error and did literally nothing." Both wildcard (Err(_)) and binding (Err(e)) forms count; the silent thing is the no-op body. let _ = result; and result.ok(); do NOT fire - those are explicit auditable discards, not silent swallows. if let Ok(v) = result { ... } without else doesn't fire either (common idiom where the Err case is handled elsewhere).
| Option | Default | Description |
|---|---|---|
enabled |
false |
Toggle the rule |
severity |
"warning" |
"error" or "warning" |
Bad:
Good:
SAFE207: unlogged_error_branch#
What it flags: Err arms / if let Err(...) bodies with non-empty bodies that contain no log call and don't propagate / panic. Rust only.
The Rust spiritual analogue of SAFE203 logging_on_error - handling an error without logging it loses the failure context. Recognised log calls: error! / warn! / info! / debug! / trace! / log! / event! (log + tracing crates), eprintln! / eprint! / println! / print! / dbg!. Exempts bodies that contain a return_expression, a panic-like macro (panic! / todo! / unreachable! / unimplemented!), or a tail-position Err(...) re-raise.
| Option | Default | Description |
|---|---|---|
enabled |
false |
Toggle the rule |
severity |
"warning" |
"error" or "warning" |
Bad:
Good:
SAFE208: result_unwrap_outside_tests#
What it flags: Any .unwrap() / .expect() / .unwrap_unchecked() outside test code (#[test] / #[cfg(test)] mod exempt). Rust only.
The broad Holzmann-rule-7 form: catches bare-variable unwraps (let r = foo(); r.unwrap();) and unwrap chains the narrower SAFE205 (lock-specific) / SAFE803 (nullable-method-specific) rules don't cover. With all three enabled, mutex.lock().unwrap() fires multiple codes - documented intentional overlap; users pick strictness level by enabling subsets. unwrap_or / unwrap_or_default / unwrap_or_else are NOT flagged - they're explicit-default-on-Err, not silent failures.
| Option | Default | Description |
|---|---|---|
enabled |
false |
Toggle the rule |
severity |
"warning" |
"error" or "warning" |
Bad:
pub fn read_config() -> Config {
let raw = std::fs::read_to_string("config.toml").unwrap(); // SAFE208
toml::from_str(&raw).unwrap() // SAFE208
}
Good:
pub fn read_config() -> Result<Config, ConfigError> {
let raw = std::fs::read_to_string("config.toml")?;
toml::from_str(&raw).map_err(ConfigError::Parse)
}
SAFE306: dangerous_mem_ops#
What it flags: Calls to std::mem::transmute / transmute_copy / forget / zeroed / uninitialized. Rust only.
All four are footguns: transmute reinterprets bits as a different type (use From / TryFrom / bytemuck instead); forget skips Drop (use ManuallyDrop); zeroed constructs an all-zero value of any type (use MaybeUninit::zeroed + explicit unsafe read so the hazard is visible at the use site); uninitialized was deprecated in 1.39+ in favour of MaybeUninit::uninit().
Path-qualified detection: the function must be a scoped_identifier whose path contains "mem" (so mem::transmute after use std::mem and std::mem::transmute both fire, but a user-defined my_helpers::transmute does NOT). Bare transmute(x) (no mem:: prefix) is NOT flagged.
| Option | Default | Description |
|---|---|---|
enabled |
false |
Toggle the rule |
severity |
"error" |
"error" or "warning" |
dangerous_mem_ops_rust |
["transmute", "transmute_copy", "forget", "zeroed", "uninitialized"] |
Names to flag (trailing bareword of the scoped path) |
Bad:
Good:
SAFE307: interior_mutable_static#
What it flags: static items whose type provides safe interior mutability, and lazy_static! declarations. Rust only. Disabled by default.
Holzmann rule 6 (declare data at the smallest possible scope) bans global mutable state. Rust's static mut route requires unsafe and is therefore already audit-gated by SAFE602 (undocumented_unsafe), but the idiomatic route, a plain static holding a Mutex / RwLock / OnceLock / Atomic* / a lazy_static! block, is entirely safe code and invisible to SAFE602. SAFE307 closes that gap.
Two shapes fire: a static whose declared type contains an interior-mutability wrapper name as a standalone token (qualified paths like std::sync::Mutex<T> match too); and a lazy_static! { ... } macro invocation, flagged wholesale because the macro's whole purpose is declaring lazily-initialised statics and its body is an opaque token tree (the same limitation SAFE801 has with sqlx::query!). const items (immutable by construction) and static mut (SAFE602's territory) are not flagged. Word-boundary matching keeps Lazy from matching a user type like LazyLoader.
| Option | Default | Description |
|---|---|---|
enabled |
false |
Toggle the rule |
severity |
"warning" |
"error" or "warning" |
interior_mutable_types_rust |
Mutex, RwLock, RefCell, Cell, OnceLock, OnceCell, Lazy, LazyLock, LazyCell, AtomicBool, AtomicI8..AtomicI64, AtomicIsize, AtomicU8..AtomicU64, AtomicUsize, AtomicPtr |
Wrapper type names that mark a static as interior-mutable |
# pyproject.toml
[tool.safelint.rules.interior_mutable_static]
enabled = true
severity = "warning"
# Narrow or extend the wrapper-type set (replaces the default list):
interior_mutable_types_rust = ["Mutex", "RwLock", "OnceLock", "AtomicUsize"]
# standalone safelint.toml (same keys, no [tool.safelint] prefix)
[rules.interior_mutable_static]
enabled = true
Bad:
static CACHE: Mutex<Vec<u8>> = Mutex::new(Vec::new()); // SAFE307
static COUNT: AtomicUsize = AtomicUsize::new(0); // SAFE307
Good:
const MAX_RETRIES: u32 = 5; // immutable constant, not flagged
// or pass the Mutex<Vec<u8>> explicitly to the consumers that need it
SAFE308: truncating_as_cast#
What it flags: as u8 / as i8 / as u16 / as i16 / as u32 / as i32 / as u64 / as i64 / as f32 casts. Rust only.
Rust's as operator silently truncates when the source value doesn't fit in the destination: 1_000_000u32 as u8 returns 64 (low byte), no panic, no error. TryFrom / try_into() returns Result<T, TryFromIntError>, making the failure mode explicit and checked. isize / usize / i128 / u128 / f64 are NOT flagged as targets (widest types; casts TO them from smaller types don't truncate). Holzmann rule 1 + 7.
| Option | Default | Description |
|---|---|---|
enabled |
false |
Toggle the rule |
severity |
"warning" |
"error" or "warning" |
truncating_cast_targets_rust |
["i8", "u8", "i16", "u16", "i32", "u32", "i64", "u64", "f32"] |
Target type names to flag |
Bad:
Good:
SAFE602: undocumented_unsafe#
What it flags: unsafe { ... } blocks lacking a // SAFETY: comment (case-insensitive) on a preceding line. Rust only.
The // SAFETY: comment convention (also enforced by clippy::undocumented_unsafe_blocks) documents why a particular use of unsafe is sound - which invariants the surrounding code upholds, why the safety contract of each unsafe operation is met. Without it, future readers (including the original author six months later) can't audit whether the unsafe is still justified.
Both // SAFETY: (line comment) and /* SAFETY: */ (block comment) forms count. Multiple intervening line comments are allowed (the SAFETY line doesn't need to be the immediately-previous sibling, but no non-comment statement may sit between them). unsafe fn declarations are NOT covered - they require /// # Safety doc comments, a separate convention.
| Option | Default | Description |
|---|---|---|
enabled |
false |
Toggle the rule |
severity |
"warning" |
"error" or "warning" |
Bad:
Good:
// SAFETY: dst was allocated and aligned by the caller (see `Buffer::reserve`);
// value is a Copy type so this can't leak Drop.
unsafe {
std::ptr::write(dst, value);
}
Go-only rules#
The following 2 rules apply only to Go source. They cover Go-idiom patterns the cross-language rules don't translate to cleanly (Go has no try/catch, so the swallowed-error and panic-placement hazards take Go-specific shapes). Both ship disabled by default; opt in via [tool.safelint.rules.<name>] enabled = true. See Go for the full language reference including idiomatic fix patterns.
SAFE209: empty_error_check#
What it flags: an if err != nil { } (or == nil) whose body is empty or comment-only. Go only.
Go's error handling is explicit: a function returns an error and the caller checks it. Writing the check and then leaving the body empty silently swallows the failure, which is worse than not checking at all (it looks handled). This is Go's analogue of Rust's SAFE206 (silent_result_discard) and the spirit of Python's SAFE202 (empty_except). A comment-only body fires too: the error was acknowledged and still ignored.
The error identifier is configurable; the default matches Go's conventional err. The comparison operand order is not assumed (err != nil and nil != err both match).
| Option | Default | Description |
|---|---|---|
enabled |
false |
Toggle the rule |
severity |
"warning" |
"error" or "warning" |
error_names_go |
["err"] |
Identifier names treated as the error variable |
# pyproject.toml
[tool.safelint.rules.empty_error_check]
enabled = true
error_names_go = ["err", "e", "rerr"]
Bad:
Good:
SAFE211: panic_calls_outside_tests#
What it flags: panic(...) calls in non-_test.go files. Go only.
Production Go code should return an error up the call stack, not unwind it with panic. A panic in a library forces every caller to recover or crash, defeating Go's explicit-error contract (Holzmann rule 1, simple control flow). Test files (_test.go) are exempt - a panic there is an acceptable test-failure signal, mirroring how Rust's SAFE204 exempts #[test] / #[cfg(test)] contexts.
The flagged call set is configurable; add the resolved barewords Fatal / Fatalf / Exit if your project treats log.Fatal* / os.Exit as panic-equivalent.
| Option | Default | Description |
|---|---|---|
enabled |
false |
Toggle the rule |
severity |
"warning" |
"error" or "warning" |
panic_calls_go |
["panic"] |
Call names that count as panicking |
# pyproject.toml
[tool.safelint.rules.panic_calls_outside_tests]
enabled = true
panic_calls_go = ["panic", "Fatal", "Exit"]
# safelint.toml
[rules.panic_calls_outside_tests]
enabled = true
panic_calls_go = ["panic", "Fatal", "Exit"]
Bad:
func mustParse(s string) Config {
c, err := parse(s)
if err != nil {
panic(err) // SAFE211
}
return c
}
Good: