"Notice me, Senpy! (⁄ ⁄>⁄ ▽ ⁄<⁄ ⁄)"
Senpy — Fast static analyzer, semantic linter, control-flow analyzer and VS Code language server for Ren'Py visual novels. Detects undefined variables, broken labels, control-flow bugs, rollback/save issues, translation errors, and more.
CLI / CI / Linux / macOS:
pip install senpy-linterVS Code Extension (Windows, zero Python setup):
Download the latest senpy-vscode-X.X.X.vsix from the Releases page → Install from VSIX in VS Code.
0.7.6 — New rules (RPY704-707, RPY801-802), rule improvements, mypy hardening, fixture coverage 100%
| Feature | Description |
|---|---|
| 6 new lint rules | RPY704 (unused default variable), RPY705 (unused screen parameter), RPY706 (unused transform parameter), RPY707 (unused audio channel registration), RPY801 (duplicate translation string), RPY802 (inconsistent translation across files). Total: 49 → 51 rules. |
| Rule improvements | RPY108: catches incompatible BinOp in expressions ("text" + 5). RPY401: concrete fix suggestion ($ var = 0). RPY403: tautology detection (if x or not x:). RPY502: case-insensitive image matching (show Sayori → suggests sayori). |
| Fixture coverage 100% | All 51 rules now have bad+good .rpy fixtures with auto-verified JSON snapshots. Was 23/45 (51%). |
| mypy 264 → 32 | Vendor excluded, DiagnosticSink → SenpyAnalyzer (TYPE_CHECKING), pygls overrides, None guards. −88% errors. |
| CLI flag validation | --fix+--check, --fix+--diff, --check+--diff now exit with code 2 and a clear message (no traceback). Warnings for no-op combinations. |
| CI badges always update | Badges now update on every main push, even if a matrix test fails (via if: always()). No more stale green badges. |
| Dependabot | Weekly dependency updates for pip + GitHub Actions, grouped into single PRs. |
| RPY902 example plugin | examples/todo_comment_plugin/ — detects # TODO:, # FIXME:, # XXX:, # HACK: comments. Second example plugin alongside my_senpy_plugin (RPY901). |
| Feature | Description |
|---|---|
| Plugin autoload | Installed-and-trusted plugins are now loaded automatically when you run senpy, senpy rule list/show, or senpy inspect. Previously the loader pipeline existed but was never called from CLI entrypoints — plugin rules were invisible. Now senpy rule list after pip install -e examples/my_senpy_plugin shows RPY901. |
| Pre-commit hook | .pre-commit-hooks.yaml ships in the repo root. Add Senpy to any project using pre-commit with a 4-line config (see Pre-commit Integration below). Only --check (non-mutating) is exposed — --fix would loop on Windows CRLF. |
| BLAKE2b cache hash | File cache keys now use BLAKE2b instead of MD5. Faster, no security baggage. Old .senpy_cache/cache.pkl invalidates silently (hash mismatch → cache miss → recompute, no crash). |
_RWLock timeout |
The LSP server's read/write lock gained acquire_read(timeout=...) / acquire_write(timeout=...) so a stuck client can't hang the LSP forever. Full migration to the readerwriterlock library is deferred to post-1.0 (documented tradeoff). |
| macOS Python matrix | CI now runs Ubuntu × Windows × macOS across Python 3.11/3.12/3.13 (9 jobs), plus Python 3.14 beta on Ubuntu + Windows (experimental, continue-on-error). |
SYSTEM_LABELS in inspect |
senpy inspect no longer reports start, splash_window, after_load, main_menu, etc. as "unreferenced" — they are system labels Ren'Py calls automatically. |
NON_ASSET_EXTENSIONS |
.rpy, .rpym, .rpyc, .rpa files are no longer counted as "unused assets" by RPY503 — they are code, not media. |
| English-only codebase | All comments and docstrings in senpy/ are now English (the Russian-to-English translation pass was completed in 0.7.3; 0.7.5 extends the guard to scripts/). The test_no_russian_comments.py test now scans scripts/ too. |
| Feature | Description |
|---|---|
senpy inspect subcommand |
Dumps all symbols Senpy sees in a project as JSON: labels, screens, variables, transforms, images, jump_sources (reverse index), used_assets, diagnostics, unresolved (broken jumps / missing screens / unreferenced labels / unused assets), and per-section stats. Designed for debugging hover/asset resolution and for plugin authoring. Shortcuts: --summary, --unresolved, --label X, --screen X, --transform X. |
| Output formats: rdjson + gitlab | --output-format rdjson (Reviewdog NDJSON — one JSON object per line, pipe into reviewdog -f=rdjson) and --output-format gitlab (GitLab Code Quality JSON array — fingerprint-stable for MR annotations). Total output formats: 7 (text, json, sarif, junit, github, rdjson, gitlab). |
senpy rule subcommand |
Browse rule metadata without running the analyzer: senpy rule list [--format json] [--tag T] [--severity S], senpy rule show CODE. --rules and --rule CODE flags on the main CLI continue to work as aliases. |
| Tags on rules | Every rule in RULES_DATABASE now has a tags field (28 tags across 45 rules: formatting, variables, state, rollback, deprecated, screen, assets, audio, translations, dead-code, etc.). senpy rule list --tag rollback filters by tag (union semantics). |
--tag / --ignore-tag filters |
Lint mode accepts --tag rollback state (run only those rules) and --ignore-tag dead-code (silence those rules). Tag matching is case-insensitive; a rule passes --tag if ANY of its tags matches. |
strict = true config |
The strict field on SenpyConfig is now enforced (was documented since 0.6.0 but never read). With strict = true, every diagnostic whose code is NOT explicitly overridden in [tool.senpy.severity] is promoted to ERROR. Explicit severity overrides win over strict. |
| FMT-STRING-CONSISTENCY | 8th formatter rule (7th in code, 8th by ID). Normalizes the outer quotes of single-line string literals in CODE lines to a consistent style. Default: 'hi' → "hi" (Ren'Py convention). Configurable via `string_style = "double" |
| Format Style Guide (stub) | docs/FORMAT_STYLE_GUIDE.md documents the 6 original formatter rules. Full version (8 rules) lands in 0.7.6. |
| Feature | Description |
|---|---|
| Non-interactive plugin trust | --trust-plugin NAME / --untrust-plugin NAME CLI flags write to ~/.senpy/trusted_plugins.json and sys.exit(0) without running the analyzer. Trust management is now fully scriptable. The default trust callback detects LSP mode (--lsp in argv) and declines untrusted plugins without prompting (no input() hang in LSP). |
| English-only codebase (Russian-to-English pass) | All Russian comments and docstrings in senpy/ translated to English in a single mechanical pass: 52 files, ~1057 lines changed (additions = removals, no logic changes). senpy/domain/tsundere.py (intentional Russian easter-egg) and senpy/vendor/ are whitelisted. tests/test_no_russian_comments.py guards the invariant going forward. |
| Dynamic badges | Five badges auto-updated by CI on every push to main: tests, rules (45), LSP handlers, coverage, speed. scripts/update_badges.py reads metrics and writes JSON files; README badges are shields.io endpoint?url=... pointing at raw JSON on main. |
| 6 new CLI flags | --statistics, --add-noqa, --respect-gitignore, --config FILE (with SENPY_CONFIG env var), --show-settings, --cache-dir. |
| Feature | Description |
|---|---|
| God-function refactoring | 12 long functions split into focused helpers (≤60 lines each, ≤80 for dispatch tables). Notable: _check_show_scene (188 → 30 lines), _check_audio_statement (174 → 21 lines), MermaidBuilder.build (132 → 18 lines), process_quoted_strings (187 → 58 lines). All public APIs unchanged; characterization tests held. |
| DRY helpers | New senpy/core/paths.py (28 LOC) and senpy/core/progress.py (24 LOC) centralize normalize_path and progress_print — previously copy-pasted across modules. |
| First dynamic badge | The tests badge auto-updates on main via scripts/update_badges.py. 4 more badges arrive in 0.7.3. |
| Feature | Description |
|---|---|
| Stale API cleanup | All LanguageServer.show_message_log calls (pygls 2.x removed API) replaced with _logger.debug/warning. Stale build_exe.bat and build_vsix_dev.bat removed. |
| Doc-sync | README "What's New" updated from 0.6.0 to 0.7.0; plugin example in README synced with examples/my_senpy_plugin/; SECURITY.md broken test links fixed. |
| Internal-ID scrub hook | scripts/clean_internal_ids.py (pre-commit hook) scrubs any leaked internal task IDs from comments, CHANGELOG, commit messages, docstrings. tests/test_clean_internal_ids.py locks in the contract. |
warnings.filterwarnings removal |
Module-level global filterwarnings removed from core/analyzer.py and lsp/server.py. Tests run with -W error::SyntaxWarning to catch silent regressions. |
| Feature | Description |
|---|---|
| Image Preview on Hover | Hover over show sayori happy or scene bg forest to see an inline preview of the sprite/CG with size metadata. The PNG/JPEG/WEBP/GIF/BMP header parser is custom-written, requiring no dependency on Pillow. |
| Audio Hover | Hover over play music bgm_calm / queue sound click / define audio.X = "…" statements to see a "Play" link that opens VS Code's built-in media viewer. Aliases are resolved during indexing, not on every hover. |
| Video Hover | If an image or an auto-discovered file has a video extension (.mp4, .webm, .ogv, .avi, .mkv), the hover will display an "Open in default app" link via the new senpy.openExternal VS Code command. |
| Auto-generated Ren'Py API Docs | 754 entries have been automatically collected from Ren'Py 8.5.3 RST sources. Together with 221 curated entries, RENPY_API_DOCS_ALL now contains 975 symbols — a 4.4× increase. |
TextMate Grammar for .rpy/.rpym |
A 270-line grammar with source.renpy scope, copied verbatim from renpy-magic (MIT). It covers comments, strings, Ren'Py text tags {…} and interpolation […], python/init python blocks, $-prefixed python lines, labels, screens, transforms, image definitions, say statements, numbers, and operators. |
| Orphaned Video Detector | MEDIA_EXTENSIONS has been expanded to include .mp4/.avi/.mkv — RPY503 (unused asset check) now flags orphaned video files as well. |
Full changelog: CHANGELOG.md
renpy lint catches syntax errors, but does not catch the bugs that actually
ship to players. These are real bugs that Senpy would have caught before
release:
define player_gold = 100
label spend_gold:
$ player_gold -= 50 # ← mutates a `define`
"You have [player_gold] gold."Symptom: Game works in dev. Players report Exception: define player_gold is not revertable
on rollback, or save files silently corrupt after loading an old save.
Senpy catches it: RPY105 (define mutation) fires at lint time.
label start:
jump intro
label intro:
$ greeted = True
"Hello!"
label continue_story:
if greeted: # ← `greeted` not declared with `default`
"Hi again!"Symptom: Game works on first playthrough. Players who load a save at
continue_story (skipping intro) hit NameError: name 'greeted' is not defined.
Senpy catches it: RPY104 (dynamic assignment without default).
label branch_demo:
menu:
"Take the sword":
$ has_sword = True
"Leave it":
pass # ← has_sword never set in this branch
if has_sword: # ← MAYBE_SET read
"You swing!"Symptom: Game works for players who take the sword. Players who leave it
hit NameError: name 'has_sword' is not defined (or in rollback, the variable
silently retains a previous playthrough's value).
Senpy catches it: RPY404 (reading MAYBE_SET variable) and RPY401
(branch asymmetry).
label add_item:
$ inventory.add("sword") # ← list has no .add() methodSymptom: AttributeError: 'list' object has no attribute 'add' the first
time a player picks up an item. Tested paths miss it; untested paths crash.
Senpy catches it: RPY108 (incompatible collection method) with QuickFix
suggesting .append().
# 200 image definitions, only 30 ever shown on screen
image bg_old_castle = "old_castle.png" # ← left over from cut content
image bg_old_forest = "old_forest.png" # ← unusedSymptom: Build size balloons. Players download 500MB instead of 200MB. Nobody notices until a Steam reviewer complains about download size.
Senpy catches it: RPY701 (unused define), RPY702 (unused transform),
RPY703 (unused screen).
# game/script.rpy
$ player_name = "Hero"
"Welcome, [player_name]!" # ← English source
# game/tl/russian/script.rpy
translate russian welcome_1:
"Добро пожаловать, [player]!" # ← typo: player instead of player_nameSymptom: Russian players see Добро пожаловать, [player]! literally
(interpolation variable doesn't exist). English build works fine.
Senpy catches it: RPY601 (broken interpolation variable in translation).
Every bug above:
- Slips past
renpy lint(which only checks syntax + label targets). - Survives dev playtesting (devs always pick the "interesting" branch).
- Surfaces only after release — via Steam reviews, Discord screenshots, or itch.io bug reports.
- Costs hours of "works on my machine" debugging because the player can't reproduce it in front of you.
Senpy catches them at lint time — before they ship. That's why you need it.
For the full list of 45+ rules with examples, see RULES.md.
The standard built-in renpy lint command only checks for basic syntax errors and broken label targets. It is blind to deep runtime Python execution issues, rollback desynchronizations, and save-file corruptions. Consequently, critical NameError and KeyError crashes often slip into production builds and are only discovered after dozens of hours of manual playtesting.
Senpy provides comprehensive static semantic analysis without requiring the Ren'Py SDK runtime:
- Zero-Setup Standalone VSIX: Windows extension packages bundle a self-contained runtime — writers and narrative designers don't need Python, virtual environments, or
pipinstalled. - High Throughput & Disk Serialization: Analyzes 77,000+ lines in ~3.5s on cold start and 0.04s incrementally via two-tier
.senpy_cache/disk serialization. - Save Game & Rollback Safety: Catches dangerous
definemutations (RPY105), non-revertable types (RPY106), dynamic assignments missingdefault(RPY104), and type-taint conflicts (RPY108). - Init Phase Dependency Graph: Analyzes alphabetical file loading order in
init python:to prevent runtimeNameErrorcrashes on startup (RPY109). - Arity & Signature Checking: Verifies parameter counts and keyword arguments on
call label(...)(RPY310) anduse screen(...)(RPY207). - Branching Dataflow Analysis: Traces nested
if/elif/elseandmenu:branches to ensure variables aren't evaluated in an uninitializedMAYBE_SETstate (RPY401,RPY404). - Screen & UI Action Inspector: Catches predictive state mutations (
RPY201), imperative loops (RPY205), dead buttons (RPY203), function calls in actions (RPY206), and inspects string targets in screen Actions (SetVariable,ToggleVariable,Show,ShowMenu,Hide) for typos and missing declarations (RPY202,RPY204). - ATL Transforms & Transitions: Validates custom and built-in positions/transforms in
show ... at <transform>and transitions inwith <transition>, catching runtimeNameErrortypos with QuickFix suggestions (RPY502). - Collection Method Guard: Detects incompatible method invocations on known types (
list.add()→.append(),dict.append(),str.pop()) before runtimeAttributeErrorcrashes occur (RPY108). - Translation Validator & Coverage: Audits
game/tl/formatting, catches broken interpolation variables (RPY601), and calculates localization completion metrics viasenpy --coverage. - Runtime Crash Inspector: Parses
traceback.txtpost-mortem crash logs, explaining failures in human-readable terms (senpy --traceback). - Dead Code Detection: Finds unused
defineconstants (RPY701), unusedtransformdefinitions (RPY702), and unusedscreendefinitions (RPY703).
Ren'Py ships with a built-in lint command (renpy.exe lint), but it launches the entire Pygame runtime and only catches syntax-level issues. Senpy is a standalone static analyzer that runs without the Ren'Py SDK and catches a different class of bugs.
| Aspect | Ren'Py built-in lint |
Senpy |
|---|---|---|
| Approach | Launches Pygame runtime, executes init python: blocks |
Static analysis, no SDK required |
| Speed on 70k LOC | 30–40 seconds | ~3.5s cold / 0.04s incremental |
| Save / rollback safety | ❌ | ✅ RPY104, RPY105, RPY106 |
| Translation mismatch detection | ❌ | ✅ RPY601, RPY602, RPY603 |
| Story graph visualization | ❌ | ✅ senpy --graph |
| Dead code detection | ❌ | ✅ RPY701–RPY707 |
| Branching dataflow analysis | ❌ | ✅ RPY401, RPY404 |
| Init phase dependency graph | ❌ | ✅ RPY109 |
| CI-ready (GitHub Actions, SARIF) | Needs headless Ren'Py SDK in CI | pip install senpy-linter + SARIF output |
| IDE integration | ❌ | ✅ LSP for VS Code (20+ handlers) |
| What it catches | Syntax errors, broken jump/call targets, missing images |
+ Save corruption, rollback desync, translation bugs, dead code, type taint, init order |
| Distribution | Bundled with Ren'Py SDK | PyPI (senpy-linter) + Open VSX extension + GitHub Releases |
The two tools are complementary, not replacements. Senpy catches a class of bugs that built-in lint cannot see (semantic, save/rollback, translation, control-flow). Run both: built-in lint for SDK-level checks, Senpy for everything else.
vscode-language-renpy is the official Ren'Py extension for VS Code (152 ⭐, maintained by the Ren'Py team). It provides syntax highlighting and basic snippets. Senpy provides semantic analysis on top of that — it understands variables, control flow, screens, and translation files, not just text.
| Capability | vscode-language-renpy |
Senpy |
|---|---|---|
| Syntax highlighting | ✅ | ✅ |
| Language configuration (brackets, comments) | ✅ | ✅ |
| Snippets | ✅ basic | ❌ |
| Real-time diagnostics | ❌ | ✅ 49+ rules, runs on every keystroke |
| Go-to-definition | ❌ | ✅ labels, screens, transforms, defines |
| Find references | ❌ | ✅ across the whole project |
| Hover docs | ❌ | ✅ renpy API + asset previews |
| Rename symbol | ❌ | ✅ labels, screens |
| Semantic tokens | ❌ | ✅ |
| Story graph visualization | ❌ | ✅ Mermaid diagram of branching routes |
| Save / rollback safety checks | ❌ | ✅ |
| Translation validator | ❌ | ✅ game/tl/ mismatch detection + coverage |
The two extensions are complementary and can be installed side-by-side. If you want both syntax highlighting (official) and semantic diagnostics (Senpy), install both.
Senpy includes a full Language Server Protocol implementation with 20+ handlers:
| Feature | Shortcut | Description |
|---|---|---|
| Diagnostics | automatic | Real-time error/warning squiggles (~0.04s incremental cache) |
| Go to Definition | F12 |
Jump to label/screen/variable/asset definitions |
| Hover | hover | Docs for 220+ Ren'Py API functions |
| Autocomplete | Ctrl+Space |
Labels, screens, transforms, variables, image attributes |
| Document Symbols | Ctrl+Shift+O |
Outline view with labels/screens/transforms |
| Workspace Symbol | Ctrl+T |
Global symbol search across all files |
| Rename | F2 |
Rename label → updates all jump/call references |
| Find All References | Shift+F12 |
All jump/call sites for a label |
| Code Folding | automatic | Collapse label/screen/menu/init blocks |
| Document Highlight | cursor | Highlight all occurrences of word in file |
| QuickFix | Ctrl+. |
Auto-fix suggestions (tabs→spaces, smart quotes, unclosed tags) |
| Color Swatches | 0.6.0+ |
Hex color literals shown as clickable color picker |
| Format Document | Shift+Alt+F |
Run Senpy formatter (8 rules) |
| Format Selection | Ctrl+K Ctrl+F |
Format selected range |
| Signature Help | 0.6.0+ |
Parameter hints for call label(...) / use screen(...) |
| Call Hierarchy | 0.6.0+ |
Right-click → "Find Callers" / "Find References" |
| Story Graph | command | Interactive Mermaid.js flowchart of all labels and jumps |
Senpy 0.6.0 introduces a plugin system — write your own lint rules as pip packages.
# 1. Create a plugin project (see example below)
cd examples/my_senpy_plugin
pip install -e .
# 2. Run Senpy — it discovers your plugin automatically
senpy /path/to/game
# First run: "Plugin 'my-senpy-plugin' wants to load. Trust and enable? [y/N]"This rule (from examples/my_senpy_plugin/) flags dialogue lines that are not wrapped in _() for translation:
from senpy.api import Diagnostic
from senpy.plugins import Rule, RuleContext, RuleMeta, register_rule
PLUGIN_API_VERSION = "1.0"
@register_rule("RPY901")
class HardcodedDialogueRule(Rule):
meta = RuleMeta(
code="RPY901",
docs="Detects dialogue lines without _() for translation",
severity="WARNING",
tags=["translations", "accessibility"],
)
def check(self, ctx: RuleContext) -> list[Diagnostic]:
diags = []
for line_no, raw in enumerate(ctx.lines, start=1):
stripped = raw.lstrip(" \t")
if not stripped or stripped[0] not in ('"', "'"):
continue
if stripped.startswith('_("') or stripped.startswith("_('"):
continue # already translated
diags.append(
Diagnostic(
file_path=ctx.file_path,
line_number=line_no,
line_content=raw.rstrip(),
code="RPY901",
message=f"Hardcoded dialogue without _() for translation",
severity="WARNING",
)
)
return diagsFull guide: Plugin Development README
# Analyze a Ren'Py game directory
senpy "path/to/game"
# Automatically apply safe fixes (unclosed tags, smart quotes, tab indents)
senpy "path/to/game" --fix
# Show what was fixed (detailed report)
senpy "path/to/game" --fix --show-fixes
# Calculate translation completion metrics
senpy "path/to/game" --coverage
# Generate project documentation (GDD / Wiki)
senpy "path/to/game" --export-wiki "PROJECT_WIKI.md"
# Generate Python type stubs (.pyi) for Pylance / MyPy
senpy "path/to/game" --stubs
# Inspect a Ren'Py runtime crash log
senpy --traceback "path/to/traceback.txt"
# One-click GitHub Actions workflow generator
senpy --init-ci
# Generate 'senpy_defaults.rpy' for missing default variables
senpy "path/to/game" --defaults
# Export story flow as Mermaid.js diagram
senpy "path/to/game" --graph-file "story_graph.md"
# Generate HTML report with interactive story graph
senpy "path/to/game" --export-html "report.html"
# Output in SARIF, JSON, JUnit XML, or GitHub Actions format
senpy "path/to/game" --output-format sarif --output-file senpy.sarif
senpy "path/to/game" --output-format json
senpy "path/to/game" --output-format junit
senpy "path/to/game" --output-format github
# List all available rules
senpy --rules
# Enable tsundere mode
senpy "path/to/game" --tsundereSenpy includes 45 specialized semantic rules across 8 categories:
| Series | Category | Description |
|---|---|---|
| RPY0xx | Indentation & Formatting | Tab characters (RPY001), smart quotes in code (RPY002). |
| RPY1xx | Variables, State & APIs | Missing [var] references, NameError on +=, define mutations, deprecated APIs (RPY107), type taints (RPY108), init load order (RPY109). |
| RPY2xx | Screens & UI | Predictive mutations (RPY201), dead buttons (RPY203), missing screens (RPY204), imperative loops (RPY205), parameter mismatches (RPY207). |
| RPY3xx | Dialogue & Story Flow | Unclosed tags (RPY301), broken jumps (RPY302), dead labels (RPY303), duplicate labels (RPY307), story islands (RPY309), call args (RPY310). |
| RPY4xx | Branching & Logic | Menu branch asymmetry (RPY401), empty choices (RPY402), contradictions (RPY403), MAYBE_SET reads (RPY404). |
| RPY5xx | Assets & Media | Missing audio (RPY501), unresolved sprites (RPY502), unused assets (RPY503), unregistered channels (RPY505). |
| RPY6xx | Translations | Variable mismatches (RPY601), tag mismatches (RPY602), empty strings (RPY603). |
| RPY7xx | Dead Code | Unused define (RPY701), unused transform (RPY702), unused screen (RPY703). |
Full descriptions, rationale, and examples: RULES.md
[tool.senpy]
decompile = true
max_errors = 50
ignore = [
"RPY104", # Dynamic assignments without default
"RPY303", # Unreferenced labels (dynamic routers)
]
[tool.senpy.severity]
RPY104 = "ERROR" # Elevate to build-breaking
RPY308 = "INFO" # Demote fallthrough to info"Warning: {b}unclosed style" # noqa
$ dynamic_var = 1 # noqa: RPY104For bulk rule exclusions, create a .senpy-ignore file in the project
root (next to senpy.toml). Each line is a rule code to suppress:
# .senpy-ignore — one rule code per line
RPY101 # typo in dialogue variable
RPY104
# RPY105 # commented out — rule will fire
RPY301
Lines starting with # are comments. Inline comments (after the rule
code) are allowed. Codes are case-insensitive (normalized to uppercase).
senpy --init-ci # generates .github/workflows/senpy.ymlOr manually create .github/workflows/senpy.yml:
name: Senpy Code Scanning
on:
pull_request:
branches: [ "main", "master" ]
jobs:
lint:
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install senpy-linter
- run: senpy game/ --output-format sarif --output-file senpy.sarif
continue-on-error: true
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: senpy.sarifSenpy ships a pre-commit hook that runs
senpy --check on every commit. Add this to your .pre-commit-config.yaml:
repos:
- repo: https://github.com/Xilleet/senpy
rev: v0.7.5 # use the latest tag
hooks:
- id: senpyThe hook is non-mutating — it only reports issues, never auto-fixes
them. This is deliberate: an auto-fixing hook combined with git's
autocrlf on Windows creates an infinite loop (the formatter
normalizes line endings, git converts them back, the formatter runs
again). Run senpy --fix manually if you want auto-fixes.
When analyzing game distributions or mods without source code, Senpy detects standalone .rpyc files:
- Copies
.rpycinto an isolated OS temporary directory. - Prompts to download
unrpycautomatically if not present. - Analyzes decompiled scripts entirely within the sandbox.
- Cleans up — your project directory stays unmodified.
Senpy catches runtime crashes before you launch the game. Here are common errors and the Senpy rules that prevent them:
| Runtime Error | Senpy Rule | What it catches |
|---|---|---|
NameError: name 'x' is not defined on load |
RPY104 |
$ x = 1 inside a label without a top-level default x = 0 — variable won't survive save/load |
NameError: name 'x' is not defined in if |
RPY103 |
if x: where x was never declared via default or define |
NameError: name 'x' is not defined after menu |
RPY401 |
Variable defined in one menu: branch but missing in another — crashes if player picks the wrong path |
NameError on game launch (init phase) |
RPY109 |
Function/class used in init python: before it's defined (alphabetical file load order violation) |
KeyError: 'player_name' in dialogue |
RPY101 |
Typo in [player_name] interpolation — variable doesn't exist |
KeyError in translation string |
RPY601 |
Translation new "..." has different [var] than original old "..." |
TypeError: unsupported operand type(s) for += |
RPY108 |
Type taint conflict (e.g., str += int) or invalid method like list.append() on a dict |
AttributeError: 'list' object has no attribute 'add' |
RPY108 |
Wrong method on collection type (e.g., my_list.add() instead of .append()) |
ScriptError: Label 'xxx' is not defined |
RPY302 |
jump xxx or call xxx to a label that doesn't exist |
SyntaxError: tab/indent mismatch |
RPY001 |
Tab characters in indentation (Ren'Py requires 4 spaces) |
| Game crashes after loading a save | RPY104 / RPY105 |
Variable created with $ instead of default, or define constant mutated |
| Screen UI freezes / FPS drops | RPY201 / RPY205 |
Direct state mutation inside screen or heavy for/while loops in screen language |
Tip: Run
senpy path/to/game --fixto auto-fix the safe ones (RPY001,RPY002,RPY301), or use QuickFix (Ctrl+.) directly in VS Code.
Contributions, bug reports, and rule suggestions are welcome.
- Fork the repository.
- Create a feature branch (
git checkout -b feature/NewRule). - Commit your changes (
git commit -m 'feat: add RPY901 check'). - Ensure test suite passes (
pytest). - Open a pull request.
Want to write a plugin instead of contributing to core? See the Plugin Development Guide.
Senpy is a passion project — I needed a semantic linter for my own Ren'Py visual novel, and none existed. So I built one.
How it's made: I read and review Python code fluently, but I don't enjoy typing boilerplate. So I let AI tools do the typing, while I do:
- Architecture (what rules to add, how the LSP server should work)
- Code review (every line — I catch bugs the AI misses)
- Testing on real Ren'Py projects
- Bug report triage and fixes
- Release management
The AI doesn't decide what to build, doesn't push code, doesn't choose features. I do. The AI is my fast typist.
If you're curious about this workflow, I'm happy to discuss in Discussions. If you find a bug, report it — I read every issue and fix what matters to users.
Also check out ScreenTale, another tool from me.
The name senpy is shared with several unrelated projects. If you landed here looking for one of those, here's a disambiguation:
| Project | What it is | Link |
|---|---|---|
| Senpy (this repo) | Semantic linter and static analyzer for Ren'Py visual novels | https://github.com/Xilleet/senpy |
pypi.org/project/senpy |
Sentiment analysis server (Marl/Onyx/NIF vocabularies) | https://pypi.org/project/senpy/ |
ACasey13/senpy |
Python package for Neyer sensitivity testing | https://github.com/ACasey13/senpy |
SCIA Engineer senpy |
Python framework to generate SCIA Engineer XML data | https://www.scia.net/en/support/addons/senpy-python-framework-generate-scia-engineer-data |
| Senpy: A framework for semantic sentiment and emotion | Academic paper, ScienceDirect | https://www.sciencedirect.com/science/article/abs/pii/S0950705119305313 |
This project (Xilleet/senpy) is the only one related to Ren'Py. The naming collision is unfortunate — if you came here from a search for sentiment analysis or sensitivity testing, please follow the corresponding link above.
Distributed under the MIT License. Free for commercial and non-commercial visual novel development.




