Changelog#
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased#
Fixed (Java)#
SAFE401 resource_lifecyclefor Java's manualtry { ... } finally { ... }form is now strict: the finally clause must contain a direct<acquired-var>.close()invocation for the rule to consider the resource guarded. The previous heuristic (mirroring JS) accepted any try with a finally clause, sotry { in = new FileInputStream(p); } finally { audit(); }silently passed even though nothing closedin. Bare-expression acquirers (try { new FileInputStream(p); } finally { ... }) now always fire, since there's no variable handle that could ever be closed. Helper-pattern trade-off:IOUtils.closeQuietly(in),Try.run(() -> in.close()), and similar close-via-helper patterns are NOT recognised under the new strict matcher and will fire SAFE401; switch to try-with-resources or suppress with// nosafe: SAFE401on the acquirer line. The strict path applies to Java only; JavaScript retains its documented heuristic.
2.1.0rc1 - 2026-05-16#
Java is now a supported language, with a dedicated Spring Boot framework preset. First MINOR release after v2.0.0; ships as a release candidate so the new language can be exercised against real Spring Boot codebases before promoting to GA. Source-language analysis works on every Java file; per-project tuning happens via the new [tool.safelint.java] framework = "spring-boot" selector that adds Spring-aware taint sinks, nullable methods, and four new structural rules (SAFE901-904) covering common Spring Boot misuses. Python / JavaScript / TypeScript users see zero behaviour change.
Install as a RC pre-release:
pip install --pre 'safelint[java]==2.1.0rc1'
# or, for a polyglot repo:
pip install --pre 'safelint[python,java]==2.1.0rc1'
# or the kitchen-sink:
pip install --pre 'safelint[all]==2.1.0rc1'
Pre-commit users update rev: v2.0.0 to rev: v2.1.0rc1 and add 'safelint[java]==2.1.0rc1' to additional_dependencies (the RC pin is required until v2.1.0 GA so pre-commit's pip resolver does not fall back to v2.0.0 which lacks the [java] extra).
Added#
- Java as a fully-registered language,
.javafiles are now parsed, walked, and dispatched through the engine the same way as Python / JavaScript / TypeScript. Newsrc/safelint/languages/java.pyexports the per-grammar node-type constants (METHOD_DECLARATION/CONSTRUCTOR_DECLARATION/LAMBDA_EXPRESSION/CATCH_CLAUSE/TRY_WITH_RESOURCES_STATEMENT/METHOD_INVOCATION/OBJECT_CREATION_EXPRESSION/ etc.) plus theJAVALanguageDefinition. Registered insrc/safelint/languages/__init__.pywith the standard available / unavailable gating - missing the[java]extra surfaces the install hint on first run, same posture as JS / TS. - 16 cross-language rules ported to Java:
SAFE101 function_length,SAFE102 nesting_depth,SAFE103 max_arguments,SAFE104 complexity,SAFE202 empty_except,SAFE203 logging_on_error,SAFE303 side_effects_hidden,SAFE304 side_effects,SAFE401 resource_lifecycle,SAFE501 unbounded_loops,SAFE601 missing_assertions,SAFE701 test_existence,SAFE702 test_coupling,SAFE801 tainted_sink,SAFE802 return_value_ignored,SAFE803 null_dereference. Each rule'slanguagetuple was widened to include"java"and the per-language node-type tables grew a Java entry. Java-specific defaults shipped for the configurable rules:io_functions_java(println/print/FileInputStream/Files.readAllBytes/ ...),tracked_functions_java(file streams vianew+Filesfactory methods + JDBCgetConnection),assertion_calls_java(JUnit 5Assertions.*+ AssertJassertThat),sinks_java/sources_java(Servlet API for sources;Runtime.exec/executeQuery/ reflection / SSRF entry points for sinks) andsanitizers_java(limited to generic validators / wrappers:sanitize/validate/quote/escape. Context-specific encoders are deliberately NOT in the defaults: URL encodersencode/encodeURIComponentare URL-only, Apache Commons HTML/XML escapers and SpringhtmlEscapeare HTML-only, and the OWASP Java Encoder API is per-context; including any of those as universal sanitizers would suppress SAFE801 on cross-context flows likejdbc.query("... " + encode(input)). Users who route output through one of those encoders can opt them in via TOML),flagged_calls_java(Filemutators + immutable-type methods),nullable_methods_java(Map.get/getParameter/getHeader/ reflection getters). - Java framework presets via
[tool.safelint.java] framework = "<name>", mirroring the JS runtime-presets architecture from v1.13.0. Two presets ship today: vanilla(default) - plain Java stdlib defaults; the fourSAFE9xxSpring rules stay disabled. Existing v2.0.0 users picking up v2.1.0rc1 see no behaviour change unless they explicitly opt in.spring-boot- extendssinks_javawith unambiguous JdbcTemplate / RestTemplate methods:query/queryForObject/queryForList/queryForMap/queryForRowSet/batchUpdate, andgetForObject/getForEntity/postForObject/postForEntity/postForLocation/patchForObject. Bareput/delete/update/exchangeare deliberately excluded because they collide with HashMap / File / project-local helpers under SAFE801's single-set design (no receiver-aware matching); users who specifically wantrestTemplate.put/.delete/.exchangeSSRF detection orjdbcTemplate.updateSQLi detection can opt in via TOML. Extendsnullable_methods_javawithqueryForObject(RowMapper implementations and nullable column values can yield null; the zero-rows case raisesEmptyResultDataAccessExceptioninstead). Enables the fourSAFE9xxSpring-specific structural rules below. Source-language analysis is identical across presets - same parser, same AST walks - only the rule defaults shift.- Four new Spring Boot framework-aware rules (
SAFE9xxband, Java-only, default-disabled under vanilla, enabled by thespring-bootpreset): SAFE901 spring_field_injection(warning):@Autowiredon a field declaration. Spring's reference docs recommend constructor injection (immutable, testable, fail-fast on missing deps). Both bare@Autowiredand fully-qualified@org.springframework.beans.factory.annotation.Autowiredare recognised.SAFE902 spring_missing_transactional(error): service-layer method (@Service/@Componentclass) doing 2+ Spring Data writes (save/saveAll/saveAndFlush/delete/deleteAll/deleteAllInBatch/deleteAllById/deleteAllByIdInBatch/deleteById/update) without@Transactionalon the method or the class. Receiver-name guard constrains matching to receivers whose lowercased name containsrepo/dao/jdbctemplateso unrelated calls likefile.delete()/restTemplate.delete()don't count. Single-write methods are exempt.SAFE903 spring_unvalidated_input(error):@RestController/@Controllermethod parameter binds@RequestBodyor@ModelAttributewithout@Valid/@Validated.@PathVariableand@RequestParamare deliberately NOT covered (typically bind to primitives). ComplementsSAFE801structurally -SAFE801catches via dataflow,SAFE903catches at the input boundary.SAFE904 spring_async_checked_exception(warning):@Asyncmethod declares athrowsclause. Spring runs@Asyncon a separate thread and silently swallows exceptions; the caller never sees them, regardless of throws-clause declarations. Fix: catch inside the body and either swallow with logging or returnCompletableFuture.failedFuture(...).[java]PEP 621 extra inpyproject.toml, pullstree-sitter-java>=0.23.0. Folded into[all]alongside[python]/[javascript]/[typescript].pip install --pre 'safelint[java]==2.1.0rc1'is the typical Java-only invocation during the RC (drop--preand the pin once v2.1.0 GA ships); polyglot Python + Java monorepos compose extras via'safelint[python,java]==2.1.0rc1'.- Bundled skill addendum at
src/safelint/skill_files/languages/java.md(~250 lines) shipped in the wheel so every AI client (Claude Code, Cursor, GitHub Copilot, Gemini, Windsurf, codex, Continue.dev, Cline, aider, Trae, Antigravity, Zed) consults Java-specific guidance on demand viasafelint skill path. Covers install nuance, framework presets, per-rule notes for all 20 applicable rules, deliberately-skipped rules with rationale, idiomatic Java + Spring fix patterns, and integration-with-existing-tooling guidance. - mkdocs site page at
docs/languages/java.mdmirroring the skill addendum for the user-facing site. Wired into the Languages nav between TypeScript and Configuration; the landing page (docs/index.md) gains a Java row in the supported-languages table. - Pre-commit
types_orextended to[python, javascript, ts, tsx, java]in.pre-commit-hooks.yamlso the published hook automatically routes.javafiles to the engine without users needing to override the filter. The dedicated Pre-commit docs page (docs/pre-commit.md) and the root README gain the['safelint[java]']additional_dependenciesvariant.
Changed#
- Internal helper refactors to keep the engine surface clean as the per-language tables grew:
safelint.languages._node_utils.call_nameswitched to a per-call-node-type dispatch table (_CALL_NAME_DISPATCH) so adding Java'smethod_invocationandobject_creation_expressiondidn't push the function-return-count past SAFE104's limit. The public API is unchanged.CALL_TYPESin_node_utilsnow includes Java's two call shapes so cross-language rules walkingnode.type in CALL_TYPESautomatically catch Java method invocations andnew Foo()constructor calls without per-rule edits.- Per-rule drift-detection allow-list in
tests/core/test_engine.pyrestructured. A new_RULES_JAVA_ONLYbucket joins_RULES_WIDENED_FOR_JS_FAMILY/_RULES_WIDENED_FOR_JS_FAMILY_AND_JAVA/_RULES_JS_FAMILY_ONLY. The fourSAFE9xxrules live in the Java-only bucket; the previously-cross-language rules ported in this release graduated into the_AND_JAVAbucket as each port landed. Contributors adding a new per-language rule are pointed at the four buckets in the assertion error message. - Coverage threshold held at 97 in
pyproject.toml(unchanged from v2.0.0). The Java port temporarily dipped coverage to 95.84% during scaffolding; the defensive AST-guard branches that valid tree-sitter-java grammar doesn't reach were either pragma-annotated (# pragma: no coverwith a one-line rationale) or covered by targeted tests intests/analysis/test_dataflow_java.py(cast-unwrap / scoped-type / lambda-capture paths). End-to-end coverage of the cross-language Java rule branches is exercised through the integration suite (tests/integration/test_spring_boot_e2e.py) plus per-rule Java unit tests (tests/rules/test_resource_lifecycle_java.py,tests/rules/test_max_arguments_java.py,tests/rules/test_spring_rules.py). Final coverage: 97.01%.
Known limitations#
SAFE302 global_mutationis deliberately NOT registered for Java. Python'sglobalkeyword and JS'sglobalThis.x = ...patterns have no clean Java analogue. The natural Java equivalent (writes to non-final static fields from outside the declaring class's own static initialiser) needs class-scope analysis the rule doesn't yet do. Deferred to a future release pending user feedback.SAFE304 side_effectsdoes NOT exempt@Beanfactory methods under thespring-bootpreset. Spring@Beanfactory methods that legitimately create side-effectful resources (DB connections, HTTP clients) trigger the warning today. Suppress with// nosafe: SAFE304on each factory method until a futureskip_functions_annotated_withconfig knob lands. The preset's docstring spells this out explicitly so the limitation is auditable.- Java 21+ string templates are treated conservatively as untainted by the dataflow tracker. tree-sitter-java does not yet expose the template-substitution shape uniformly, so a
STR."Hello \{name}"form won't propagate taint through the template. A future grammar upgrade can lift this without re-architecting the rule.
Upgrading from v2.0.0#
For Python / JavaScript / TypeScript users, no action needed - v2.1.0rc1 is fully backward-compatible. The new [java] extra is opt-in, the Java rules are Java-only (no false positives on existing files), and the framework preset is gated on an explicit [tool.safelint.java] framework = "..." selector that defaults to vanilla.
For Java adopters, the minimal config is:
# safelint.toml (standalone) - no [tool.safelint] wrapper
[java]
framework = "spring-boot" # if you're using Spring Boot; omit for vanilla Java
[rules.tainted_sink]
enabled = true # dataflow rules are opt-in; flip on for security checks
[rules.test_existence]
test_dirs = ["src/test/java"] # Maven / Gradle convention
See docs/languages/java.md for the full per-rule reference and idiomatic fix patterns.
2.0.0 - 2026-05-15#
v2.0.0 GA. The release candidate cycle (rc1 / rc2 / rc3) is closed. RC validation surfaced no blocking issues, so the engine and rule behaviour of v2.0.0 is identical to v2.0.0rc3: every rule check, every Tree-sitter walk, every config-resolution path, every CLI flag, every JSON / SARIF emission produces byte-identical output for byte-identical input. The GA bump also folds in one deliberate skill-install refactor so the bundled-skill layout and the Claude install shape land symmetric with every peer client before 2.0.0 freezes that surface for the rest of the 2.x line, the change is internal to the install machinery (CLI surface unchanged; existing v1.x Claude installs continue to report fresh on safelint skill status because the destination SKILL.md path and its bytes both match). The release-time changes are:
pyproject.tomlversionbumped from2.0.0rc3to2.0.0.- The RC-pin comment block in
[project.optional-dependencies]removed;pip install 'safelint[<lang>]'now resolves to2.0.0straight from PyPI with no pin or--preflag needed. - Pre-release callouts removed from every install surface:
README.md,docs/index.md, the threedocs/languages/*.mdpages, the wheel-bundledsrc/safelint/skill_files/README.md, all 12 AI-client skill files (undersrc/safelint/skill_files/<client>/), and the three skill language addendums. - Install pins updated:
'safelint[<lang>]==2.0.0rc3'to bare'safelint[<lang>]'; pre-commitrev: v2.0.0rc3torev: v2.0.0. uv.lockregenerated.- Bundled skill-files layout symmetry. Claude Code's
SKILL.mdmoved from the top ofsrc/safelint/skill_files/into its own subdirectory atsrc/safelint/skill_files/claude/SKILL.md, joining the per-client siblings (cursor/,copilot/,gemini/,windsurf/,codex/,continue/,cline/,aider/,trae/,antigravity/,zed/). Every client now ships exactly one bundled file underskill_files/<client>/; the sharedlanguages/<lang>.mdaddendums andREADME.mdstay at the bundle root and are looked up on demand viasafelint skill pathfrom every client (Claude included). - Claude install destination simplified to a single file.
safelint skill install --client claudepreviously copied a directory tree (SKILL.md+languages/+README.md) to~/.claude/skills/safelint/; it now copies a single file to~/.claude/skills/safelint/SKILL.md, matching the one-file-in, one-file-out shape every other client already used. The CLI surface is unchanged (same--client claude, same--symlink/--force/--projectflags, sameupdate/remove/statussemantics). Internal Claude-special-case code paths (_install_symlink_directory_filtered,_is_symlink_managed_directory, the tree-hash freshness check,_PEER_CLIENT_DIRS) are gone, the install primitives become single-file across the board. Upgrading from a v1.x Claude install: the destination still hasSKILL.mdat the same path, sosafelint skill statusreports fresh as long as content matches; only the orphanlanguages/andREADME.mdnext to it stay behind on disk. Cleanest cleanup issafelint skill remove --client claude && safelint skill install --client claude, but it's optional, the orphans are harmless.
Cumulative scope since v1.13.0 (the last 1.x release): every supported language ships as an opt-in PEP 621 extra ([python] / [javascript] / [typescript] / [all]); TypeScript / TSX / AssemblyScript are first-class; the silent-failure guard exits with code 2 when a run lints zero files because of a missing grammar; hook mode emits one summary per pre-commit invocation rather than one per batch (see issue #52); skill files cover twelve AI clients; the writing-style baseline drops em-dashes from documentation, mkdocs, skills, code, and CLI output (one rule message text changed as documented in the rc3 entry below).
Upgrading from v1.x: see the Migration from v1.13.0 table in the 2.0.0rc1 entry below; the install-command swap from bare safelint to safelint[<lang>] is the only required change for most users.
Upgrading from a v2.0.0 RC: drop any ==2.0.0rcN pin or --pre flag; pip install -U 'safelint[<lang>]' is sufficient. Pre-commit users should bump their rev: pin to v2.0.0.
2.0.0rc3 - 2026-05-14#
Iteration on the v2.0.0 RC. Bug fixes and a project-wide writing-style cleanup found during testing of 2.0.0rc2. No feature changes; same packaging story, same extras, same rule coverage. Install with pip install 'safelint[<lang>]==2.0.0rc3' (or pass --pre); pre-commit users update rev: v2.0.0rc2 to rev: v2.0.0rc3.
Fixed#
- Issue #50: hook-mode summary line duplicated under pre-commit batching. A clean run with
# nosafesuppressions printedAll checks passed. (N suppressed); pre-commit batches files across multiple hook invocations, so that became one summary line per batch, each showing a misleading partial count. Hook and stdin mode now honoursilent_on_cleanfully: a clean run emits nothing at all, including the--statisticstable. The aggregated, language-agnostic suppression breakdown stays available viasafelint check(interactive) and in every--format json/--format sarifdocument. - Hook mode emitted two messages per invocation in the silent-failure case. When every passed file was skipped for a missing grammar, each batch printed both a per-extension warning and the silent-failure error, both carrying the same install hint.
_dispatch_hook_modenow detects the silent-failure case before emitting per-extension warnings: all-skipped runs get only the error; mixed runs still get the warning as context. - Issue #52: suppression summary scattered per file batch under pre-commit. Pre-commit ran the hook in parallel batches across files, so a
pre-commit run safelint --all-filesover a project with# nosafesuppressions produced one "Found N errors ... (M SAFE### suppressed)" summary block per batch, each carrying a partial count, with some batches missing the suppression mention entirely. Addedrequire_serial: trueto the published hook manifest in.pre-commit-hooks.yamlso pre-commit runs the hook as a single process and the summary aggregates across the whole run. On very large repos that exceed the OS argv limit pre-commit may still split into sequential invocations, but each batch's summary is then at least internally consistent. Downstream change of default: anyone consuming the publishedrepo: https://github.com/shelkesays/safelinthook will now see serialized execution (no parallel batches). Cost: no inter-batch parallelism, which is a non-issue in practice because the engine is fast per-file.
Changed#
- Project-wide writing-style cleanup: em-dashes removed. Em-dashes (
—) are gone from documentation, the mkdocs site, AI-client skill files, code comments, docstrings, and CLI output strings. Prose was restructured (comma, colon, parentheses, or a sentence split); a plain hyphen is used only where a dash is genuinely needed. Heads-up for downstream tooling that string-matches safelint output: two surfaces saw character-level changes and patterns may need updating. (1) CLI runtime strings (error / warning / install-hint lines from_diagnostics, the per-file summary line, the suggestions-available tail), and (2) one rule violation message:SAFE305 wide_scope_declarationnow reads"`var` declaration uses function-scope hoisting - replace with `let` or `const` for block scope"(previously the same text with an em-dash before "replace"). No other rule message strings changed.
2.0.0rc2 - 2026-05-13#
Iteration on the v2.0.0 RC, bug fixes and UX polish discovered during real-world testing of 2.0.0rc1. No feature changes; same packaging story, same extras, same rule coverage. Install with pip install 'safelint[<lang>]==2.0.0rc2' (or pass --pre); pre-commit users update rev: v2.0.0rc1 → rev: v2.0.0rc2.
Fixed#
- Pre-commit silent-failure noise. Hook mode emitted both a per-extension warning AND the silent-failure error per invocation, each carrying the same install hint. Pre-commit batches files across N invocations (one per ~120 KB of argv to stay under OS limits), so an
--all-filesrun against a large repo with a missing grammar produced 2 × N near-identical lines of stderr. The error already carries the actionable install hint, so the preceding warning is pure duplication in the silent-failure case._dispatch_hook_modenow detects the silent-failure case before emitting per-extension warnings, mixed runs (some files lint, others skipped) still get the warning as actionable context; all-skipped runs get only the error. Halves the noise per invocation. Note: the published hook now setsrequire_serial: trueby default (see issue #52 in the 2.0.0rc3 entry above), so multi-invocation runs are uncommon to begin with. _emit_hook_grammar_warningsnow respects--format. Hook mode unconditionally emitted per-extension stderr warnings even undersafelint --format json <files>. Editor plugins / CI driving safelint in hook mode and parsing JSON now get clean stderr. Symmetric with the directory-walk variant's existingsilentkwarg gating.- Missing-grammar pre-scan now honours user
exclude_paths/extend_exclude_paths. Previously the pre-scan ran before config load, so an excludedgenerated/**directory full of.tsfiles would either spuriously warn or (worse) trip the silent-failure guard into exit 2, for files safelint would never have linted anyway. Config is now loaded first and the resolved exclude list is threaded through_emit_missing_grammar_warnings/_scan_for_unavailable_extensions, mirroring the engine's_is_excluded/_is_excluded_dirsemantics. _filter_modified_under_targetno longer keeps deleted paths.git diff --name-only HEADreports deleted files; the helper was leaving them inconsidered_modified, so a setup like "deleted the only.tsfile insrc/python/, TS grammar unavailable" would trip exit 2 telling the user to install the TS grammar for a file they had just deleted. Mirrors the existence check_filter_supported_filesalready had.- Single-file unsupported runs now correctly exit 2.
safelint check foo.tswith the TS grammar missing returned[LintResult(path="foo.ts")], a 1-element list whose lone entry was an empty placeholder produced at language-lookup time. The silent-failure guard only fired whennot results, so the truthy 1-element list bypassed it and the run exited 0 with a "clean" verdict on a file safelint never actually linted. The guard now treats any result whose path-suffix is inunavailable_foundas skipped. _handle_no_targetsno longer over-fires on off-target diffs. The third tuple element of_get_git_modified_supported_fileswas the repo-wide raw git-modified set, sosafelint check src/python/would exit 2 because a.tsfile modified elsewhere in the repo had an unavailable grammar. Renamed toconsidered_modifiedand filtered to under-target via new_filter_modified_under_targethelper.- Dataflow string-list config typos now raise instead of silently matching per-character.
sinks_typescript = "eval"(note: bare string, not a list) was being coerced into{'e', 'v', 'a', 'l'}byfrozenset(...)and the rule silently stopped matchingeval. The three JS-family sites indataflow.py(sinks/sanitizers/sources,flagged_calls,nullable_methods) now route through the establishedresolve_lang_config_lookup+_validated_string_listpattern that names the offending key in theTypeError.
Changed#
- JSON schema docs path correction across skill files. The 11 per-client AI-client skill files plus the wheel-bundled
src/safelint/skill_files/README.mdreferenceddocs/JSON_SCHEMA.md; the file was renamed todocs/json-schema.mdfor the mkdocs site. URLs were 404s on GitHub. Now consistent. SKILL.md/skill_files/README.mdcross-doc links flipped from../../<doc>.md(which resolved tosrc/<doc>.md, wrong by one parent) to absolute GitHub URLs. Works in both source view AND wheel-installed locations (~/.claude/skills/safelint/).- CI gains a
safelint check src/ --all-files --fail-on=errorstep so a push that bypasses pre-commit can't merge with safelint violations. Matches the local pre-commit hook's args. - docs.yml triggers on
src/safelint/rules/**so rule add/rename/remove now redeploys the docs site automatically (the rules-at-a-glance snippet is generated fromALL_RULES). - CI matrix extends to Python 3.14 alongside 3.11 / 3.12 / 3.13, exercising the implicit support claim from
requires-python = ">=3.11". WideScopeDeclarationRuledocstring corrected to describe the rule as JS-family (JavaScript and TypeScript), matching the actuallanguage = ("javascript", "typescript")class attribute.- AssemblyScript pre-commit override recipe updated across
README.md,docs/languages/typescript.md, and.pre-commit-hooks.yaml, the originaltypes_or: []form silently never fired because pre-commit treats an empty tag list as "no tag matches" rather than "filter disabled". The working form istypes_or: [text]plusfiles: \.(ts|tsx|as)$.
Internal#
- Python grammar registration in
safelint.languages.__init__collapsed into a singleif/elseblock mirroring the JS / TS shape, reducing future-drift risk when adding a new language. - 12 new regression tests across
tests/test_cli.py,tests/test_main_routing.py, andtests/rules/test_dataflow_javascript.pycovering each of the bug fixes above. Total now 980 tests at 97.20% coverage.
2.0.0rc1 - 2026-05-12#
v2.0.0 ships TypeScript / AssemblyScript support and restructures packaging so every grammar is an opt-in extra. The TypeScript work that was prepped as 1.14.0rc1 ships here instead, bundled with the packaging change because both shift the install story. Net behaviour: pip install safelint alone installs only the engine, every language grammar (including Python) ships as a separate extra. Users opt in to whichever languages their project actually contains: pip install 'safelint[python]', pip install 'safelint[javascript]', pip install 'safelint[typescript]', pip install 'safelint[python,javascript]' for a monorepo, pip install 'safelint[all]' for the kitchen sink. Files whose grammar isn't installed are skipped at lint time with a one-line stderr install hint, no hard error as long as at least one other file gets linted. When every candidate file gets skipped (the typical TS-only or JS-only project against a base install), the silent-failure guard exits with code 2 so CI / pre-commit can't silently report clean.
This is a packaging-breaking release. Every v1.x user must update their install command, including Python-only users, who now need pip install 'safelint[python]'. The motivation: as v2.0.0 adds TypeScript and future versions add Go / Rust / Java / C / C++ / PHP, baking any one language into the core install means every project pays for languages it doesn't use. The symmetric opt-in model scales: each new language adds an extra and folds into [all]; the base install never grows.
The TypeScript / AssemblyScript work, originally drafted as v1.14.0rc1, never released to PyPI, folds into this entry below. Same rule coverage (18 rules on TS, 17 cross-language + 1 JS-family for var), same per-language config inheritance (_typescript keys with TS → JS fallback), same TS-specific rule handling for generics, as casts, non-null assertions, and declare global. Install with pip install 'safelint[typescript]'==2.0.0rc1 --pre.
Release candidate for v2.0.0, published as an RC so the packaging change can be validated against real-world install / pre-commit flows before promoting to stable. Same promotion path as 1.13.0: if real-world testing surfaces issues, fixes land in 2.0.0rc2; otherwise the RC promotes directly to GA. The --pre flag is required because pip install safelint without it keeps tracking the 1.x line.
Migration from v1.13.0#
While v2.0.0 is in RC, the "New command" column needs a pin or
--pre. Either append==2.0.0rc1(e.g.pip install 'safelint[python]==2.0.0rc1') or pass--preto any of the commands below. An unpinnedpip install 'safelint[python]'resolves to the latest 1.x release on PyPI, which doesn't define these per-language extras and would install only the engine with no grammar. Drop the pin once v2.0.0 GA ships.
| Your setup | Old command (v1.13.0) | New command (v2.0.0) |
|---|---|---|
| Python-only project | pip install safelint |
pip install 'safelint[python]' |
| JS / Node project | pip install safelint |
pip install 'safelint[javascript]' |
| TypeScript project (any flavour) | pip install safelint |
pip install 'safelint[typescript]' |
| Python + JS monorepo | pip install safelint |
pip install 'safelint[python,javascript]' |
| Anything-and-everything | pip install safelint |
pip install 'safelint[all]' |
| Pre-commit hook (any language) | additional_dependencies: [] |
additional_dependencies: ['safelint[<lang>]'] |
Forgetting to add the extra is usually a non-fatal mistake: safelint emits one stderr line per skipped file extension, e.g. safelint: warning: skipping .py files, install with: pip install 'safelint[python]', and continues with whatever files it CAN lint. If at least one file gets linted (mixed-language project, one extra installed), the run finishes normally and the skipped files surface only as warnings. If every candidate file is skipped because no matching grammar is installed, safelint fails the run with exit code 2 (the silent-failure guard, see the Silent-failure guards bullet below). That guard fires in pretty and JSON / SARIF modes, so a CI pipeline can't accidentally report green when no linting actually happened.
Added#
- Optional-grammar packaging via PEP 621 extras, new
pyproject.tomlextras:[python](addstree-sitter-python),[javascript](addstree-sitter-javascript),[typescript](addstree-sitter-javascriptandtree-sitter-typescript; the bundle handles the typical TS project shape where.jsconfigs and.tssource coexist), and[all](everything currently supported). Multiple extras compose,pip install 'safelint[python,javascript]'for monorepos. Adding a new language in the future appends one new extra and folds it into[all]; the base install never grows. safelint.languages.unavailable_extensions(), new public registry helper returning{ext: install_hint}for extensions whose grammar package isn't installed. Empty when every extra is installed. CLI uses it to surface the install hint at lint time.safelint.languages.install_hint_for(extension), convenience wrapper around the same map.- Per-grammar
GRAMMAR_INSTALL_HINTmodule-level strings onsafelint.languages.javascriptandsafelint.languages.typescript, the exactpip install ...command users should run. - CLI install-hint helpers (
_emit_missing_grammar_warnings,_emit_hook_grammar_warnings,_scan_for_unavailable_extensionsincli.py), fire one warning line per unique missing-grammar extension at the start ofsafelint check(directory walk) andsafelint <file>(hook mode). Pretty-mode only; JSON / SARIF stderr stays clean for tooling consumers. Walk excludes the usual vendored / generated dirs (.git,node_modules,.venv,.tox,__pycache__,dist,build,target,.pytest_cache,.mypy_cache,.ruff_cache,.safelint_cache) so node_modules / .venv don't trigger spurious hints. - Pre-commit-aware install hints. When safelint detects it's running under pre-commit (via the
PRE_COMMIT=1env var pre-commit sets at hook execution time), the missing-grammar hint message switches frominstall with: pip install 'safelint[python]'toadd 'safelint[python]' to additional_dependencies in your .pre-commit-config.yaml. Pre-commit users can't run pip directly because the hook environment is managed, the new hint points at the actual lever (additional_dependencies) they have. See_format_install_actionincli.py. safelint skill installnow auto-detects language grammars too. Symmetric with the existing AI-client auto-detection: after a successfulsafelint skill install, safelint walks the project tree for source-file extensions, compares them against the grammar extras currently installed, and emits one composed install line covering every missing grammar, e.g.safelint: warning: Detected source files for 2 languages (python, typescript) whose tree-sitter grammar isn't installed. Run: pip install 'safelint[python,typescript]'. Singlepip installcommand for the multi-language case. Silent (no nudge) when every needed grammar is already present. New module-level constants:EXTRA_NAMEon eachlanguages/<lang>.py; new public helpersafelint.languages.extra_name_for(ext); new CLI helpers_compose_extras_install_command,_emit_skill_install_grammar_hint.- Silent-failure guards for the two ways a missing-grammar run could exit 0 without actually linting anything:
- Directory mode (
safelint check src/), when file discovery finds files with unavailable-grammar extensions AND zero files actually get linted,safelintexits with code 2 (configuration error) and printssafelint: error: no files linted, every supported file was skipped because its grammar package isn't installed, install with: pip install 'safelint[<lang>]'. The install hint is embedded in the error itself so the failure is self-explanatory in JSON / SARIF mode (where per-extension stderr warnings are suppressed) and switches toadd 'safelint[<lang>]' to additional_dependencies in your .pre-commit-config.yamlunder pre-commit. - Hook mode (
safelint <files>invoked by pre-commit), when every file pre-commit passed had an extension whose grammar isn't installed,safelintexits with code 2 instead of 0. The error embeds the same install hint. This makes pre-commit show the hook as Failed (red) instead of Passed (green), without this guard, a pre-commit user withoutadditional_dependencieswould see a clean run while no linting actually happened, the worst kind of silent failure.
Changed (TypeScript work, originally drafted for v1.14.0rc1)#
- TypeScript (
.ts) and TSX (.tsx) as registered languages,tree-sitter-typescript>=0.23.0shipped as the[typescript]extra (not in core). Two grammars (typescriptandtsx) under one logical language name ("typescript"); both grammars share the same rule dispatch. - AssemblyScript (
.as) ride-along,.asfiles are parsed with the standard TypeScript grammar; no separate registration, zero parser-side work. AssemblyScript users get the full TypeScript rule suite via the same[typescript]extra. - TypeScript-specific rule handling for the AST shapes JS rules didn't recognise:
- SAFE103 (max_arguments), generic type parameters (
<T, U, V>) live in a separatetype_parametersAST node, NOT insideformal_parameters, so they correctly don't count toward the limit. New_TS_COUNTED_PARAM_TYPESset recognisesrequired_parameter/optional_parameter/rest_parameterwrappers. - SAFE302 (global_mutation), new
_PASSTHROUGH_WRAPPER_TYPESset extends paren-unwrapping with TS-onlyas_expression/satisfies_expression/non_null_expression.(globalThis as any).counter = 1now correctly resolves toglobalThisand fires.declare global { ... }ambient blocks correctly don't fire (no runtime assignments inside). - SAFE801 (tainted_sink),
_SPREADING_TYPESinJsTaintTrackerextends to TS pass-through wrappers; taint flows througheval(userInput as string)/eval(userInput satisfies T)/eval(userInput!). The same TS wrapper types (required_parameteretc.) are now recognised as parameter shapes, so TS function parameters get seeded into the tainted set. - SAFE803 (null_dereference), unwraps every pass-through TS wrapper (
parenthesized_expression,as_expression,satisfies_expression,non_null_expression) in a loop before checking whether the object is a nullable call.users.find()!.name,(users.find() as User).name,(users.find()).name, and(users.find() satisfies User).nameall fire correctly. - SAFE701 / SAFE702 (test_existence / test_coupling), generate TS test filename patterns (
foo.test.ts/foo.spec.ts/foo.test.tsx/foo.spec.tsx/foo.test.as/foo.spec.as). - Per-language config-key precedence with TS → JS fallback,
get_per_language_confighelper insafelint.core._validators. For TypeScript files, every_javascript-suffixed config key (sinks_javascript,tracked_functions_javascript,global_namespaces_javascript,io_functions_javascript,assertion_calls_javascript,nullable_methods_javascript,flagged_calls_javascript, plussanitizers_javascript/sources_javascript) is automatically inherited unless an explicit_typescript-suffixed key overrides it. The override door is open; most projects won't need to use it because TS and JS share the same runtime threat surface. resolve_lang_config_lookupsibling helper that also returns the actual source key the value came from, used by_validated_string_listcallers so TypeError diagnostics name the key the user actually set (was previouslyfoo_typescripteven when the bad value came fromfoo_javascriptvia TS → JS fallback).- 17 cross-language rules widened from
language = ("python", "javascript")to("python", "javascript", "typescript"). SAFE305 (wide_scope_declaration) widened from("javascript",)to("javascript", "typescript"),varis still hazardous in TypeScript. docs/languages/typescript.md, per-language nav page covering scope (TS / TSX / AS), the 18 rules that fire, TS-specific rule notes, config sharing with JS, the runtime preset story, and the new[typescript]install extra.src/safelint/skill_files/languages/typescript.md, bundled AI-client addendum with TS-specific rule notes; all 12 client skill files now reference it from their language registry table.
Breaking changes#
pip install safelintno longer bundles any tree-sitter grammar package. Every supported language is opt-in: install the matching extra(s) for the languages your project actually contains. Python is now on equal footing with JavaScript / TypeScript, including Python-only users, who must update their install command topip install 'safelint[python]'. The motivation is forward-looking: as Go / Rust / Java / C / C++ / PHP land in future versions, baking any single language into the base install means every project pays for languages it doesn't use; the symmetric opt-in model keeps the install minimal for every use case. Files whose grammar isn't installed are silently skipped with a clear install hint on stderr, see the Migration table above.
Fixed#
- SAFE803 silently bypassed by TS pass-through wrappers other than
!._javascript_deref_hitonly peelednon_null_expression;(call as Foo).bar,(call).bar, and(call satisfies Foo).barslipped past. The new loop unwraps all four pass-through wrappers, the rule now matches the taint tracker's wrapper handling. Regression tests cover bare-paren,as,satisfies, and stacked-wrapper combinations. - TypeError diagnostics named the wrong config key when the TS → JS fallback fired. A user setting
io_functions_javascript = "log"(bare string typo) on a TS file used to see aTypeErrornamingio_functions_typescript, a key they never set.resolve_lang_config_lookupthreads the actual source key through so the error message points at the key the user can fix. - SAFE303 / SAFE304 on TypeScript,
side_effects.py:_io_funcs_for_langbuilt its config key viaf"io_functions_{lang_name}", producingio_functions_typescriptfor TS files. That key wasn't inDEFAULTS(onlyio_functions_javascriptwas), so TS files silently got an empty I/O primitive list and SAFE303 / SAFE304 never fired on TypeScript. The new TS → JS config-key fallback (see Changed above) fixes this. Regression test added (test_ts_io_functions_inherits_javascript_default).
1.13.0 - 2026-05-12#
Previewed as 1.13.0rc1 (2026-05-11) and 1.13.0rc2 (2026-05-11). The RC validation window surfaced one real-world papercut, safelint check --all-files would trip over project virtualenvs at .venv/, which landed in 1.13.0rc2 (built-in vendor-dir defaults for exclude_paths, plus a new extend_exclude_paths config key for additive use). No further issues surfaced after rc2, so the GA ships with the same content. Install with pip install safelint (the --pre flag is no longer needed).
The two new rc2 entries fold in here:
Added (in the rc2 → GA window)#
- Built-in
exclude_pathsdefaults for common vendor / generated directories.safelint check(and--all-files) no longer walks into.venv/,venv/,.tox/,.nox/,__pycache__/,.pytest_cache/,.ruff_cache/,.mypy_cache/,.ty_cache/,build/,dist/,htmlcov/,node_modules/, orsite-packages/by default. Previously these were only excluded if the user listed them explicitly in their config, a--all-filesrun from a project root with a Python virtualenv at.venv/would lint thousands of third-party files. The defaults apply at the directory level (os.walkprunes descent), so the cost of having them excluded is essentially zero. - New
extend_exclude_pathsconfig key. Appends to the active exclude list rather than replacing it. Use this for project-specific excludes ("generated/**","vendor/**", etc.) when you want to keep the built-in vendor-dir defaults. Sister to the existingextend_tracked_functionspattern for SAFE401, same additive semantics, same shape.
Changed (in the rc2 → GA window)#
exclude_pathssemantics, settingexclude_pathsin your config still replaces the built-in defaults wholesale (existing semantics, backwards-compatible). Most projects should migrate toextend_exclude_pathsto keep the vendor-dir defaults active. See Default exclude paths for the migration guide.
The rest of this section describes the JavaScript support that landed in 1.13.0rc1 and is preserved unchanged in the GA.
JavaScript (Node) is now a supported language alongside Python. Registry-driven multi-language support: .js / .mjs / .cjs files are discovered, parsed via Tree-sitter, and run against 17 of the 19 existing rules, plus one new JS-only rule (SAFE305 wide_scope_declaration) for a total of 20 rules safelint now ships. Python users see no behaviour change beyond the v1.12.2 .pyw bugfix; the additive language work is what justifies this release as 1.13.0 (per the project's semver rules: scope expansion is MINOR, never MAJOR).
Added#
safelint.languages.javascript, new module registering JavaScript withLanguageDefinition(name="javascript", file_extensions=frozenset({".js", ".mjs", ".cjs"}), comment_node_type="comment", comment_prefix="//"). Plus the JS Tree-sitter node-type constants every rule needs (function/control-flow/expression/statement/pattern types).tree-sitter-javascript>=0.23.0runtime dependency. Peer of the existingtree-sitter-pythondep.safelint.analysis.dataflow_javascript, new module withJsTaintTracker, the JavaScript counterpart of the PythonTaintTracker. Same public surface; per-language node-type vocabulary internally. Handlesconst/let/vardeclarations,assignment_expression/augmented_assignment_expression, template-string interpolation (`${expr}`), destructuring (array / object / rest / pair patterns), spread elements, member / subscript propagation, and theassume_taint_preservingknob.- 17 rules now lint JavaScript with
language=("python", "javascript"): - Structural:
function_length(SAFE101),nesting_depth(SAFE102),max_arguments(SAFE103),complexity(SAFE104). - Error handling + side-effects:
empty_except(SAFE202, JS empty catch),logging_on_error(SAFE203, recognisesconsole.*and genericlogger.*plusthrow <id>;as re-raise),side_effects_hidden(SAFE303),side_effects(SAFE304). - State purity:
global_mutation(SAFE302, fires on function-body assignments toglobalThis.*/window.*/global.*/self.*/process.env.*and similar configurable namespaces; reading a global is fine; module-level assignments are exempt as legitimate setup). - Loop / tests / assertions:
unbounded_loops(SAFE501, only thewhile (true)no-break case fires on JS; the non-comparison-condition heuristic stays Python-only),missing_assertions(SAFE601, walks for calls toassert/expect/console.assert/ Node'sassert.*helpers),test_existence(SAFE701) andtest_coupling(SAFE702, pair Pythontest_<stem>.pyand JS<stem>.test.{js,mjs,cjs}/<stem>.spec.{js,mjs,cjs}). - Resource safety:
resource_lifecycle(SAFE401, fires on calls to configurable acquirer names,createReadStream,createWriteStream,openSync,createServer,connect, etc., that aren't enclosed in atry { ... } finally { ... }somewhere up the AST chain; heuristic-only, doesn't verify that the finally actually closes the resource). - Dataflow:
tainted_sink(SAFE801),return_value_ignored(SAFE802),null_dereference(SAFE803, recognises optional chainingfoo?.baras the safe form, exempt from the rule). - New JavaScript-only rule:
wide_scope_declaration(SAFE305) flags everyvardeclaration. Holzmann Power-of-Ten Rule 6 ("declare variables at the smallest possible scope") translated to JS's actual scope-control mechanism,varis function-scoped (hoisted across blocks),let/constare block-scoped (the narrower scope). Fires on top-levelvar, function-bodyvar, block-levelvar, multi-bindingvar x = 1, y = 2;(single violation per declaration node, the line is the unit of fix), andfor (var i = 0; ...). Python has novar/let/constdistinction; the rule is registered withlanguage = ("javascript",)and the engine's per-language dispatch correctly skips it on.py/.pywfiles. Default enabled, severitywarning. - JavaScript runtime presets, new
[tool.safelint.javascript] runtime = "<name>"config key selects which API surface the JS rule defaults assume. Five presets ship:node(default, current behaviour),browser(Web APIs / DOM /localStorage/ observers; drops Nodefs),deno(Deno.*APIs; dropsprocess/window),cloudflare-workers(Workers Runtime: KV / R2 / Durable Objects /Requestbody methods; minimal global-namespace list), andbun(Node-compatible plusBun.serve/Bun.spawn). Affects defaults for SAFE302 (global_namespaces_javascript), SAFE303 / SAFE304 (io_functions_javascript), SAFE401 (tracked_functions_javascript), SAFE801 (sinks_javascript/sources_javascript), SAFE802 (flagged_calls_javascript), and SAFE803 (nullable_methods_javascript). User-explicit_javascriptconfig keys still win over the preset. Unknown runtime names warn on stderr and fall back tonode. Source is the same JS regardless of runtime, only the rule defaults shift, not the parser or rule logic. - Per-language config keys (additive, existing user TOMLs unchanged):
[tool.safelint.rules.side_effects_hidden]and[...].side_effectsgetio_functions_javascript.[...].missing_assertions]getsassertion_calls_javascript.[...].tainted_sink]getssinks_javascript,sanitizers_javascript,sources_javascript.[...].return_value_ignored]getsflagged_calls_javascript.[...].null_dereference]getsnullable_methods_javascript.[...].global_mutation]getsglobal_namespaces_javascript.[...].resource_lifecycle]getstracked_functions_javascript.CALL_TYPESfrozenset andresolve_lang_namehelper insafelint.languages._node_utils, cross-language utilities used by the widened rules.- Bundled AI-client skills,
src/safelint/skill_files/languages/javascript.md(the JS shared addendum) ships a full per-rule notes table, idiomatic-fix patterns for each of the 15 ported rules, and the "rules that stay Python-only" reference. All 12 client docs (Claude Code'sSKILL.md, Cursor'scursor/safelint.mdc, GitHub Copilot'scopilot/copilot-instructions.md, Gemini'sgemini/GEMINI.md, Windsurf'swindsurf/safelint-rules.md, codex'scodex/instructions.md, Continue.dev'scontinue/safelint.md, Cline'scline/safelint.md, aider'saider/CONVENTIONS.md, Trae'strae/safelint.md, Antigravity'santigravity/safelint.md, Zed'szed/safelint.md) gained a JavaScript row in their Step 2, Identify the language(s) involved registry tables. - 115 new tests distributed across per-rule JS test files (
tests/rules/test_*_javascript.py) and the engine-level smoke test file (tests/core/test_engine_javascript.py). Total: 764 tests pass at 97.25% coverage.
Changed#
- Pre-commit hook spec (
.pre-commit-hooks.yaml) and the in-tree self-development hook (.pre-commit-config.yaml),types_or: [python]becomestypes_or: [python, javascript]. Downstream users with mixed Python + JS repos automatically have both filetypes routed to safelint after upgrade. Additionally the published.pre-commit-hooks.yamldrops itsfiles: ^src/filter, that filter was a leak from this repo's in-tree.pre-commit-config.yaml(where it's intentional, safelint lints itself only undersrc/) into the published hook spec, where it forced every downstream installer onto the same layout. Heads-up: this broadens the default scope for downstream consumers. With it gone, the hook honours onlytypes_or: [python, javascript]plus the consumer's ownfiles:/exclude:keys, so projects with sources at the repo root, inapp/, inlib/, etc. now get linted by default after upgrade. If you previously relied on the^src/default to scope safelint to one directory, add the equivalent filter to your local.pre-commit-config.yaml:
- repo: https://github.com/shelkesays/safelint
rev: <tag>
hooks:
- id: safelint
files: ^src/ # restore the previous default if needed
call_name in _node_utils.py extended to handle JavaScript member_expression (obj.method(...)) alongside Python attribute (obj.method(...)). Both foo(...) forms (bare identifier function calls) continue to resolve via the existing identifier branch.
Stays Python-only (by design)#
Two rules don't have a useful JavaScript translation and remain registered for Python only, they will not fire on .js / .mjs / .cjs files. The decision rationale lives in src/safelint/skill_files/languages/javascript.md "Rules that stay Python-only".
- SAFE201
bare_except, Pythonexcept:(no exception type) silently catchesKeyboardInterruptandSystemExit. JavaScripttry/catchalways catches every throw type by language design; the Python-specific process-signal hazard doesn't exist. SAFE202 (empty catch) + SAFE203 (catch must log) cover the related JS concerns. - SAFE301
global_state, Python rule fires on theglobalkeyword regardless of whether a write follows. JavaScript has no read-only-global declaration form; on JS the rule would always be a strict subset of SAFE302. JS users get the same protection from SAFE302 (global_mutation) alone.
Behaviour changes (heads-up)#
- JS-only projects, anyone who had
safelint checkrunning on a Python repo with stray.jsfiles: those files will now be discovered, parsed, and linted (most rules will fire). If that's not what you want, scope-suppress with[tool.safelint.per_file_ignores]keyed on the.jsglob, or setenabled = falseper rule. - Mixed Python + JS projects, both file types now flow through pre-commit and
safelint checkautomatically. The 17 widened rules apply to both languages with their per-language defaults. - Pure-Python projects, no intended default-behaviour change beyond the v1.12.2
.pywbugfix. Some Python codepaths did pick up correctness fixes during the v1.13.0 cycle (e.g. SAFE701/702 now skip test files themselves and gate the coupling check to changed paths undertest_dirs, SAFE102 now countsmatchblocks for Python 3.10+), but those only affect users who had explicitly enabled the relevant rules, defaults are unchanged.
Limitations documented for future enhancement#
- Block-style
nosafedirectives (/* nosafe */) are not recognised, only line-style// nosafeand// safelint: ignore. Documented in the JS shared addendum anddocs/contributing/adding-a-language.mdStep 4. - JSX (
.jsx) is not registered.tree-sitter-javascriptparses some JSX leniently as a superset, but flagging it as a separate language registration later avoids accidental drift in rule semantics. - TypeScript (
.ts/.tsx) is a separate language addition, not in this release. - Arrow-function naming via variable binding (
const getX = () => ...), this remains a limitation only for rules that still read function names solely viafunc_node.child_by_field_name("name")and therefore may report such functions as<anonymous>(e.g., SAFE101function_length). SAFE303 (side_effects_hidden) and SAFE304 (side_effects) already resolve JS arrow-function names through the parentvariable_declaratorvia the shared_func_display_namehelper, soconst getX = () => console.log(...)correctly reports asgetXfor those rules. Other rules can be enhanced the same way later.
1.12.2 - 2026-05-09#
Completion of the multi-language readiness work started in v1.12.1. The engine, cache, suppression parser, file discovery, and per-rule dispatch were already registry-driven, but three CLI helpers and the published pre-commit hook spec still hard-coded .py. With this release every supported-extension check reads from safelint.languages.supported_extensions(), so registering a new language is genuinely additive, drop a LanguageDefinition into languages/<lang>.py, append it to the registry loop, append the new filetype tag to types_or in .pre-commit-hooks.yaml, and the CLI discovers it everywhere automatically.
tuple(supported_extensions()) now contains .py and .pyw (the registry is a frozenset, so iteration order isn't guaranteed); types_or: [python] is identical in semantics to the previous types: [python] for downstream pre-commit users.
Fixed#
.pywfiles now picked up by git-modified mode and the pre-commit hook. The old CLI helpers usedstr.endswith(".py")for filtering, which silently dropped.pywfiles ("foo.pyw".endswith(".py")isFalse). The engine's--all-filesdiscovery loop already used the registry and handled.pywcorrectly, so this only affected git-modified runs (safelint check src/) and pre-commit hook mode. Existing.pywprojects that were getting clean runs in those modes may now see previously-hidden violations on those files; if that's unwelcome on a transitional codebase, scope-suppress with[tool.safelint.per_file_ignores]keyed on a*.pywglob.
Changed (internal: registry-driven)#
- CLI git-status filters now read from the registry.
_collect_all_py_filesand_filter_py_filesinsrc/safelint/cli.pyare renamed to_collect_all_supported_filesand_filter_supported_filesrespectively, and both now buildexts = tuple(supported_extensions())once per call to drive thestr.endswithcheck. The hook-mode pre-filter at the bottom ofmain()([f for f in args.files if f.endswith(".py")]) reads from the registry too. Sosafelintinvoked by pre-commit with mixed Python + (future) TypeScript files accepts both rather than silently dropping the non-Python ones. - Published pre-commit hook spec uses
types_or..pre-commit-hooks.yamlpreviously declaredtypes: [python]. Switched totypes_or: [python]so the add-a-language edit becomes a one-line append (- ts) instead of a schema change. Description generalised from "Python files" to "source files"; an inline comment markslanguage: pythonas the hook runtime (a real source of confusion in pre-commit configs), not the language being linted.
Changed (docs)#
docs/contributing/adding-a-language.mdgains an explicit Step 6, Update CLI / pre-commit plumbing that lists the surfaces reading from the registry vs. the one place still requiring a manual edit (thetypes_orline in.pre-commit-hooks.yaml). Old Step 6 (tests + docs) is now Step 7; old Step 7 (bundled AI-client skills) is now Step 8.
Behaviour changes (heads-up)#
.pywprojects, see Fixed above. The bugfix is genuine, but if your.pywfiles have been quietly accumulating violations because git-modified mode and the pre-commit hook were skipping them, you'll see those surface on the firstsafelint checkafter upgrade. Workaround if you need a transitional grace period: scope-suppress with[tool.safelint.per_file_ignores]keyed on**/*.pyw.- Pure-
.pyprojects, no change. The renamed CLI helpers are private (underscore-prefixed); thetypes_orchange is single-element today, so downstream pre-commit users see no difference until a second filetype tag lands.
1.12.1 - 2026-05-09#
A small follow-on to v1.12.0. One user-visible bug fix, one perf optimisation, an internal-API cleanup, and pre-emptive engine plumbing for the eventual second-language work. No behaviour change for current users beyond the bug fix.
Fixed#
per_file_ignores = ["*"]no longer triggers a spurious typo-guard warning. v1.12.0 added the"*"wildcard as a documented blanket-suppress mechanism in tomlper_file_ignores, but the validation pass in_parse_per_file_ignoresstill treated"*"as an unknown entry and emittedsafelint: warning: unknown entries in per_file_ignores...for the exact value the docs tell users to use. The validation now exempts"*"while preserving the typo guard for genuinely unknown codes/names. The pre-existing wildcard test was extended to capture stderr and assert the absence of the warning, so any future regression here surfaces in CI.
Changed (internal)#
- Single-pass directive parsing.
_parse_suppressions(line-level# nosafe) and_parse_file_level_ignores(file-level# safelint: ignore) used to walk the Tree-sitter tree independently, two full passes per file. New_parse_directiveshelper folds both into one O(N) pass; on a 5000-line generated file (a primary use case for file-level ignores), this halves the per-file walk cost. Behaviour is bit-for-bit identical to the two-pass version. The two original helpers are kept as thin wrappers so the existing unit tests intests/core/test_suppression.pycontinue to work without changes. _merge_in_file_directivessignature cleanup. The boolean parameter was forcing surrounding mandatory non-bool params into keyword-only territory unnecessarily. Reordered so only the boolean is keyword-only, matching Python convention (positional mandatory params first, then*, then keyword-only).
Added (pre-emptive: dormant until a second language lands)#
BaseRule.language: tuple[str, ...] = ("python",), new class attribute that the engine consults in_run_rulesbefore dispatchingcheck_file. Rules whoselanguagetuple doesn't include the active file'sLanguageDefinition.nameare skipped. Today every rule defaults to("python",)and Python is the only registered language, so the filter is a no-op for current usage. The plumbing is the engine half of the per-language dispatch contract documented inADDING_A_LANGUAGE.md; the per-rule audit (which existing rules port cross-language vs. stay Python-only) is per-rule work that ships with each new language. Adding TypeScript / Go / Rust now requires only registering a newLanguageDefinitionand wideninglanguageon the rules that port, no engine changes.- 4 new dispatch tests in
tests/core/test_engine.py: filter skips Python-only rules on a hypothetical-language file (via a monkeypatched fakeLanguageDefinition), filter doesn't accidentally skip Python rules on Python files (regression guard),BaseRule.languagedefault is pinned, every registered rule still inherits the default.
Behaviour changes (heads-up)#
- None for end-users. The
"*"typo-guard fix removes an erroneous warning; the perf and signature-cleanup work is invisible; the dispatch infrastructure is dormant. End-to-end behaviour forsafelint checkandsafelint skill *commands is identical to v1.12.0.
1.12.0 - 2026-05-08#
A focused feature release on top of v1.11.0. The suppression model grows from three layers to four with a new in-file # safelint: ignore directive that lets users silence rules for a whole file from inside the file itself, matching the established pattern from ruff (# ruff: noqa), flake8 (# flake8: noqa), pylint (# pylint: disable=), and mypy (# type: ignore).
Added#
-
In-file
# safelint: ignoredirective, file-scope suppression placed as a top-of-file (or anywhere alone-on-its-line) comment. Three forms:Best for the case "this whole file is intentionally violating", auto-generated code, fixtures, vendor adapters, where toml's# safelint: ignore # suppress every rule for this file # safelint: ignore: SAFE101 # suppress one code # safelint: ignore: SAFE101, SAFE304 # suppress multiple # safelint: ignore: function_length # by rule name (equivalent to the code form)per_file_ignoresis overkill (no glob pattern needed) and inline# nosafeis wrong (the violation isn't on a single line). -
Tree-sitter parsed, so
# safelint: ignoreliterals inside docstrings or string content are correctly ignored. - Comment must be alone on its line. Trailing comments after code are skipped, those are scope-local and use
# nosafeinstead. This prevents a per-line directive that's typed with the wrong prefix from silently extending to the whole file. - Typo-guarded. Unknown codes / rule names emit a
safelint: warning:line on stderr (matching the toml typo guard); the run continues. -
Auditable. Suppressed violations still land in
LintResult.suppressedand surface in the CLI's per-code breakdown at the end of a run. -
"*"wildcard support inper_file_ignores, falls out of the same machinery the bare file-level directive uses. You can now write[tool.safelint.per_file_ignores]with"some/path/**" = ["*"]to skip every rule for a path pattern, instead of having to enumerate every code.
Changed#
- Suppression model is now four layers, narrowest to widest. They compose, a violation is suppressed if any layer matches:
# nosafe(line scope), same as before.# safelint: ignore(file scope), new in 1.12.0.per_file_ignores(glob scope), same as before, plus"*"wildcard support.ignore(project scope), same as before.
Documentation#
- CONFIGURATION.md, new File-level suppression section with form table, placement rule, typo-guard behaviour, and a four-mechanism comparison table mapping each scope to its right use case.
- CLAUDE.md, Suppression model section updated from 3 layers to 4 with implementation pointers (
_parse_file_level_ignores,_merge_in_file_directives,_is_per_file_ignored's wildcard short-circuit).
Behaviour changes (heads-up)#
- None for existing files. Files without a
# safelint: ignoredirective behave identically to v1.11.0, the new code path is purely additive. The only observable difference is the new wildcard"*"interpretation inper_file_ignores; if any user was previously writing literal"*"as a rule code (would have been a no-op since no rule matches), they'll now find that entry blanket-suppresses every rule. We're not aware of any such usage in the wild.
1.11.0 - 2026-05-08#
Multi-client expansion, the AI-client skill registry grows from 2 supported clients to 12. The architecture from v1.6.0–v1.10.0 was built for this; each new client is one ClientSpec append plus a bundled artefact (and 10 install/lifecycle regression tests). Top-level safelint help gains dedicated Skill subcommands and Skill flags sections so the install / update / remove / status / path surface is discoverable without a second safelint help skill round-trip.
Added#
- Ten new AI-client integrations: GitHub Copilot, Gemini, Windsurf, codex, Continue.dev, Cline, aider, Trae, Antigravity, Zed. Every client follows the same install / update / remove / status / path surface and the same project-vs-user-scope semantics, with auto-detection wired into the existing
--client autoflow. Per-client install destinations: - GitHub Copilot,
.github/copilot-instructions.md(auto-loaded by VS Code Copilot Chat) - Gemini,
GEMINI.mdat repo root (auto-discovered by Gemini CLI) - Windsurf,
.windsurfrulesat repo root - codex,
.codex/instructions.md(primary) plus a delimited section inAGENTS.mdwhen that file exists (preserves user content; see Secondary install below) - Continue.dev,
.continue/rules/safelint.md - Cline,
.clinerules/safelint.md - aider,
CONVENTIONS.md(project or user); requires a one-lineread: [CONVENTIONS.md]entry in.aider.conf.ymlsince aider doesn't auto-load conventions files. The post-install message reminds users. - Trae,
.trae/rules/safelint.md - Antigravity,
.antigravity/rules/safelint.md - Zed,
.rulesat repo root ClientSpecsecondary-install architecture. New optional fieldssecondary_install_relpathandsecondary_install_section_markerslet a client write a delimited HTML-comment section into a shared cross-agent file (e.g.AGENTS.md) when that file already exists at the scope root. Used by codex; the architecture generalises to any future cross-agent shared file. Lifecycle parity: install writes the section, update re-renders on drift, status escalates to DIFFERS when section content drifts (even if the primary install is fresh), remove strips just the section (preserving other content; deletes the file only if it becomes empty after stripping). Section-based edits are always the contract for the secondary destination, never a full-file overwrite, so user content in shared files is safe.- Top-level
safelint helpgains Skill subcommands and Skill flags sections. All five lifecycle actions (install,update,remove,status,path) and the common flags (--client,--project,--symlink,--force, plus--pathand--dry-runforremove) are now visible at the top level, no secondsafelint help skillround-trip needed to find--forceetc.--forceis intentionally placed under Skill flags (not Global options) since it doesn't apply tocheck. run_updateperformance: hash/walk runs at most once per install per run. Previouslyrun_updateand_update_oneeach invoked_install_statusindependently per target, doubling the directory walk and content hash for Claude installs (the most file-heavy bundle)._update_onenow accepts an optionalstatusparameter;run_updatethreads its precomputed value through. Direct callers and tests are unaffected (defaultNonemeans "compute internally").run_updateno longer falsely prints "all up to date" when every target was OSError-skipped. Newany_processedflag gates_print_update_all_freshon at least one target having a readable status. Without the gate, an all-permission-denied run would silently report success.
Changed#
io_functionsin the bundledsafelint.toml([rules.side_effects_hidden]), removed the unmatchable"subprocess"entry (the rule walks bare callable names,subprocess.run(...)resolves to"run") and replaced it with the actual subprocess callable names (run,Popen,call,check_call,check_output).- Documentation fan-out for the multi-client expansion:
AI_CLIENTS.md(Supported clients table + Per-client guides + manual install examples),src/safelint/skill_files/README.md(clients list + layout tree + manual install examples),README.md(top-level integration block), andADDING_AN_AI_CLIENT.mdall enumerate the 12 supported clients. The Roadmap section inAI_CLIENTS.mdwas retired since the previously listed candidates (Copilot, codex, windsurf, antigravity) all shipped. - Test coverage threshold remains 97%; current coverage 97.24% across 628 tests. The 10 new client integrations add 100+ install/symlink/force/overwrite/auto-detect/CLI-routing/path-print/peer-exclusion tests plus 24 codex-specific tests for the secondary-install lifecycle and section helpers.
Behaviour changes (heads-up)#
safelint helpoutput changed shape, Skill subcommands and Skill flags sections now appear between Commands and Options. Existing users see a longer (more discoverable) help; no commands or flags removed.- Auto-detection now scans for 12 client markers, not 2. A project with markers for several clients gets installs for all of them in registry order. To install for a single client, pass
--client <name>explicitly. - codex's secondary install touches
AGENTS.mdwhen present. If you have an existingAGENTS.mdwith content for other agents,safelint skill install --client codex --projectwill append a delimited safelint section to it (your other content is preserved). The section sits between<!-- safelint:begin -->and<!-- safelint:end -->markers;safelint skill remove --client codexstrips it cleanly. If you don't want any AGENTS.md modification, install codex without--projectso it lands at user-scope only, or remove the AGENTS.md file before installing.
Security hardening#
safelint skill remove --path PATHnow validates that PATH's tail matches a registered install relpath before deleting. Without this guard, a typo or shell-expansion accident (--path ~/.configinstead of~/.cursor/..., or an unset env-var that expanded to a sensitive path) could triggershutil.rmtreeon the wrong directory. The check accepts every registered client's canonical install path (.cursor/rules/safelint.mdc,.codex/instructions.md,.continue/rules/safelint.md,.clinerules/safelint.md,.trae/rules/safelint.md,.antigravity/rules/safelint.md,.windsurfrules,GEMINI.md,.rules,CONVENTIONS.md,.claude/skills/safelint,.github/copilot-instructions.md) regardless of where the parent directories sit. Truly unrecognisable install locations should be removed manually withrmafter inspecting their contents.- codex secondary install (
AGENTS.md) refuses to follow symlinks. Without this guard, anAGENTS.mdset up as a symlink, intentionally by the user, or maliciously by an attacker with write access to the install scope (e.g. shared CI workspace), would have causedinstall/update/removeto read and write through the symlink, potentially corrupting any user-writable file the link pointed at (e.g.~/.ssh/authorized_keys, system files when running as root). All three lifecycle paths (_install_secondary,_remove_secondary,_secondary_status) now checktarget.is_symlink()and refuse with asafelint: warning: refusing to install/remove safelint section through symlink at ...line on stderr. The primary.codex/instructions.mdinstall is unaffected.
1.10.0 - 2026-05-06#
Round-out release for the skill-install lifecycle: update and remove complete the install / status / update / remove quartet so users have a full set of maintenance commands without falling back to manual rm / re-install cycles.
Added#
safelint skill update, refresh installed skills whose content has drifted from the bundled wheel. Idempotent by default (no-op when fresh), with--forceto re-install regardless of drift status (useful for reverting customised installs back to bundled). Inherits--client/--project/--symlink/--forcefrom install.--client autohere resolves via existing install paths, NOT marker files like install does, "what's installed?" vs "what client is the user using?" are separate questions.safelint skill remove, delete detected installs. Inherits--client/--projectfrom install, plus three remove-specific flags:--symlink, filter to symlink-shape installs only, leaving copy-mode installs intact ("delete only my symlink installs").--path PATH, remove one specific location, bypassing every other flag (useful for unusual / forgotten install locations).--dry-run, preview what would be removed without deleting anything.- Shared install-path auto-detection between
updateandremovevia new_detected_installed_clients(*, only_symlink)helper. Distinct from install's_detected_clients(directory, marker_attr)(marker-file scan).
1.9.0 - 2026-05-05#
A focused follow-on to v1.8.0 covering one practical question users asked: how do I know my installed AI-client skill is up to date after pip install --upgrade safelint? Two new surfaces answer it. Also lands a build-time drift-detection test pair that prevents bundled-doc rot for every registered AI client (and every future one, the tests parametrise over the registry).
Added#
safelint skill status, new subcommand that compares every detected installed AI-client skill (Claude Code at~/.claude/skills/safelint/, Cursor at~/.cursor/rules/safelint.mdc, project-scoped equivalents) against the bundled artefact in the active wheel. Reports per-location fresh / differs from bundled, exit 0 when every detected install matches, exit 1 when any differs. Pipe-friendly:safelint skill status || safelint skill install --forceis the canonical "refresh after upgrade" idiom. Symlink installs always report fresh by construction. Documented inAI_CLIENTS.md"Updating after a safelint upgrade" → "Checking whether your installed skill is current".safelint check --check-skill-freshness, opt-in flag that folds the same drift check into a normal lint run. Stale installs surface assafelint: warning: …lines on stderr through the diagnostics channel. Informational only, doesn't fail the lint. Off by default so day-to-daysafelint checkinvocations stay fast (no extra FS scan).ClientSpec.documentation_relpaths+ parametrised drift-detection tests. Each registered AI client declares which files underskill_files/collectively must mention every rule code/name inALL_RULESand every extension insupported_extensions(). Two parametrised tests (test_skill_documents_every_active_rule[<client>],test_skill_documents_every_supported_extension[<client>]) fail CI the moment a contributor adds a rule or language without updating each registered client's bundled docs. New clients added to_CLIENT_SPECSautomatically inherit both checks.- Bundled skill crib-sheets (
SKILL.md,cursor/safelint.mdc) backfilled with the eight rules previously absent from their rationale tables: SAFE203, SAFE401, SAFE601, SAFE701, SAFE702, SAFE801, SAFE802, SAFE803. The drift test now passes at 100% rule coverage.
Changed#
- Top-level
safelint --help"Commands" entry forskillnow listsstatusalongsideinstallandpath. Same change in theCONFIGURATION.mdembedded help example. - Documentation fan-out: the new commands and
--check-skill-freshnessflag are now mentioned in the top-levelREADME.md, the bundled-in-wheelsrc/safelint/skill_files/README.md, and theCONFIGURATION.mdsafelint checkflag table, not only inAI_CLIENTS.md.
1.8.0 - 2026-05-04#
This release bundles three internal milestones (originally tracked as 1.8.0 / 1.9.0 / 1.10.0 during development; only 1.7.0 was published to PyPI) into a single user-visible release. It closes the most-asked-about gaps versus ruff, incremental config, unused-suppression detection, per-rule statistics, broader resource-lifecycle coverage, smarter empty-except detection, configurable global-mutation strictness, configurable function-size counting, and tightens the SAFE801 (tainted_sink) dataflow analysis and introduces advisory suggestions on JSON / SARIF outputs alongside a ruff-style top-level CLI surface. SafeLint stays review-only, there is no --fix flag now or planned.
Added#
Configuration ergonomics#
extend_ignore/extend_per_file_ignoresconfig keys, grow the corresponding default lists instead of replacing them. Mirrors ruff'sextend-selectergonomics. Both are folded into the canonicalignore/per_file_ignoreskeys at config-load time and stripped from the resolved dict, so downstream consumers (engine, runner) only see the merged lists. Order-preserving dedupe means duplicates between the base and the extension collapse to a single entry.extend_tracked_functionsconfig key on theresource_lifecyclerule, appends to the (now-richer) default list instead of replacing it.strictconfig flag on theglobal_mutationrule,strict = truefires on anyglobaldeclaration even without a write, mirroring ruff'sPLW0603. Defaultstrict = falsekeeps the original Holzmann-aligned behaviour (only flag actual mutations). Useful for teams whose policy is to ban theglobalkeyword entirely.count_modeconfig option on thefunction_lengthrule, three counting strategies:"lines"(default), inclusive source-line span. Original behaviour."logical_lines", source lines minus blanks and pure-comment lines. Less game-able than the raw-lines metric."statements", count Python statement nodes. Equivalent to ruff'sPLR0915; fully formatting-independent. Skips nested function bodies so an inner helper doesn't inflate its outer's count.assume_taint_preservingconfig option on thetainted_sinkrule, controls how unknown function calls (those whose name is in neithersourcesnorsanitizers) propagate taint.true(default, preserves the historical behaviour) means an unknown call's result is tainted iff any argument is tainted.falsemeans unknown calls always drop taint, giving a less conservative analysis with fewer false positives but new false negatives where an internal helper does flow tainted data through to a sink. Set tofalsewhen your codebase has many "obviously safe" wrappers and you'd rather miss a flow than report a false positive.
New rules and detection improvements#
SAFE004(unused_suppression), emits a warning for any inline# nosafedirective that didn't actually suppress anything. Catches stale annotations after refactors. The engine tracks per-(line, code) usage during the rule run; unused entries are reported afterward. Globally disable viaignore = ["SAFE004"]if your workflow generates many transient suppressions. Self-referential directives (both# nosafe: SAFE004and# nosafe: unused_suppression, case-insensitive on the code form) are special-cased to avoid recursion.- Broader default tracked functions for SAFE401, covers
socket,mmap,Lock/RLock/Semaphore,Pool/ThreadPoolExecutor/ProcessPoolExecutor,TemporaryFile/NamedTemporaryFile/TemporaryDirectory,ZipFile/TarFile, plusSession(PEP-8-cased) alongside the existingopen/connect/session. Extended cleanup-pattern list addsreleaseandshutdown. Closes the most common ruff-vs-safelint coverage gap on real codebases. SAFE202now catches the canonicalexcept: passand other no-op idioms, previously the rule's check was so narrow it effectively never fired on real code (only on the malformed-AST case). Now flagspass/continue/.../ single-literal expression bodies (0,None,True,False, string-as-comment"TODO"/""etc.) when they're the entire body of an except clause. Multi-statement bodies are still allowed (solog_message; passdoesn't trip).- Splatted-arg taint propagation,
foo(*tainted_list)andfoo(**tainted_dict)now correctly flow the splat operand's taint into the call. Previouslylist_splatanddictionary_splatTree-sitter nodes weren't matched inTaintTracker._is_tainted, so calls likeeval(*user_args)slipped through without a violation.
Advisory suggestions (JSON / SARIF)#
suggestions[]array on every Violation, a list of advisorySuggestionobjects, each with a one-linedescriptionand zero or moreTextEditentries (range + replacement). Empty when the rule has no fix to offer. Surfaced in JSON output (--format json), in SARIF output (--format sarif→ nativefixes[]block, advisory by spec), and via the public Python API (Violation.suggestions: tuple[Suggestion, ...]).SAFE201 (bare_except)ships the first suggestion, replace bareexcept:withexcept Exception:. Validates the schema end-to-end with a real rule. More rules can attach suggestions in subsequent releases without further schema changes.docs/JSON_SCHEMA.mddocuments the newSuggestionandTextEditshapes and dedicates a section to the advisory only contract for editor / CI integrations.
CLI surface#
--statisticsCLI flag, prints a per-rule count summary at the end of a pretty-mode run (CODE RULE ACTIVE SUPPRESSED). Useful for "where do we stand?" snapshots in CI. Sorted by descending total count, ties broken alphabetically by code for deterministic output. Silent on a clean run.- Top-level CLI help and version, ruff-style. New
safelint helpandsafelint versioncommands plus the conventional-h/--help/-V/--versionshort and long flags. Help text uses a coloured layout matching ruff's (Commands / Options / Global options sections, bold headers, cyan command names, dim descriptions). Subcommand-level help is reachable via eithersafelint help checkorsafelint check --help. ANSI colour auto-disables when stdout is not a TTY. Documented inCONFIGURATION.mdunder "Top-level commands and flags".
AI-client integration#
- Cursor support alongside Claude Code.
safelint skill installgains a--clientflag acceptingauto/claude/cursor. Cursor installs deliver a single MDC project rule (safelint.mdc) to~/.cursor/rules/(user) or<cwd>/.cursor/rules/(project), matching Cursor's native Project Rules format. Both clients share the same step-by-step workflow because safelint's CLI surface is the same; the bundled language addendums remain accessible to either client viasafelint skill path. The Claude install excludes thecursor/subdirectory from the materialised skill folder so peer-client bundles don't leak into~/.claude/skills/safelint/(in both copy and symlink modes, symlink mode now per-entry-symlinks the directory contents instead of linking the whole tree). - Auto-detection is now the default.
safelint skill install(no--client) is--client autounder the hood. It scans cwd for client markers (CLAUDE.md/.claude//.cursor//.cursorrules); if found, installs each detected client's skill project-scoped. Otherwise it scans home (~/.claude//~/.cursor/) and installs user-scoped. If neither has any markers, it errors out with the exact--clientcommands the user can run instead. Multi-detection is supported, both Claude and Cursor present means both get installed in registry order.--client auto --projectskips the home fallback. Behaviour change for users running off the development branch: prior to 1.8.0 in this branch, baresafelint skill installalways installed Claude unconditionally. The new default may install Cursor or both in some environments. Pass--client claudeexplicitly to preserve the prior behaviour. (No PyPI users are affected, only 1.7.0 has shipped to PyPI.) ClientSpecregistry pattern. A frozen dataclass + tuple registry insafelint._skill_installdefines each supported client. Adding GitHub Copilot, codex, windsurf, etc. is oneClientSpecappend plus the bundled artefact, no control-flow changes elsewhere. CLI--clientchoices are derived from the registry so argparse stays in sync automatically.- New top-level user guide:
AI_CLIENTS.mddocuments auto-detection logic, per-client install / usage, project-vs-user scope, symlink mode, troubleshooting, and the developer guide for adding a new client. The bundled in-wheel reference atsrc/safelint/skill_files/README.mdstays as a short install-focused doc and links out.
Changed#
- Pretty-mode summary line updated from "No fixes available (safelint does not auto-fix violations)" to either "No suggestions available (safelint does not auto-fix)." (when no rule emitted suggestions) or "N advisory suggestion(s) available, view via --format json or --format sarif (safelint does not auto-apply fixes)" (when at least one rule did). Wording deliberately distinguishes "no auto-fix" (a permanent design choice) from "no suggestions available" (a per-run state). Test assertions updated accordingly.
- Cache schema version bumped from "1" to "2". The new
suggestions[]field onViolationrequires a richer reconstruction thanViolation(**dict), which the cache now handles via_dict_to_violation/_dict_to_suggestion/_dict_to_text_edit. Existing cache entries written by older safelint versions become unreachable automatically (the version is folded into the engine fingerprint, which is part of the cache key). - The taint-through-formatting paths (f-strings,
"…".format(tainted),"… %s …" % tainted) now have explicit regression tests covering each form. The behaviour itself was already in place since the original SAFE801 implementation; this release just locks the contract in so it can't silently regress.
Fixed#
SAFE202previously only matched empty-named-children blocks (which Tree-sitter doesn't actually produce for valid Python), so the rule was effectively dead code. The broadened detection above fixes this.ReturnValueIgnoredRulenow anchors violations on the call node rather than the wrappingexpression_statement, so column ranges match the offending call instead of including trailing newline / semicolon tokens.
Notes#
- The contract is permanent: SafeLint will never ship
--fix. This is documented as a project policy indocs/JSON_SCHEMA.md("Suggestions are advisory only" section). Editor integrations (Claude Code skill, VSCode plugin) may render suggestions as Quick Fix code actions, but every edit goes through user confirmation. The SARIFfixes[]block is natively advisory per the SARIF 2.1.0 spec, GitHub code scanning, IDE extensions, and other consumers already implement confirmation flows for it. - Helper-function inlining (cross-function taint analysis) was considered for the SAFE801 work in this release but deferred. Adding it would require a full call-graph walker bounded by depth limits and constitutes a larger rewrite of
TaintTracker. The current intra-procedural-only analysis remains the design contract; if a real-world need emerges for cross-function taint, it can be picked up in a future release. - Internal milestones 1.9.0 and 1.10.0 were never published to PyPI; their work is included in this 1.8.0 release. Those version numbers remain available for future use.
1.7.0 - 2026-05-04#
This release adds column-precise positioning to violations, the foundational change needed before a polished VSCode extension can underline the exact span of an offending construct rather than the whole line. No breaking changes; the new fields default to null for any consumer that doesn't read them.
Added#
- Fully-resolved range positions on every Violation:
end_lineno,column_start,column_end(in addition to the existing requiredlineno). All four are 1-based; the range is half-open[start, end), matching LSP / VSCodeRangeand SARIFregionsemantics.end_linenocorrectly anchorscolumn_endto the end-line of multi-line constructs (function definitions, except clauses, while loops), without it, editors would mis-applycolumn_endto the start line.linenoremains required (no default); the three additional fields default toNonefor synthetic violations without a Tree-sitter node (e.g.test_existenceagainst missing files), which editor consumers should treat as "underline the whole line". node_range(node)helper insafelint.languages._node_utils, returns(start_line, end_line, column_start, column_end)tuples directly from a Tree-sitter node, so rule code stays free of inlinestart_point[0] + 1/end_point[1] + 1plumbing.BaseRule._make_violation_for_node(filepath, node, message), convenience wrapper around_make_violationthat auto-extracts the full 4-coordinate position info from a Tree-sitter node. Most rules now use this; the lower-level_make_violationacceptscolumn_start/column_end/end_linenokwargs for the few cases (e.g. parse errors) where the node isn't available.
Changed#
- All built-in rules with a Tree-sitter node in scope now populate columns:
function_length,nesting_depth,max_arguments,complexity,bare_except,empty_except,logging_on_error,side_effects,side_effects_hidden,resource_lifecycle,unbounded_loops,missing_assertions,global_state,global_mutation,tainted_sink,return_value_ignored,null_dereference. Thetest_existence/test_couplingrules continue to emit file-level violations with no column data (the violation is about the file, not a span). TaintTracker.sink_hitsnow stores(call_node, var_name, sink_name)tuples instead of(lineno, var_name, sink_name)so the consuming rule can derive position info, including columns, from the node directly.- Parse-error violations (
SAFE000) now carry the column of the offending token as a zero-width caret (column_start == column_end), so editors can render a precise marker. - JSON output (
--format json) gainsend_lineno,column_start, andcolumn_endkeys on every violation. Existing consumers ignoring unknown keys are unaffected. - SARIF output (
--format sarif) populatesregion.startColumn,region.endColumn, and (for multi-line constructs only)region.endLine. Single-lineendLineis omitted because SARIF spec defaults absentendLinetostartLine.
1.6.0 - 2026-05-02#
This release ships the Claude Code skill inside the wheel and adds a one-line install command, plus a batch of correctness fixes from the v1.5.0 review cycle (caching, argv routing, SARIF URIs, CLI strictness, clean-run UX).
Added#
safelint skill installsubcommand, copies the bundled Claude Code skill into~/.claude/skills/safelint/(default) or<cwd>/.claude/skills/safelint/(with--project). Use--symlinkfor a live link to the bundled location,--forceto replace an existing install. New install flow ispip install safelint && safelint skill install.safelint skill pathsubcommand, prints the on-disk location of the bundled skill files. Useful for inspectingSKILL.mddirectly or debugging install issues.- Skill files are now bundled in the wheel at
safelint/skill_files/(mirroringsafelint/languages/one-to-one).safelint skill installfinds them viaimportlib.resources, so the same code path works forpip install,uv add, and editable installs from a checkout. docs/JSON_SCHEMA.md, the stable schema forsafelint check --format json. Documents top-level keys, the Violation object, severity / fail_on / blocking semantics, and example consumers in bash / Python / Node. Versioning policy: additions are non-breaking; removals require a major bump.
Changed#
- The Claude Code skill now lives at
src/safelint/skill_files/in the source tree (wasskills/safelint/). The skill itself is also more modular: a language-agnostic core (SKILL.md) plus per-language addendums underlanguages/<lang>.md, mirroringsrc/safelint/languages/<lang>.py. To add a new language, follow the new step 7 inADDING_A_LANGUAGE.md.
Fixed#
per_file_ignoresis now folded into the engine fingerprint, so adding/removing/editing a glob entry between runs invalidates the affected cache entries. Previously a cache hit carried the cachedsuppressedlist over unchanged, which meant removing aper_file_ignoresentry left previously suppressed violations stuck in the suppressed list, the user would loosen config and still see the silence applied. The post-hit re-filter (which only walked the active list and never the suppressed list) is now also redundant and has been removed.- Argv routing no longer breaks when a value-taking global flag precedes the
checksubcommand. Previouslysafelint --format json check srcsawjsonas the first non--token and fell into hook mode, silently no-oping (jsonandcheckaren't.py) with exit 0. The router now recognises the value-taking flags (--format,--fail-on,--mode,--ignore,--config,--stdin-filename) and skips their values when looking for the subcommand. - Cache key now includes the normalised filepath (in addition to source bytes and engine fingerprint), so two files with identical contents under different paths no longer share a cache entry. Without this, every emitted
Violationfrom the second-served file would carry the first file's path, and path-dependent rules (test_existence,test_coupling) would draw conclusions from the wrong file. - Cache directory now anchors to the discovered config root (where
safelint.tomlor[tool.safelint]was actually found while walking up), not to the directory the user happened to pass tosafelint check. Hook mode resolves the location the same way as check mode, so a single project can no longer end up with multiple.safelint_cache/directories scattered across subdirectories. safelint checkin pretty mode now prints theAll checks passed.summary on a clean run (matching ruff/ty's UX). Pre-commit hook mode and--stdinmode stay silent on success via a newsilent_on_cleanflag.- SARIF
artifactLocation.urinow emits a valid URI reference: backslash separators are normalised to forward slashes, absolute paths are made cwd-relative when possible, and special characters are percent-encoded. GitHub code scanning previously rejected SARIF docs produced on Windows hosts. - CLI now fails loudly on unknown flags.
--formta=jsonand similar typos used to be silently ignored (because hook/stdin parsing calledparse_known_args); they now surface aserror: unrecognized arguments: --formta=json. - In
--format json/--format sarif, status messages from the git-modified-files probe go to stderr instead of stdout, so machine-readable output stays a single parseable document. The "no modified Python files" early-return now also emits an empty JSON/SARIF doc on stdout in those modes.
Migration#
If you installed the v1.5.0 skill by symlinking skills/safelint/ from a git checkout, that path no longer exists in v1.6.0. To migrate:
rm ~/.claude/skills/safelint # remove the stale symlink
pip install --upgrade safelint
safelint skill install
1.5.0 - 2026-05-02#
This release adds the foundations needed by editor integrations and the upcoming Claude Code skill / VSCode plugin: structured output formats, an in-process stdin mode, and a content-addressed result cache. No breaking changes.
Added#
--formatflag with three choices:pretty(default, unchanged ruff/ty multi-line coloured output),json, andsarif. The JSON format emits a stable schema with aversion,summary(counts + suppressed breakdown), and flatviolations/suppressedlists. The SARIF format is SARIF 2.1.0 conformant and consumable by GitHub code scanning, Azure DevOps, and similar tools. The flag is available in bothsafelint checkand pre-commit hook modes.--stdin/--stdin-filename PATHflags read source from stdin instead of from disk and lint it as if it came fromPATH. Designed for editor integrations that need to lint un-saved buffers without round-tripping through a temp file. The pseudo-filename drives language detection by extension and shows up as the violation file path.SafetyEngine.check_source(filepath, source)public method runs the same rule pipeline ascheck_filebut on a caller-provided buffer. Used by stdin mode and available to library consumers building editor integrations.- Per-file lint-result cache keyed on
sha256(source + engine fingerprint)where the engine fingerprint folds in safelint version, an internal cache schema version, and the active rule set with per-rule config. The cache lives at<config-dir>/.safelint_cache/(next topyproject.toml/safelint.toml, mirroring.pytest_cache's convention) and stores one JSON file per key. Re-runs on unchanged files are essentially instant, important for editor "lint on save" loops. --no-cacheflag disables the cache for the current run (e.g. CI where every run is fresh anyway, or when debugging cache-related issues)..safelint_cache/added to the project's.gitignore.ADDING_A_LANGUAGE.mddeveloper guide: a concrete walkthrough of adding a new language (TypeScript, Go, Rust, …), with a per-rule audit of which Python rules are portable, language-agnostic, or Python-only.
Notes#
--stdinmode unconditionally bypasses the disk cache. Editor keystrokes produce a slightly different buffer every time; caching them would only churn the project tree without ever helping. The--no-cacheflag is therefore a no-op in stdin mode.- The new public
LintCacheclass acceptscache_dir=Noneto opt out of caching at the engine level, used by--no-cache, by stdin mode, and recommended for any tests / library callers that need isolation.
1.4.1 - 2026-05-01#
Added#
max_file_size_bytestop-level config option (default 5 MiB). Files larger than the bound are skipped with asafelint: warning: skipping <path> (<size> bytes exceeds max_file_size_bytes=…)diagnostic to stderr instead of being read into memory and parsed. Guards against OOM on accidentally-huge inputs (binary blobs masquerading as.py, very large generated files). To allow larger files, raise the bound explicitly,0is rejected as a likely typo (it would disable the OOM guard entirely) and falls back to the default with an init-time warning. Engine init validates the value: must be a non-negative integer, otherwiseTypeError/ValueErrorfires before any file is read. Closes #20.
Fixed#
- File discovery is now safe against symlink cycles.
SafetyEngine._discover_filesswitched fromPath.rglob('*')(which follows symlinks and can recurse forever on a cycle likea/sub -> ..) toos.walk(target, followlinks=False). Same single-pass O(number_of_files) cost, but safe by construction. Matches what ruff and flake8 do by default. Closes #19.
1.4.0 - 2026-05-01#
Heads-up, breaking library API change.
LintResult.suppressedis nowlist[Violation](wasint). Library consumers that read this field directly need to switch tolen(result.suppressed)for the count. CLI users are unaffected. See Changed below for details and migration notes.
Added#
- Standalone
safelint.tomlconfiguration file (top-level keys, no[tool.safelint]wrapper). When bothsafelint.tomlandpyproject.toml[tool.safelint]exist in the same directory,safelint.tomlwins, matchingruff.toml/pyproject.tomlprecedence. examples/sample.safelint.tomlreference covering every supported configuration key.- Public
safelint.languages.supported_extensions() -> frozenset[str]for callers that need to know which file extensions have a registered language. Use this instead of importing the private_REGISTRY. walk()insafelint.languages._node_utilsaccepts an optionalskip_typesparameter that prunes subtrees rooted at any matching node type (used by per-function rules to avoid descending into nesteddef/async defbodies).
Changed#
side_effects(SAFE304) andside_effects_hidden(SAFE303) now normalise both sides of the name comparison. Function names are lowercased for matching, and user-suppliedio_name_keywords/pure_prefixesare lowercased once at config load, so configurations likeio_name_keywords = ["Write", "Log"]orpure_prefixes = ["Get", "Calculate"]behave the same as their lowercase forms. Previously only the function name was lowered, leaving uppercase config entries silently unmatched.load_config()now returns a fresh deep copy of the merged config on every call. Mutating the result (e.g.config["ignore"].append(...)) no longer corrupts the module-globalDEFAULTS.- Removed YAML (
.safelint.yaml) configuration support and thesafelint[yaml]install extra. Migrate to[tool.safelint]inpyproject.tomlor to a standalonesafelint.toml. - CLI summary "All checks passed." is now bold green to match
ruff/ty. - The "No fixes available …" line is no longer printed on clean runs (with or without suppressions). It only appears when there are active violations a developer might wonder about auto-fixing.
- Suppressed-violation summary now shows a per-code breakdown, e.g.
(2 SAFE501, 1 SAFE304 suppressed), instead of a bare(N suppressed)count, so it is clear which rules were silenced. - Breaking (library API):
LintResult.suppressedis nowlist[Violation](wasint). Uselen(result.suppressed)for the count and iterate to inspect codes, rules, file paths, and line numbers of suppressed violations. - Replaced internal use of Python's
loggingmodule with a dedicated diagnostics channel that writes formatted single-line messages to stderr (safelint: warning: …,safelint: error: …). Configuration typos and malformed-TOML errors are now surfaced cleanly instead of leaking through Python'slastResortlogging handler. walk()now traverses onlynamed_children(skips Tree-sitter's anonymous punctuation/keyword tokens), reducing the number of nodes visited per traversal across every rule and the suppression parser.- Parse-error violations (
SAFE000) now include line, column (1-based), and a kind hint such asmissing ':'orsyntax error. The lineno on the violation now points at the offending location instead of being hardcoded to 0. MaxArgumentsRulenow counts*argsand**kwargsparameters, each as one argument. Previously they were silently ignored, allowing functions to exceedmax_argswithout triggering.- An empty
[tool.safelint]section inpyproject.toml(or an emptysafelint.toml) is now treated as a present-but-empty config. Previously the loader fell through to an ancestor directory's config, hiding unintentionally-blank sections. - Self-development pre-commit hook switched from
repo: https://github.com/shelkesays/safelint @ v1.3.2torepo: local, so contributors run the in-tree code rather than an outdated published release while iterating on safelint itself.
Fixed#
- Per-function rules no longer incorrectly aggregate metrics from nested
def/async defbodies into the enclosing function. Affectscomplexity,nesting_depth,missing_assertions,unbounded_loops,global_state,global_mutation,logging_on_error(a logging call inside a nested helper would have falsely satisfied the rule), and the dataflowTaintTracker. Each nested function is scored as its own unit, as the outer-walk loop already intended. state_purity(global_state,global_mutation) now also stops at nested class definitions, aglobal Xdeclared inside a nested class body lives in that class's scope, not the enclosing function's.function_length(SAFE101) reported counts that were off by one (a 60-line function showed59 lines). The calculation is now inclusive of thedefline.- Dataflow taint tracker now unwraps
keyword_argumentnodes,eval(code=user_input)is no longer missed because the tainted value was hidden behind a kwarg wrapper. - Dataflow taint tracker now propagates taint through tuple/list destructure targets (
a, b = tainted,[a, b] = tainted,(a, b) = tainted), starred destructures (a, *rest = tainted), and chained assignments (a = b = tainted). Previously the LHS shape was assumed to be a single bare identifier, so every other form silently dropped the taint. - Top-level
ignoreandper_file_ignoresentries now validate that every value is a string. Non-string elements (e.g.["SAFE101", 42]) and wrong-shape values (e.g.ignore = "SAFE101") are reported with a clearTypeErrorat engine init instead of crashing later on.upper(). - File discovery now does a single
rglob('*')pass and filters by suffix, instead of onerglob('*<ext>')per registered extension. Discovery is now O(number_of_files) rather than O(number_of_extensions * number_of_files). No behaviour change on a single-language registry, but matters as more languages are added.
1.3.1 - 2026-04-24#
Added#
ignoreconfig key and--ignoreCLI flag: suppress rules globally by code (SAFE101) or name (function_length); unknown entries log a warning at startup.per_file_ignoresconfig key: suppress specific rules for files matching a glob pattern (e.g."tests/**" = ["SAFE101", "SAFE103"]); multiple patterns union their ignore lists.# nosafe: RULE, CODEinline suppression: codes and rule names can now be mixed in the same comma-separated list on a single# nosafe:comment.- Suppressed violation count (from both
# nosafeandper_file_ignores) reported in the end-of-run summary so suppressions remain auditable.
Changed#
SafetyEngine.__init__extracted into two focused static methods (_build_active_rules,_parse_per_file_ignores) to keep complexity within bounds.- Pattern matching for
exclude_pathsandper_file_ignoresswitched frompathlib.Path.matchtofnmatch.fnmatchcase(path.as_posix(), pattern), fixing incorrect**handling on Python ≤ 3.12. --ignoreCLI flag changed fromnargs="+"toaction="append"so it can be repeated (--ignore SAFE101 --ignore SAFE103).- CLI summary now shows
(N suppressed)instead of(N suppressed via # nosafe)to cover all suppression mechanisms.
Fixed#
per_file_ignorespatterns with**(e.g.tests/**) now correctly match files in nested subdirectories on Python ≤ 3.12.- Pre-commit hook no longer shows a success message for files that were silently excluded.
1.3.0 - 2026-03-01#
Added#
- Initial public release with 16 built-in rules covering function length, nesting depth, cyclomatic complexity, error handling, global state, side effects, resource lifecycle, loop safety, and opt-in dataflow analysis.
- TOML (
pyproject.toml) and YAML (.safelint.yaml) configuration with deep-merge against built-in defaults. # nosafeinline suppression (bare and with specific codes).exclude_pathsglob patterns to skip directories entirely.fail_fastexecution option.- Pre-commit hook integration.
--mode=ciand--fail-onCLI flags.