Propose the patch
None of the counter-hypotheses held and judged confidence is still ≥ 0.85. One grep, then validate. Trivial cases are tagged quick win.
The scanner says “nothing imports this.” That is the prosecution. Before an agent proposes a deletion, a second deterministic layer plays devil's advocate: it checks named counter-hypotheses against real repository artifacts — crontab, Dockerfile, serverless handlers, CI scripts, reflection, feature flags — and returns file:line evidence. The judge combines both sides into remove, verify, or keep, plus the one next check that settles it.
The scan's graph evidence and confidence score. Offline. No model.
30+ named hypotheses checked against artifacts with file:line hits.
Are we pointing at the right thing? Generic names, barrels, parse failures.
remove / verify / keep, adjusted confidence, security, effort.
The judge itself is deterministic — no LLM. Optional counsel is a second devil's advocate over the judge's packet: it may confirm, escalate, or overturn a remove, and it cannot strengthen a verify. Still never file bodies, still once, still cached.
The most expensive false positive in dead-code cleanup is not a FastAPI route (framework detection already handles that). It is a hidden live path: a nightly job, a Lambda handler string, a Makefile target, a feature flag that is off today. Agents that delete those “unused” files are fast and wrong. The judge is the due-diligence layer that keeps developer velocity high without trading away safety.
None of the counter-hypotheses held and judged confidence is still ≥ 0.85. One grep, then validate. Trivial cases are tagged quick win.
Medium objections, identification caveats, or confidence in the warn band. The next_check names the exact file:line to open. Do not crawl the repo.
A strong objection (scheduler, container, serverless, CLI, plugin, CI) produced evidence. Record deadpath.remember keep if you agree. The finding stays visible so the evidence is reviewable.
deadpath judge fixtures/deadapp prints this table (also the default MCP output for deadpath.judge, and what plugins render to the user):
| # | sev | confidence | kind | target | judge | devil's advocate | next check | security | effort |
|---|---|---|---|---|---|---|---|---|---|
| 1 | block | 0.92 | orphan_file | pkg/legacy_export.py | remove | none of 28 hypotheses | One grep, then propose the patch. | remove_first: unsafe_deserialization L7 | trivial · ~2m · quick win |
| 2 | block | 0.92 | orphan_file | pkg/orphan.py | remove | none · flag: recently_added_in_git | One grep, then propose the patch. | — | trivial · ~2m · quick win |
| 3 | block | 0.88 | unused_export | pkg/exports.py :: dead_symbol | remove | none · flag: recently_added_in_git | One grep, then propose the patch. | — | trivial · ~2m · quick win |
| 4 | warn | 0.58 | unused_export | pkg/hooks.py :: maybe_dead | verify | none (scan already priced whole-module import) | Read the definition and grep once. | — | trivial · ~2m |
| 5 | note | 0.37 (scan 0.62) | orphan_file | pkg/nightly.py | keep | scheduled_job @ ops/crontab:2 | Open the crontab. If the schedule is live, remember keep. | — | trivial · ~5m |
Nightly job. Nothing imports pkg/nightly.py. A naive scanner would call it dead. The devil's advocate found 0 2 * * * python -m pkg.nightly in ops/crontab:2, dropped confidence from 0.62 to 0.37, and returned keep with the exact next check. That is the scheduler-job case this layer exists for.
Security first. pkg/legacy_export.py is unused and calls pickle.loads. The judge never prints the secret or the payload — only the marker name and line number — and tags it remove_first. Dead risky code is unmonitored attack surface; it goes to the top of the workflow.
Each hypothesis has a strength, a confidence penalty, and a next-check template. Evidence is redacted and truncated. Overlap with scan signals (a name already seen in config) halves the judge's own penalty so the same fact is not priced twice.
| Hypothesis | Looks in | Strength | Why it matters |
|---|---|---|---|
explicit_keep_marker | deadpath: keep / deadpath-ignore on the target | strong | The author already ruled. Honor it. |
orchestration_workflow | Temporal/Cadence, Prefect, Dagster, Airflow DAG ids | strong | Orchestrators invoke by workflow/task id, not by import. |
container_or_process_entry | Dockerfile CMD/ENTRYPOINT, Procfile, compose, k8s command, systemd/supervisor | strong | The process table is the real entry graph. |
serverless_handler | serverless.yml, SAM, Azure function.json, vercel.json, wrangler.toml, Firebase, Zappa | strong | Clouds invoke by handler string. |
cli_entry_point | console_scripts, package bin, python -m in scripts/CI | strong | Tools are executed, not imported. |
plugin_or_entry_point_group | pyproject entry-points, VS Code contributes, pytest11, ServiceLoader | strong | The host discovers by name. |
ci_or_build_invocation | GitHub Actions, Makefile, tox/nox, package.json scripts, Gradle, Rakefile | strong | Build and CI run code the graph never sees. |
infrastructure_manifest | Terraform, Helm, CloudFormation, Pulumi, Ansible | strong | Deploy-time packaging and invocation. |
dynamic_loading_or_reflection | source next to importlib / getattr / Class.forName / require | medium | String-named load is the classic false positive. |
side_effect_import | bare import 'x' / importlib.import_module | medium | The module runs even if nothing is bound. |
template_reference | Jinja, ERB, Blade, Razor, XAML, HTML event handlers | medium | Templates call code by name at render time. |
scheduler_registration_in_file | @Scheduled, celery beat, APScheduler, Hangfire, node-cron | medium | Autodiscovery loads the module without an importer. |
event_or_signal_subscriber | Django receivers, Kafka/SQS listeners, socket.io, EventBus | medium | Registration at import time still needs a loader. |
registry_pattern_in_file | @Component, ServiceLoader, __init_subclass__, pluggy | medium | DI containers resolve by class, not import. |
ide_or_runner_config | .vscode/launch.json, .idea run configs, devcontainer | medium | Developers run this directly. |
public_library_surface | published package manifests (not private: true) | medium | External consumers the graph cannot see. |
convention_dispatched_name | clean_*, getServerSideProps, *Command, lambda_handler | medium | Frameworks look the name up by convention. |
feature_flag_or_env_gate | LaunchDarkly, Unleash, FEATURE_*, os.environ | medium | Dormant is not dead. |
platform_or_build_conditional | #[cfg], //go:build, sys.platform, _windows.go | medium | The other OS still ships. |
migration_or_data_script | alembic, db/migrate, flyway, seeds | medium | Tooling loads by directory scan. Never delete applied migrations. |
generated_code | DO NOT EDIT, _pb2.py, .g.dart | medium | Delete the generator input, not the output. |
type_or_contract_definition | Protocol, interface, trait, enum, TypedDict | medium | Structural / type-only use is invisible to the graph. |
barrel_star_reexport | export * from / from x import * | medium | Named-use tracking is incomplete. |
executable_script_shebang | #!/usr/bin/env python | weak | Meant to be executed, not imported. |
parses_command_line_arguments | argparse, click, cobra, clap | weak | Likely a tool run by hand or automation. |
deprecation_window | @deprecated, Obsolete, “remove in v3” | weak | Kept on purpose until the window closes. |
test_only_reference | tests/ only | weak | No production caller; decide with the owner. |
work_in_progress_marker | WIP, TODO: wire, placeholder | info | Not-yet-wired ≠ abandoned. No penalty; a flag. |
recently_added_in_git | git log --diff-filter=A --since=14.days | info | May simply not be wired up yet. |
documentation_reference | README, docs, notebooks | info | Update docs in the same patch. |
A finding can be “correctly unused” of the wrong thing. Before a remove verdict, the judge asks whether the identifier is trustworthy.
| Check | When it fires | Effect |
|---|---|---|
generic_name | Symbol/stem is ≤ 3 characters or in a stop-list (util, index, helper…) | Token/grep evidence is unreliable; penalty 0.08–0.15. |
stem_collision | Two source files share the same stem | A crontab naming worker may mean a different file. |
star_reexports_present | The repo uses export * / import * | Named-use tracking is incomplete. |
graph_incomplete_parse_failures | Source files failed to parse | Their imports are missing from the graph. |
unresolved_workspace_imports | JS/TS monorepo | @scope/pkg imports are not resolved to files. |
name_precision_graph | Java, C#, Swift, … | Info only; confidence is already capped below block. |
These are the cases that produce the worst incidents when a scanner (or an agent) is believed blindly. Each has a named hypothesis and a required human check — never an automatic deletion.
| Corner case | Hypothesis | Safe action |
|---|---|---|
| Temporal / Prefect / Dagster / Airflow DAG | orchestration_workflow | If the workflow is still registered, remember keep. If it is retired, remove the registration and the code together. |
Dockerfile CMD, Procfile, systemd unit | container_or_process_entry | Confirm the process is still deployed. |
| Lambda / Cloud Functions / Azure Functions handler string | serverless_handler | Open the manifest; platforms invoke by string. |
python -m pkg.job in a Makefile or workflow | cli_entry_point / ci_or_build_invocation | Check whether that step still runs in CI or on a box. |
| Feature flag off in this environment | feature_flag_or_env_gate | Dormant ≠ dead. Ask whether the flag can still be turned on. |
sys.platform == 'win32' file on a Linux CI runner | platform_or_build_conditional | Confirm the other platform is still shipped. |
| Protobuf / ORM / Flutter generated file | generated_code | Remove the generator input; the next generate undoes an output delete. |
| Applied Alembic / Rails / Flyway migration | migration_or_data_script | Never delete. History is load-bearing. |
| Published library export with no in-repo caller | public_library_surface | Deprecate first; the graph cannot see PyPI/npm consumers. |
getServerSideProps / *Command / clean_<field> | convention_dispatched_name | Frameworks look these up by name. |
export * from './lib' | barrel_star_reexport | Grep the barrel's consumers, not the defining file. |
| WIP / “not yet wired” / added this week | info flags | Ask the author. No confidence penalty; do not auto-promote to remove. |
# deadpath: keep on the symbol | explicit_keep_marker | Honor it; remember keep. |
Dead code is not just clutter. Unused endpoints, leftover eval, pickle.loads, verify=False, and secret-like literals are unmonitored attack surface — they are not in the live path, so they are not in the on-call dashboard either. The judge scans the finding's definition (or the whole orphan file) for markers and reports line numbers only. Values are redacted by the same patterns used everywhere else (sk-, AKIA, ghp_, PEM keys, JWT-shaped tokens, password = "…").
| Marker | Meaning | Priority |
|---|---|---|
code_execution | eval / exec / os.system / child_process | review / remove_first if verdict is remove |
unsafe_deserialization | pickle.loads, yaml.load, ObjectInputStream, unserialize | remove_first |
tls_or_verification_disabled | verify=False, InsecureSkipVerify, NODE_TLS_REJECT_UNAUTHORIZED | remove_first |
network_exposure | route / controller / listen( in unused code | remove_first — dead endpoint |
secret_like_literal | API-key / PEM / JWT shape (value never shown) | remove_first |
debug_or_permissive_config | DEBUG=True, CORS *, AllowAny, chmod 777 | review |
sql_string_building / dangerous_html / memory_unsafe / path_or_file_exposure | classic injection / XSS / memory / path bugs | review |
supply_chain_surface | unused dependency still in the lockfile/SBOM | review — inherits its CVEs |
Workflows surface security_first findings before anything else. That is the secure-code mechanism: the cheapest security win in a cleanup backlog is deleting code that is both unused and risky, after validation, with explicit approval.
Due diligence that costs an hour per finding will not be used. The judge is built so the cheap cases stay cheap:
remove + trivial size (≤ 60 lines, ≤ 1 objection) get a ~2 minute estimate. Agents are told to take those first.keep are listed once in the workflow and omitted from verify/edit. Re-arguing them is the most common token sink.deadpath.judge (or deadpath.scan with format: "table") and show the table as-is. No prose rewrite, no second crawl.remove verdict is permission to propose a patch after validation, not to write the filesystem.Effort is trivial / small / large from line count of the target plus the number of sustained objections. It is a planning hint, not a promise.
deadpath judge [PATH] [--format table|md|json] [--no-memory] [--no-judge]
deadpath scan [PATH] [--format table|md|json|sarif]
deadpath plan [PATH] [--format table|md|json]
deadpath workflow [PATH] [--format table|md|json]
table is a markdown table: sev · confidence · kind · target · judge · devil's advocate · next check · security · effort. MCP deadpath.judge defaults to that table. md adds the case-file write-up (prosecution, defense, identification, security, next check) under the table. JSON is the machine envelope. Scan still defaults to markdown for humans and JSON for MCP; pass format: "table" when the agent is presenting to a user.
Pass --no-judge (or MCP judge: false) to see raw scan confidence. The threshold --min-confidence applies to the scan score so a keep verdict cannot hide the finding — the evidence is the point.
warn findings only.verify/keep.