Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ __pycache__/
.pytest_cache/
.coverage
.ruff_cache/
.skylos/
.uv-cache/
.uv-tools/
.typos-oxendict-base.json
Expand Down
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,15 @@
- **Testing:** Passes all relevant unit and behavioural tests (`make test`).
- **Linting:** Passes the complete `make lint` pipeline: Hecate, Ruff, the
built-in Pylint rules under managed PyPy, `df12-python-lints` under CPython
3.14, and `ambrleaks` snapshot scanning.
3.14, `ambrleaks` snapshot scanning, and the blocking Skylos dead-code
scan. Investigate every finding and remove genuine dead code. After
verifying a false positive, prefer a precise, typed entry-point rule in
`[tool.skylos.dead_code]` with its fully qualified symbol and a reason that
names the verified runtime caller. Match methods as `type = "method"`,
rather than `"function"`. Use the named allow-list target only when an
entry-point rule cannot describe the boundary: run
`make skylos-allow NAME=handler REASON="Loaded by plugin registry"`
with the verified caller in the reason.
Comment thread
leynos marked this conversation as resolved.
- **Formatting:** Adheres to formatting standards (`make check-fmt`; use
`make fmt` to apply fixes).
- **Typechecking:** Passes type checking (`make typecheck`).
Expand Down
17 changes: 16 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,14 @@ DF12_FUTURE_ANNOTATIONS = $(DF12_PYLINT_BASE) --enable=C9112 \
--ignore-paths='^tests/steps/test_.*_steps[.]py$$'
AMBRLEAKS = $(UV_ENV) $(UV) tool run --python $(DF12_PYTHON) \
--from '$(DF12_PYTHON_LINTS)' ambrleaks
SKYLOS_VERSION = 4.33.2
SKYLOS = $(UV_ENV) $(UV) tool run --from 'skylos==$(SKYLOS_VERSION)' skylos \
--config-file pyproject.toml
SKYLOS_PRODUCTION_TARGETS ?= alembic episodic openai_test_types.py

.PHONY: help all clean build build-release lint fmt check-fmt \
markdownlint nixie spelling spelling-helper-test test typecheck \
crosshair check-migrations \
crosshair check-migrations skylos-allow validate \
local-k8s-up local-k8s-down local-k8s-status local-k8s-logs \
$(TOOLS) $(VENV_TOOLS)

Expand Down Expand Up @@ -96,12 +100,23 @@ check-fmt: build ## Verify formatting
$(UV_ENV) $(UV) run ruff format --check
# mdformat-all doesn't currently do checking

validate: ## Validate the Makefile
mbake validate Makefile

lint: check-architecture ## Run linters
$(UV_ENV) $(UV) run ruff check
$(PYLINT) $(PYLINT_TARGETS)
$(DF12_PYLINT) $(PYLINT_TARGETS)
$(DF12_FUTURE_ANNOTATIONS) $(PYLINT_TARGETS)
$(AMBRLEAKS) tests
$(SKYLOS) $(SKYLOS_PRODUCTION_TARGETS) --category dead_code --gate --format concise --no-upload --no-provenance --no-grep-verify

skylos-allow: export SKYLOS_NAME = $(value NAME)
skylos-allow: export SKYLOS_REASON = $(value REASON)
skylos-allow: ## Document one named Skylos exception, not an entry point
@test -n "$${SKYLOS_NAME}" || { printf "Error: NAME is required for a named whitelist exception\\n" >&2; exit 2; }
@test -n "$${SKYLOS_REASON}" || { printf "Error: REASON is required for a named whitelist exception\\n" >&2; exit 2; }
$(SKYLOS) whitelist "$${SKYLOS_NAME}" --reason "$${SKYLOS_REASON}"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

check-architecture: build ## Check hexagonal architecture import boundaries
$(UV_ENV) $(UV) run hecate check
Expand Down
1 change: 1 addition & 0 deletions benchmarks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Reproducible evaluation artefacts for Episodic development tools."""
46 changes: 46 additions & 0 deletions benchmarks/dead_code/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Dead-code detector benchmark

This directory contains the reusable, tool-neutral corpus and normalizer for
the pyscn and Skylos comparison. It is development evidence, not part of the
Episodic application or its test fixtures.

`corpus/` is a deliberately small Python project. Its own `pyproject.toml`
bounds project-root discovery without configuring either detector. The source
contains intentional unused and unreachable code, so each corpus module uses a
file-level Ruff suppression to keep repository lint separate from benchmark
ground truth.

`expectations.json` is the oracle. Each entry labels one source location before
scanner output is considered, assigns it to either the `unused-symbol` or
`unreachable-statement` lane, and explains why Python semantics make it live or
dead. Add a new label only when its liveness can be decided without trusting a
scanner. Do not change a label merely to make a detector result pass.

The `score.py` module is intentionally specific to the two released JSON
schemas captured by this comparison. Reuse it for reruns of this corpus; add a
separate parser when evaluating a different detector rather than disguising
schema differences inside an existing parser.

`results/` retains the tool output, wall-clock metadata, normalized scores, and
repository-scan adjudication from 2026-07-27. The large repository reports are
compressed with deterministic gzip metadata; their SHA-256 digests are in
`production-adjudication.json`. Absolute checkout prefixes in the Skylos report
were replaced by `./` before compression, so the retained evidence does not
depend on one workstation path. Finding content was otherwise unchanged.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Run the corpus commands from `benchmarks/dead_code/corpus/`:

```bash
uvx pyscn@1.28.0 analyze --select deadcode --min-severity info --json .
uvx skylos@4.30.0 . --no-upload --no-provenance --confidence 0 --no-grep-verify --format json
```

Run the practical comparison from the repository root:

```bash
uvx pyscn@1.28.0 analyze --select deadcode --min-severity info --json episodic
uvx skylos@4.30.0 episodic --no-upload --no-provenance --confidence 0 --no-grep-verify --format json
```

The elapsed times are single wall-clock observations, not performance
benchmarks. They are retained to expose order-of-magnitude differences only.
1 change: 1 addition & 0 deletions benchmarks/dead_code/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Dead-code detector comparison corpus and scoring support."""
5 changes: 5 additions & 0 deletions benchmarks/dead_code/corpus/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Public surface for the dead-code detector corpus."""

from .symbols import exported_function

__all__ = ["exported_function"]
91 changes: 91 additions & 0 deletions benchmarks/dead_code/corpus/dynamic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Benchmark source locations are intentionally stable.
"""Dynamic and registered live symbols for false-positive controls."""

import typing as typ


class RegisteredFunction(typ.Protocol):
"""Describe a zero-argument callable that can enter the registry.

Attributes
----------
__name__ : str
Name used as the registry key.
"""

__name__: str

def __call__(self) -> int:
"""Invoke the registered callable.

Returns
-------
int
Result produced by the callable.
"""
...


REGISTRY: dict[str, RegisteredFunction] = {}


def register(function: RegisteredFunction) -> RegisteredFunction:
"""Store a callable under its declared name.

Parameters
----------
function : RegisteredFunction
Callable to add to the registry.

Returns
-------
RegisteredFunction
The same callable passed in ``function``.
"""
REGISTRY[function.__name__] = function
return function


@register
def registered_plugin() -> int:
"""Return the result exposed by the registered plugin.

Returns
-------
int
Constant plugin result.
"""
return 29


class DynamicHandler:
"""Provide a method resolved through dynamic attribute lookup."""

def invoked_by_name(self) -> int: # noqa: PLR6301 - dynamic getattr requires an instance method.
"""Return the result of the dynamically selected method.

Returns
-------
int
Constant handler result.
"""
return 31


class CallableHandler:
"""Provide a callable object for invocation-based discovery."""

def __call__(self) -> int:
"""Return the result produced when the handler is called.

Returns
-------
int
Constant handler result.
"""
return 37


DYNAMIC_RESULT = getattr(DynamicHandler(), "invoked_by_name")() # noqa: B009
REGISTERED_RESULT = REGISTRY["registered_plugin"]()
CALLABLE_RESULT = CallableHandler()()
109 changes: 109 additions & 0 deletions benchmarks/dead_code/corpus/flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Benchmark source locations are intentionally stable.
"""Reachable and unreachable statements for control-flow comparison."""

EXPECTED_BENCHMARK_EXCEPTION_MESSAGE = "expected benchmark exception"


def after_return() -> int: # noqa: RET503 - preserve the required return-unreachability fixture.
"""Return the fixed benchmark value.

Returns
-------
int
The fixed benchmark value.
"""
return 41
unreachable_after_return = 43 # noqa: F841 - retain the return-unreachability fixture.


def after_raise() -> None:
"""Raise the expected benchmark exception.

Raises
------
ValueError
Always, as required to exercise the unreachable assignment.
"""
raise ValueError(EXPECTED_BENCHMARK_EXCEPTION_MESSAGE)
unreachable_after_raise = 47 # noqa: F841 - retain the raise-unreachability fixture.


def after_continue(values: tuple[int, ...]) -> int:
"""Return the number of values after skipping each loop body.

Parameters
----------
values : tuple[int, ...]
Values iterated only to exercise an unreachable assignment.

Returns
-------
int
The number of supplied values.
"""
for value in values:
continue
unreachable_after_continue = value # noqa: F841 - retain the continue-unreachability fixture.
return len(values)


def after_break(values: tuple[int, ...]) -> int:
"""Return the number of values after breaking the loop.

Parameters
----------
values : tuple[int, ...]
Values iterated only to exercise an unreachable assignment.

Returns
-------
int
The number of supplied values.
"""
for value in values:
break
unreachable_after_break = value # noqa: F841 - retain the break-unreachability fixture.
return len(values)


def constant_false_branch() -> int:
"""Return the fixed value after an unreachable branch.

Returns
-------
int
The fixed benchmark value.
"""
if False:
unreachable_false_branch = 53 # noqa: F841 - retain the false-branch fixture.
return 59


def conditional_return(flag: bool) -> int: # noqa: FBT001 - boolean selects the conditional-return fixture.
"""Return the fixed value after the conditional branch.

Parameters
----------
flag : bool
Whether to take the early return branch.

Returns
-------
int
The fixed benchmark value.
"""
if flag:
return 61
reachable_after_conditional = 67
return reachable_after_conditional # noqa: RET504 - keep the explicit reachable fall-through fixture.


RETURN_RESULT = after_return()
try: # noqa: SIM105
after_raise()
except ValueError:
pass
CONTINUE_RESULT = after_continue((1, 2))
BREAK_RESULT = after_break((3, 4))
FALSE_RESULT = constant_false_branch()
CONDITIONAL_RESULT = conditional_return(False) # noqa: FBT003
4 changes: 4 additions & 0 deletions benchmarks/dead_code/corpus/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[project]
name = "episodic-dead-code-corpus"
version = "0.0.0"
requires-python = ">=3.11"
Loading
Loading