dead-poets
A playful nod to Dead Poets Society — here dead means unused: msgids in your
gettext .po/.pot catalogs that no source references. Polyglot — PHP, Twig,
JS/TS via tree-sitter AST (regex for Twig). Project specifics live in config, so a
new repo is a config change, not a code change.
Scope: the key universe is gettext
.po/.potonly — the reference side is polyglot, the catalog side is not. Reading JSON/YAML/.properties/.arbcatalogs (react-i18next, vue-i18n, Rails, Flutter ARB, Android/iOS) is a non-goal by design. Keys inen.json? Wrong tool.
Install
Usage
path— project root (default.).-c, --config— config file (defaultdead-poets.toml).-f, --format—text(colored) orjson(CI).-v, --verbose— repeatable (-v,-vv, ...).--audit— trust score for the Dead bucket (see below); advisory.--max-dead N/--max-dead-ratio R— dead-key budget; override config, mutually exclusive.
Exit codes: 0 within budget · 1 over budget (CI fails) · 2 operational
error. Gate: [output] fail_on = never | dead | dead-or-blind (default dead).
Dead-key budget (ratchet)
Catalogs start with thousands of dead keys, so failing on any is unusable. Set a debt ceiling and lower it as you delete:
[]
= 2900 # fail when dead > 2900
# max_dead_ratio = 0.15 # …or dead / total > 15% (mutually exclusive)
Default (max_dead = 0) fails on any dead key. The budget relaxes only the Dead
gate (dead-or-blind still fails on blind sites) and prints a budget line — a
budget object in JSON — with remaining headroom.
Classification
Universe = union of msgids across all catalogs (obsolete #~ / fuzzy skipped).
Each key is:
- Alive — referenced, with
alive_via:literal(i18n('save_button')),guard(a static fragment of a dynamic key —i18n(`cf_subtype_${x}`)keeps everycf_subtype_*alive; fragments shorter than[guard] min_lenare ignored), orwhitelist(runtime-only keys). - Suspect — no modeled call, but the msgid appears verbatim as a source literal (data table / enum dispatched dynamically). Review hint; exit-neutral.
- Dead — no reference of any kind.
The tool biases toward keep: a false dead ships a raw key to prod, a missed
dead key costs nothing. A call with no static part (i18n($x)) is a blind
spot — counted per language, always reported.
Auditing the Dead bucket
--audit greps each dead msgid against raw source (comments, HTML, heredocs,
substrings — not just AST literals) and tiers it:
- substring — the full msgid occurs verbatim.
- skeleton — placeholder keys (
%s/%d/{0}) whose static fragments all appear (thesprintf-assembled tell). - none — no trace: high-confidence dead.
Advisory and case-sensitive: never reclassifies or changes the exit code, runs only
with --audit (zero default overhead). JSON gains an audit object.
Configuration
[]
= ["**/*.po", "**/*.pot"]
= ["php", "twig", "js", "ts", "jsx", "tsx"]
= ["vendor", "node_modules", "cache", "var"] # on top of .gitignore
= ["."] # list → multi-repo
# One [[calls]] block per translation convention.
[[]]
= "php"
= "method" # function | method | filter | index
= "get" # call/filter name, or indexed object for `index`
= ["i18n", "this.i18n"] # method only: allowed receivers
= 0 # which arg holds the key (0-based)
[]
= "i18n-whitelist.txt" # one key per line, # comments allowed
= ["dynamic.key1"] # or inline
[]
= 3 # shortest static fragment that forms a guard
[]
= "text" # text | json (overridden by -f)
= "dead" # never | dead | dead-or-blind
= 2900 # optional budget (absolute)
# max_dead_ratio = 0.15 # …or a share of the universe
Call kinds: function (i18n('key')) · method with receiver
($i18n->get('key'); $/->/. normalized away) · filter ({{ 'key'|i18n }})
· index (locale['key']).
Migrating from ≤ 0.2:
min_guard_lenmoved from[output]to[guard] min_len— an old[output] min_guard_lenis now rejected, not silently ignored.
Library use
dead_poets::run is the whole pipeline in one call — config path in,
classification out:
use Path;
use run;
let outcome = run?; // false = no audit
println!;
for verdict in outcome.report.dead
Outcome carries the loaded config, the report (verdicts + blind summary), and
an optional audit; the budget (budget::resolve + DeadBudget::is_exceeded) and
rendering stay caller-side. Disable the default cli feature to drop the terminal
deps (clap / colored / env_logger):
[]
= { = "0.3", = false } # classifier only
Scope & safety
Keys are Dead only relative to the scanned roots. External consumers —
databases, other repos sharing the catalog, email/cron templates — are invisible,
so a Dead key may still be used. Never auto-delete: verify each key in your
TMS first.
License
Apache-2.0 or MIT, at your option.