Due diligence, not a hunch

Prosecution. Devil's advocate. Then a verdict.

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.

1

Prosecution

The scan's graph evidence and confidence score. Offline. No model.

2

Devil's advocate

30+ named hypotheses checked against artifacts with file:line hits.

3

Identification

Are we pointing at the right thing? Generic names, barrels, parse failures.

4

Verdict

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.

Why this exists

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.

remove

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.

verify

One check settles it

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.

keep

A live path exists

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.

The fixture, judged

deadpath judge fixtures/deadapp prints this table (also the default MCP output for deadpath.judge, and what plugins render to the user):

#sevconfidencekindtargetjudgedevil's advocatenext checksecurityeffort
1block0.92orphan_filepkg/legacy_export.pyremovenone of 28 hypothesesOne grep, then propose the patch.remove_first: unsafe_deserialization L7trivial · ~2m · quick win
2block0.92orphan_filepkg/orphan.pyremovenone · flag: recently_added_in_gitOne grep, then propose the patch.—trivial · ~2m · quick win
3block0.88unused_exportpkg/exports.py :: dead_symbolremovenone · flag: recently_added_in_gitOne grep, then propose the patch.—trivial · ~2m · quick win
4warn0.58unused_exportpkg/hooks.py :: maybe_deadverifynone (scan already priced whole-module import)Read the definition and grep once.—trivial · ~2m
5note0.37 (scan 0.62)orphan_filepkg/nightly.pykeepscheduled_job @ ops/crontab:2Open 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.

Counter-hypotheses (the devil's advocate)

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.

HypothesisLooks inStrengthWhy it matters
explicit_keep_markerdeadpath: keep / deadpath-ignore on the targetstrongThe author already ruled. Honor it.
orchestration_workflowTemporal/Cadence, Prefect, Dagster, Airflow DAG idsstrongOrchestrators invoke by workflow/task id, not by import.
container_or_process_entryDockerfile CMD/ENTRYPOINT, Procfile, compose, k8s command, systemd/supervisorstrongThe process table is the real entry graph.
serverless_handlerserverless.yml, SAM, Azure function.json, vercel.json, wrangler.toml, Firebase, ZappastrongClouds invoke by handler string.
cli_entry_pointconsole_scripts, package bin, python -m in scripts/CIstrongTools are executed, not imported.
plugin_or_entry_point_grouppyproject entry-points, VS Code contributes, pytest11, ServiceLoaderstrongThe host discovers by name.
ci_or_build_invocationGitHub Actions, Makefile, tox/nox, package.json scripts, Gradle, RakefilestrongBuild and CI run code the graph never sees.
infrastructure_manifestTerraform, Helm, CloudFormation, Pulumi, AnsiblestrongDeploy-time packaging and invocation.
dynamic_loading_or_reflectionsource next to importlib / getattr / Class.forName / requiremediumString-named load is the classic false positive.
side_effect_importbare import 'x' / importlib.import_modulemediumThe module runs even if nothing is bound.
template_referenceJinja, ERB, Blade, Razor, XAML, HTML event handlersmediumTemplates call code by name at render time.
scheduler_registration_in_file@Scheduled, celery beat, APScheduler, Hangfire, node-cronmediumAutodiscovery loads the module without an importer.
event_or_signal_subscriberDjango receivers, Kafka/SQS listeners, socket.io, EventBusmediumRegistration at import time still needs a loader.
registry_pattern_in_file@Component, ServiceLoader, __init_subclass__, pluggymediumDI containers resolve by class, not import.
ide_or_runner_config.vscode/launch.json, .idea run configs, devcontainermediumDevelopers run this directly.
public_library_surfacepublished package manifests (not private: true)mediumExternal consumers the graph cannot see.
convention_dispatched_nameclean_*, getServerSideProps, *Command, lambda_handlermediumFrameworks look the name up by convention.
feature_flag_or_env_gateLaunchDarkly, Unleash, FEATURE_*, os.environmediumDormant is not dead.
platform_or_build_conditional#[cfg], //go:build, sys.platform, _windows.gomediumThe other OS still ships.
migration_or_data_scriptalembic, db/migrate, flyway, seedsmediumTooling loads by directory scan. Never delete applied migrations.
generated_codeDO NOT EDIT, _pb2.py, .g.dartmediumDelete the generator input, not the output.
type_or_contract_definitionProtocol, interface, trait, enum, TypedDictmediumStructural / type-only use is invisible to the graph.
barrel_star_reexportexport * from / from x import *mediumNamed-use tracking is incomplete.
executable_script_shebang#!/usr/bin/env pythonweakMeant to be executed, not imported.
parses_command_line_argumentsargparse, click, cobra, clapweakLikely a tool run by hand or automation.
deprecation_window@deprecated, Obsolete, “remove in v3”weakKept on purpose until the window closes.
test_only_referencetests/ onlyweakNo production caller; decide with the owner.
work_in_progress_markerWIP, TODO: wire, placeholderinfoNot-yet-wired ≠ abandoned. No penalty; a flag.
recently_added_in_gitgit log --diff-filter=A --since=14.daysinfoMay simply not be wired up yet.
documentation_referenceREADME, docs, notebooksinfoUpdate docs in the same patch.

Identification check

A finding can be “correctly unused” of the wrong thing. Before a remove verdict, the judge asks whether the identifier is trustworthy.

CheckWhen it firesEffect
generic_nameSymbol/stem is ≤ 3 characters or in a stop-list (util, index, helper…)Token/grep evidence is unreliable; penalty 0.08–0.15.
stem_collisionTwo source files share the same stemA crontab naming worker may mean a different file.
star_reexports_presentThe repo uses export * / import *Named-use tracking is incomplete.
graph_incomplete_parse_failuresSource files failed to parseTheir imports are missing from the graph.
unresolved_workspace_importsJS/TS monorepo@scope/pkg imports are not resolved to files.
name_precision_graphJava, C#, Swift, …Info only; confidence is already capped below block.

Corner cases, due diligence

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 caseHypothesisSafe action
Temporal / Prefect / Dagster / Airflow DAGorchestration_workflowIf the workflow is still registered, remember keep. If it is retired, remove the registration and the code together.
Dockerfile CMD, Procfile, systemd unitcontainer_or_process_entryConfirm the process is still deployed.
Lambda / Cloud Functions / Azure Functions handler stringserverless_handlerOpen the manifest; platforms invoke by string.
python -m pkg.job in a Makefile or workflowcli_entry_point / ci_or_build_invocationCheck whether that step still runs in CI or on a box.
Feature flag off in this environmentfeature_flag_or_env_gateDormant ≠ dead. Ask whether the flag can still be turned on.
sys.platform == 'win32' file on a Linux CI runnerplatform_or_build_conditionalConfirm the other platform is still shipped.
Protobuf / ORM / Flutter generated filegenerated_codeRemove the generator input; the next generate undoes an output delete.
Applied Alembic / Rails / Flyway migrationmigration_or_data_scriptNever delete. History is load-bearing.
Published library export with no in-repo callerpublic_library_surfaceDeprecate first; the graph cannot see PyPI/npm consumers.
getServerSideProps / *Command / clean_<field>convention_dispatched_nameFrameworks look these up by name.
export * from './lib'barrel_star_reexportGrep the barrel's consumers, not the defining file.
WIP / “not yet wired” / added this weekinfo flagsAsk the author. No confidence penalty; do not auto-promote to remove.
# deadpath: keep on the symbolexplicit_keep_markerHonor it; remember keep.

Security lens

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 = "…").

MarkerMeaningPriority
code_executioneval / exec / os.system / child_processreview / remove_first if verdict is remove
unsafe_deserializationpickle.loads, yaml.load, ObjectInputStream, unserializeremove_first
tls_or_verification_disabledverify=False, InsecureSkipVerify, NODE_TLS_REJECT_UNAUTHORIZEDremove_first
network_exposureroute / controller / listen( in unused coderemove_first — dead endpoint
secret_like_literalAPI-key / PEM / JWT shape (value never shown)remove_first
debug_or_permissive_configDEBUG=True, CORS *, AllowAny, chmod 777review
sql_string_building / dangerous_html / memory_unsafe / path_or_file_exposureclassic injection / XSS / memory / path bugsreview
supply_chain_surfaceunused dependency still in the lockfile/SBOMreview — 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.

Developer velocity

Due diligence that costs an hour per finding will not be used. The judge is built so the cheap cases stay cheap:

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.

CLI and MCP

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.

What this layer will not do