dead-poets 0.3.0

Find unused (dead) gettext PO/POT keys across a polyglot codebase (PHP, Twig, JS/TS) via real AST parsing.
Documentation

dead-poets

CI License: MIT OR Apache-2.0

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/.pot only — the reference side is polyglot, the catalog side is not. Reading JSON/YAML/.properties/ .arb catalogs (react-i18next, vue-i18n, Rails, Flutter ARB, Android/iOS) is a non-goal by design. Keys in en.json? Wrong tool.

Install

cargo build --release   # binary at target/release/dead-poets

Usage

dead-poets scan <path> -c dead-poets.toml [-f text|json] [-v] [--audit] \
  [--max-dead N | --max-dead-ratio R]
  • path — project root (default .).
  • -c, --config — config file (default dead-poets.toml).
  • -f, --formattext (colored) or json (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:

[output]
max_dead = 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 every cf_subtype_* alive; fragments shorter than [guard] min_len are ignored), or whitelist (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 (the sprintf-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

[scan]
po_patterns = ["**/*.po", "**/*.pot"]
source_extensions = ["php", "twig", "js", "ts", "jsx", "tsx"]
ignore_dirs = ["vendor", "node_modules", "cache", "var"]   # on top of .gitignore
source_roots = ["."]                                       # list → multi-repo

# One [[calls]] block per translation convention.
[[calls]]
lang = "php"
kind = "method"               # function | method | filter | index
name = "get"                  # call/filter name, or indexed object for `index`
receiver = ["i18n", "this.i18n"]   # method only: allowed receivers
key_arg_index = 0             # which arg holds the key (0-based)

[whitelist]
file = "i18n-whitelist.txt"   # one key per line, # comments allowed
keys = ["dynamic.key1"]       # or inline

[guard]
min_len = 3                   # shortest static fragment that forms a guard

[output]
format = "text"               # text | json (overridden by -f)
fail_on = "dead"              # never | dead | dead-or-blind
max_dead = 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_len moved from [output] to [guard] min_len — an old [output] min_guard_len is now rejected, not silently ignored.

Library use

dead_poets::run is the whole pipeline in one call — config path in, classification out:

use std::path::Path;
use dead_poets::run;

let outcome = run(Path::new("dead-poets.toml"), Path::new("."), false)?; // false = no audit

println!("{} dead of {} keys", outcome.report.dead_count(), outcome.report.verdicts.len());
for verdict in outcome.report.dead() {
    println!("dead: {}", verdict.key.msgid);
}

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):

[dependencies]
dead-poets = { version = "0.3", default-features = 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.