blockpath 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blockpath-0.1.0/.github/workflows/ci.yml +43 -0
- blockpath-0.1.0/.github/workflows/release.yml +47 -0
- blockpath-0.1.0/.gitignore +38 -0
- blockpath-0.1.0/.pre-commit-config.yaml +25 -0
- blockpath-0.1.0/CHANGELOG.md +35 -0
- blockpath-0.1.0/LICENSE +21 -0
- blockpath-0.1.0/Makefile +36 -0
- blockpath-0.1.0/PKG-INFO +180 -0
- blockpath-0.1.0/README.md +154 -0
- blockpath-0.1.0/WORKLOG.md +65 -0
- blockpath-0.1.0/benchmark/PREREGISTRATION.md +277 -0
- blockpath-0.1.0/benchmark/adjudication.md +195 -0
- blockpath-0.1.0/benchmark/adjudication_raw.json +2181 -0
- blockpath-0.1.0/benchmark/corpus.tsv +58 -0
- blockpath-0.1.0/benchmark/corpus_rejected.tsv +57 -0
- blockpath-0.1.0/benchmark/depth_histogram.md +158 -0
- blockpath-0.1.0/benchmark/findings.json +2888 -0
- blockpath-0.1.0/benchmark/m0_adjudication.md +252 -0
- blockpath-0.1.0/benchmark/m0_adjudication_raw.json +903 -0
- blockpath-0.1.0/benchmark/raw_repos.json +1860 -0
- blockpath-0.1.0/benchmark/raw_sites.jsonl +158 -0
- blockpath-0.1.0/benchmark/ruff_crosscheck.json +27 -0
- blockpath-0.1.0/benchmark/runtime_demo/app/__init__.py +0 -0
- blockpath-0.1.0/benchmark/runtime_demo/app/clients.py +11 -0
- blockpath-0.1.0/benchmark/runtime_demo/app/handlers.py +20 -0
- blockpath-0.1.0/benchmark/runtime_demo/app/services.py +10 -0
- blockpath-0.1.0/benchmark/runtime_demo/test_app.py +16 -0
- blockpath-0.1.0/docs/adr/0001-python-312-only.md +40 -0
- blockpath-0.1.0/docs/adr/0002-three-bfs-not-tarjan.md +47 -0
- blockpath-0.1.0/docs/adr/0003-attribute-calls-not-resolved.md +40 -0
- blockpath-0.1.0/docs/adr/0004-get-running-loop-predicate.md +42 -0
- blockpath-0.1.0/docs/adr/0005-pathspec-for-gitignore.md +46 -0
- blockpath-0.1.0/docs/adr/0006-reexport-and-star-import-policy.md +52 -0
- blockpath-0.1.0/docs/adr/0007-call-edges-only-ref-not-traversed.md +58 -0
- blockpath-0.1.0/docs/adr/0008-config-in-pyproject.md +45 -0
- blockpath-0.1.0/docs/adr/0009-watchdog-thresholds.md +40 -0
- blockpath-0.1.0/docs/demo.cast +4 -0
- blockpath-0.1.0/docs/demo.svg +1 -0
- blockpath-0.1.0/docs/golden_cases.md +86 -0
- blockpath-0.1.0/docs/interview-questions.md +93 -0
- blockpath-0.1.0/docs/limitations.md +133 -0
- blockpath-0.1.0/docs/runtime-sysmon-check.md +61 -0
- blockpath-0.1.0/observed.json +204 -0
- blockpath-0.1.0/pyproject.toml +126 -0
- blockpath-0.1.0/scripts/build_corpus.py +362 -0
- blockpath-0.1.0/scripts/clone_pinned.py +136 -0
- blockpath-0.1.0/scripts/corpus_queries.txt +19 -0
- blockpath-0.1.0/scripts/demo_session.sh +52 -0
- blockpath-0.1.0/scripts/depth_survey.py +872 -0
- blockpath-0.1.0/scripts/m0_oracle.py +133 -0
- blockpath-0.1.0/scripts/m0_regressions.py +364 -0
- blockpath-0.1.0/scripts/make_adjudication.py +186 -0
- blockpath-0.1.0/scripts/make_adjudication_table.py +149 -0
- blockpath-0.1.0/scripts/make_histogram.py +360 -0
- blockpath-0.1.0/scripts/reproduce_benchmark.sh +26 -0
- blockpath-0.1.0/scripts/reproduce_depth_histogram.sh +36 -0
- blockpath-0.1.0/scripts/ruff_crosscheck.py +172 -0
- blockpath-0.1.0/scripts/run_benchmark.py +100 -0
- blockpath-0.1.0/src/blockpath/__init__.py +22 -0
- blockpath-0.1.0/src/blockpath/analysis.py +329 -0
- blockpath-0.1.0/src/blockpath/callgraph.py +298 -0
- blockpath-0.1.0/src/blockpath/check.py +33 -0
- blockpath-0.1.0/src/blockpath/cli.py +144 -0
- blockpath-0.1.0/src/blockpath/collect.py +206 -0
- blockpath-0.1.0/src/blockpath/config.py +75 -0
- blockpath-0.1.0/src/blockpath/model.py +112 -0
- blockpath-0.1.0/src/blockpath/oracle.py +143 -0
- blockpath-0.1.0/src/blockpath/oracle.yaml +222 -0
- blockpath-0.1.0/src/blockpath/py.typed +0 -0
- blockpath-0.1.0/src/blockpath/report.py +140 -0
- blockpath-0.1.0/src/blockpath/resolve.py +192 -0
- blockpath-0.1.0/src/blockpath/runtime/__init__.py +22 -0
- blockpath-0.1.0/src/blockpath/runtime/monitor.py +157 -0
- blockpath-0.1.0/src/blockpath/runtime/predicate.py +39 -0
- blockpath-0.1.0/src/blockpath/runtime/verify.py +177 -0
- blockpath-0.1.0/src/blockpath/runtime/watchdog.py +117 -0
- blockpath-0.1.0/src/blockpath/symbols.py +284 -0
- blockpath-0.1.0/tests/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/01_three_file_chain/expected.json +14 -0
- blockpath-0.1.0/tests/golden/cases/01_three_file_chain/project/app/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/01_three_file_chain/project/app/clients/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/01_three_file_chain/project/app/clients/nominatim.py +8 -0
- blockpath-0.1.0/tests/golden/cases/01_three_file_chain/project/app/handlers/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/01_three_file_chain/project/app/handlers/order.py +11 -0
- blockpath-0.1.0/tests/golden/cases/01_three_file_chain/project/app/services/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/01_three_file_chain/project/app/services/geo.py +6 -0
- blockpath-0.1.0/tests/golden/cases/02_direct_in_handler/expected.json +12 -0
- blockpath-0.1.0/tests/golden/cases/02_direct_in_handler/project/bot.py +5 -0
- blockpath-0.1.0/tests/golden/cases/03_sync_script_silent/expected.json +4 -0
- blockpath-0.1.0/tests/golden/cases/03_sync_script_silent/project/app.py +9 -0
- blockpath-0.1.0/tests/golden/cases/04_offload_to_thread_ref/expected.json +4 -0
- blockpath-0.1.0/tests/golden/cases/04_offload_to_thread_ref/project/app/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/04_offload_to_thread_ref/project/app/main.py +7 -0
- blockpath-0.1.0/tests/golden/cases/04_offload_to_thread_ref/project/app/work.py +5 -0
- blockpath-0.1.0/tests/golden/cases/05_offload_run_in_executor_ref/expected.json +4 -0
- blockpath-0.1.0/tests/golden/cases/05_offload_run_in_executor_ref/project/app/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/05_offload_run_in_executor_ref/project/app/main.py +8 -0
- blockpath-0.1.0/tests/golden/cases/05_offload_run_in_executor_ref/project/app/work.py +5 -0
- blockpath-0.1.0/tests/golden/cases/06_called_not_passed_blk002/expected.json +20 -0
- blockpath-0.1.0/tests/golden/cases/06_called_not_passed_blk002/project/app/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/06_called_not_passed_blk002/project/app/main.py +8 -0
- blockpath-0.1.0/tests/golden/cases/06_called_not_passed_blk002/project/app/work.py +5 -0
- blockpath-0.1.0/tests/golden/cases/07_relative_imports/expected.json +13 -0
- blockpath-0.1.0/tests/golden/cases/07_relative_imports/project/app/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/07_relative_imports/project/app/handlers/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/07_relative_imports/project/app/handlers/start.py +5 -0
- blockpath-0.1.0/tests/golden/cases/07_relative_imports/project/app/services/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/07_relative_imports/project/app/services/geo.py +5 -0
- blockpath-0.1.0/tests/golden/cases/08_reexport_through_init/expected.json +13 -0
- blockpath-0.1.0/tests/golden/cases/08_reexport_through_init/project/app/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/08_reexport_through_init/project/app/handlers/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/08_reexport_through_init/project/app/handlers/order.py +5 -0
- blockpath-0.1.0/tests/golden/cases/08_reexport_through_init/project/app/services/__init__.py +1 -0
- blockpath-0.1.0/tests/golden/cases/08_reexport_through_init/project/app/services/geo.py +5 -0
- blockpath-0.1.0/tests/golden/cases/09_src_layout/expected.json +13 -0
- blockpath-0.1.0/tests/golden/cases/09_src_layout/project/src/app/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/09_src_layout/project/src/app/bot.py +5 -0
- blockpath-0.1.0/tests/golden/cases/09_src_layout/project/src/app/net.py +5 -0
- blockpath-0.1.0/tests/golden/cases/10_mutual_recursion_terminates/expected.json +14 -0
- blockpath-0.1.0/tests/golden/cases/10_mutual_recursion_terminates/project/app/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/10_mutual_recursion_terminates/project/app/loop.py +14 -0
- blockpath-0.1.0/tests/golden/cases/11_shadowed_name_silent/expected.json +4 -0
- blockpath-0.1.0/tests/golden/cases/11_shadowed_name_silent/project/app/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/11_shadowed_name_silent/project/app/h.py +11 -0
- blockpath-0.1.0/tests/golden/cases/12_unresolved_silent_default/expected.json +4 -0
- blockpath-0.1.0/tests/golden/cases/12_unresolved_silent_default/project/app/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/12_unresolved_silent_default/project/app/h.py +5 -0
- blockpath-0.1.0/tests/golden/cases/13_unresolved_strict_blk003/expected.json +15 -0
- blockpath-0.1.0/tests/golden/cases/13_unresolved_strict_blk003/project/app/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/13_unresolved_strict_blk003/project/app/h.py +5 -0
- blockpath-0.1.0/tests/golden/cases/14_framework_decorator_entry/expected.json +18 -0
- blockpath-0.1.0/tests/golden/cases/14_framework_decorator_entry/project/app/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/14_framework_decorator_entry/project/app/handlers/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/14_framework_decorator_entry/project/app/handlers/cmd.py +10 -0
- blockpath-0.1.0/tests/golden/cases/14_framework_decorator_entry/project/app/svc.py +5 -0
- blockpath-0.1.0/tests/golden/cases/15_attribute_call_missed/expected.json +4 -0
- blockpath-0.1.0/tests/golden/cases/15_attribute_call_missed/project/app/__init__.py +0 -0
- blockpath-0.1.0/tests/golden/cases/15_attribute_call_missed/project/app/client.py +9 -0
- blockpath-0.1.0/tests/golden/harness.py +74 -0
- blockpath-0.1.0/tests/golden/test_golden.py +66 -0
- blockpath-0.1.0/tests/runtime/__init__.py +0 -0
- blockpath-0.1.0/tests/runtime/test_predicate.py +138 -0
- blockpath-0.1.0/tests/support.py +64 -0
- blockpath-0.1.0/tests/test_callgraph.py +209 -0
- blockpath-0.1.0/tests/test_cli.py +215 -0
- blockpath-0.1.0/tests/test_collect.py +125 -0
- blockpath-0.1.0/tests/test_resolve.py +184 -0
- blockpath-0.1.0/tests/test_resolver_property.py +95 -0
- blockpath-0.1.0/tests/test_resolver_regressions.py +130 -0
- blockpath-0.1.0/tests/test_scopes.py +120 -0
- blockpath-0.1.0/uv.lock +633 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: ci-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
check:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
strategy:
|
|
16
|
+
fail-fast: false
|
|
17
|
+
matrix:
|
|
18
|
+
python-version: ["3.12", "3.13"]
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
|
|
22
|
+
- name: Install uv
|
|
23
|
+
uses: astral-sh/setup-uv@v5
|
|
24
|
+
with:
|
|
25
|
+
enable-cache: true
|
|
26
|
+
|
|
27
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
28
|
+
run: uv python install ${{ matrix.python-version }}
|
|
29
|
+
|
|
30
|
+
- name: Sync (locked)
|
|
31
|
+
run: uv sync --locked --all-extras
|
|
32
|
+
|
|
33
|
+
- name: Lint (ruff)
|
|
34
|
+
run: uv run ruff check .
|
|
35
|
+
|
|
36
|
+
- name: Format check (ruff)
|
|
37
|
+
run: uv run ruff format --check .
|
|
38
|
+
|
|
39
|
+
- name: Type check (mypy --strict)
|
|
40
|
+
run: uv run mypy
|
|
41
|
+
|
|
42
|
+
- name: Test
|
|
43
|
+
run: uv run pytest
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
# Publishes to PyPI via Trusted Publishing (OIDC) when a v* tag is pushed. No API token is
|
|
4
|
+
# stored anywhere: PyPI is configured to trust this repository and workflow, and mints a
|
|
5
|
+
# short-lived credential for the run. The maintainer creates the tag; this does the rest.
|
|
6
|
+
#
|
|
7
|
+
# One-time setup on PyPI (the maintainer, not this workflow):
|
|
8
|
+
# pypi.org -> the blockpath project -> Publishing -> add a trusted publisher:
|
|
9
|
+
# owner: iraettae repository: blockpath workflow: release.yml environment: pypi
|
|
10
|
+
|
|
11
|
+
on:
|
|
12
|
+
push:
|
|
13
|
+
tags:
|
|
14
|
+
- "v*"
|
|
15
|
+
|
|
16
|
+
permissions:
|
|
17
|
+
contents: read
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
build:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
- name: Install uv
|
|
25
|
+
uses: astral-sh/setup-uv@v5
|
|
26
|
+
- name: Build sdist and wheel
|
|
27
|
+
run: uv build
|
|
28
|
+
- name: Check metadata
|
|
29
|
+
run: uvx twine check dist/*
|
|
30
|
+
- uses: actions/upload-artifact@v4
|
|
31
|
+
with:
|
|
32
|
+
name: dist
|
|
33
|
+
path: dist/
|
|
34
|
+
|
|
35
|
+
publish:
|
|
36
|
+
needs: build
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
environment: pypi
|
|
39
|
+
permissions:
|
|
40
|
+
id-token: write # the OIDC token Trusted Publishing exchanges for a PyPI credential
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/download-artifact@v4
|
|
43
|
+
with:
|
|
44
|
+
name: dist
|
|
45
|
+
path: dist/
|
|
46
|
+
- name: Publish to PyPI
|
|
47
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Corpus clones: third-party code, never re-published from this repository.
|
|
2
|
+
# Only the URL list, pinned SHAs and derived numbers are committed.
|
|
3
|
+
.corpus/
|
|
4
|
+
|
|
5
|
+
# Raw per-run scratch that reproduce scripts regenerate
|
|
6
|
+
.cache/
|
|
7
|
+
*.log
|
|
8
|
+
|
|
9
|
+
# Python
|
|
10
|
+
__pycache__/
|
|
11
|
+
*.py[cod]
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
*.egg-info/
|
|
15
|
+
dist/
|
|
16
|
+
build/
|
|
17
|
+
|
|
18
|
+
# Tooling
|
|
19
|
+
.mypy_cache/
|
|
20
|
+
.ruff_cache/
|
|
21
|
+
.pytest_cache/
|
|
22
|
+
.coverage
|
|
23
|
+
htmlcov/
|
|
24
|
+
|
|
25
|
+
# OS / editors
|
|
26
|
+
.DS_Store
|
|
27
|
+
.idea/
|
|
28
|
+
.vscode/
|
|
29
|
+
|
|
30
|
+
# Local assistant/tooling config, never part of the published project
|
|
31
|
+
.claude/
|
|
32
|
+
CLAUDE.md
|
|
33
|
+
.cursor/
|
|
34
|
+
|
|
35
|
+
# Dev interpreter pin: the supported range is in pyproject (requires-python),
|
|
36
|
+
# and CI is explicit, so we do not force one local version on contributors.
|
|
37
|
+
.python-version
|
|
38
|
+
benchmark/runtime_demo/observed.json
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Commits do not physically pass with dirty code: ruff lints and formats, mypy type-checks.
|
|
2
|
+
# Install once with `uv run pre-commit install`.
|
|
3
|
+
repos:
|
|
4
|
+
- repo: local
|
|
5
|
+
hooks:
|
|
6
|
+
- id: ruff-check
|
|
7
|
+
name: ruff check
|
|
8
|
+
entry: uv run ruff check --fix
|
|
9
|
+
language: system
|
|
10
|
+
types_or: [python, pyi]
|
|
11
|
+
require_serial: true
|
|
12
|
+
|
|
13
|
+
- id: ruff-format
|
|
14
|
+
name: ruff format
|
|
15
|
+
entry: uv run ruff format
|
|
16
|
+
language: system
|
|
17
|
+
types_or: [python, pyi]
|
|
18
|
+
require_serial: true
|
|
19
|
+
|
|
20
|
+
- id: mypy
|
|
21
|
+
name: mypy --strict
|
|
22
|
+
entry: uv run mypy
|
|
23
|
+
language: system
|
|
24
|
+
pass_filenames: false
|
|
25
|
+
types_or: [python, pyi]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-07-09
|
|
8
|
+
|
|
9
|
+
First release.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `blockpath check` -- static analysis that finds blocking calls reachable from a coroutine and
|
|
14
|
+
prints the full call path across files and modules. Text and `--json` reporters; exit codes
|
|
15
|
+
`0` clean / `1` findings / `2` error.
|
|
16
|
+
- Three diagnostics: **BLK001** (a blocking call reachable from a coroutine), **BLK002** (a
|
|
17
|
+
function called where it should have been passed to an executor), **BLK003** (`--strict` only:
|
|
18
|
+
a blocking call handed to a callee that could not be resolved).
|
|
19
|
+
- Import resolver handling relative imports, re-exports through `__init__.py`, `as`-aliases,
|
|
20
|
+
src-layout, and cycles, resolving names without importing or running the target code.
|
|
21
|
+
- Blocking-call oracle as data (`oracle.yaml`): ~50 entries over three severity tiers (`error`
|
|
22
|
+
on by default, `warning` and `off` opt-in), plus the thread-offload primitives.
|
|
23
|
+
- `blockpath verify -- <command>` and `blockpath compare` -- a runtime verifier built on
|
|
24
|
+
`sys.monitoring` that records which functions actually ran on the loop and reports a lower
|
|
25
|
+
bound on recall.
|
|
26
|
+
- Configuration via `[tool.blockpath]` in `pyproject.toml`.
|
|
27
|
+
|
|
28
|
+
### Measured
|
|
29
|
+
|
|
30
|
+
- A pre-registered survey of 57 public aiogram/FastAPI repositories: 51% of blocking calls
|
|
31
|
+
reachable from a coroutine sit at depth 2 or deeper, where per-file linters cannot follow.
|
|
32
|
+
- Hand-adjudicated precision on five of those repositories: 110/112 (98.2%) by the tool's literal
|
|
33
|
+
claim, 99/112 (88.4%) counting product-code bugs only, every false positive explained.
|
|
34
|
+
|
|
35
|
+
[0.1.0]: https://github.com/iraettae/blockpath/releases/tag/v0.1.0
|
blockpath-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Damir
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
blockpath-0.1.0/Makefile
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
.DEFAULT_GOAL := help
|
|
2
|
+
.PHONY: help install lint fmt typecheck test check demo bench bench-depth
|
|
3
|
+
|
|
4
|
+
help: ## show this help
|
|
5
|
+
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
|
|
6
|
+
awk 'BEGIN {FS = ":.*?## "}; {printf " %-14s %s\n", $$1, $$2}'
|
|
7
|
+
|
|
8
|
+
install: ## sync the locked dev environment and install the pre-commit hook
|
|
9
|
+
uv sync --locked --all-extras
|
|
10
|
+
uv run pre-commit install
|
|
11
|
+
|
|
12
|
+
lint: ## ruff check + format check
|
|
13
|
+
uv run ruff check .
|
|
14
|
+
uv run ruff format --check .
|
|
15
|
+
|
|
16
|
+
fmt: ## ruff autofix + format
|
|
17
|
+
uv run ruff check --fix .
|
|
18
|
+
uv run ruff format .
|
|
19
|
+
|
|
20
|
+
typecheck: ## mypy --strict
|
|
21
|
+
uv run mypy
|
|
22
|
+
|
|
23
|
+
test: ## pytest
|
|
24
|
+
uv run pytest
|
|
25
|
+
|
|
26
|
+
check: lint typecheck test ## everything CI runs
|
|
27
|
+
|
|
28
|
+
demo: ## re-record the README demo (asciinema cast + animated svg via svg-term)
|
|
29
|
+
asciinema rec --overwrite -c scripts/demo_session.sh --cols 100 --rows 12 docs/demo.cast
|
|
30
|
+
npx --yes svg-term-cli --in docs/demo.cast --out docs/demo.svg --window --width 100 --height 12
|
|
31
|
+
|
|
32
|
+
bench-depth: ## rebuild the Milestone 0 depth histogram from the pinned corpus
|
|
33
|
+
./scripts/reproduce_depth_histogram.sh
|
|
34
|
+
|
|
35
|
+
bench: ## rebuild the precision findings from the pinned benchmark repositories
|
|
36
|
+
./scripts/reproduce_benchmark.sh
|
blockpath-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blockpath
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find blocking calls reachable from a coroutine, and print the whole call path across files and modules.
|
|
5
|
+
Project-URL: Repository, https://github.com/iraettae/blockpath
|
|
6
|
+
Project-URL: Issues, https://github.com/iraettae/blockpath/issues
|
|
7
|
+
Author: Damir
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: asyncio,blocking,call-graph,event-loop,linter,static-analysis
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Requires-Dist: click>=8.1
|
|
22
|
+
Requires-Dist: pathspec>=0.12
|
|
23
|
+
Requires-Dist: pyyaml>=6.0
|
|
24
|
+
Requires-Dist: rich>=13.7
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# blockpath
|
|
28
|
+
|
|
29
|
+
[](https://github.com/iraettae/blockpath/actions/workflows/ci.yml)
|
|
30
|
+
[](https://www.python.org/downloads/)
|
|
31
|
+
|
|
32
|
+
Find the synchronous call that stalls your event loop, and print the whole path to it: from the
|
|
33
|
+
`async` handler down through `services/` and `clients/` to the blocking `requests.get`, across as
|
|
34
|
+
many files and modules as it takes.
|
|
35
|
+
|
|
36
|
+
Existing linters catch a blocking call only when it sits directly in the `async def` body. In
|
|
37
|
+
real code it almost never does -- it is three to five calls deep, in another module, behind a
|
|
38
|
+
couple of helpers nobody thinks about. That is where it hides, and that is what blockpath finds.
|
|
39
|
+
|
|
40
|
+
## The problem is real, and it was measured before a line of the analyzer was written
|
|
41
|
+
|
|
42
|
+
A survey of **57 public aiogram/FastAPI repositories**, with the protocol and thresholds
|
|
43
|
+
[registered in advance](benchmark/PREREGISTRATION.md) so the result could not be steered, asked
|
|
44
|
+
one question: when a coroutine can reach a blocking call, how deep is it?
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
depth 1 | ######################################## 77 (48.7%) <- a per-file linter sees these
|
|
48
|
+
depth 2 | ############### 28 (17.7%)
|
|
49
|
+
depth 3 | ################ 31 (19.6%)
|
|
50
|
+
depth 4 | ######### 17 (10.8%)
|
|
51
|
+
depth 5 | ## 3 ( 1.9%)
|
|
52
|
+
depth 6 | # 2 ( 1.3%)
|
|
53
|
+
|
|
54
|
+
| 158 reachable blocking call sites
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**51% of them sit at depth 2 or deeper**, across module boundaries, where no per-file linter can
|
|
58
|
+
follow. As a cross-check, `ruff --select ASYNC` run on the same repositories flags **0 of those
|
|
59
|
+
81 deep sites**. The histogram, and the fact that it rebuilds from scratch, is in
|
|
60
|
+
[benchmark/depth_histogram.md](benchmark/depth_histogram.md) (`make bench-depth`).
|
|
61
|
+
|
|
62
|
+
## What it looks like
|
|
63
|
+
|
|
64
|
+

|
|
65
|
+
|
|
66
|
+
```console
|
|
67
|
+
$ blockpath check .
|
|
68
|
+
BLK001 blocking call reachable from a coroutine
|
|
69
|
+
app/handlers/order.py:11 return resolve_address(address) <- entry: async def
|
|
70
|
+
app/services/geo.py:6 return geocode(query)
|
|
71
|
+
app/clients/nominatim.py:7 response = requests.get(BASE_URL, params=...) <- blocks the event loop
|
|
72
|
+
hint: await asyncio.to_thread(resolve_address, ...)
|
|
73
|
+
|
|
74
|
+
1 finding(s): 1 BLK001
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The path through three files is the point: no existing linter prints it.
|
|
78
|
+
|
|
79
|
+
## Quick start
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install blockpath
|
|
83
|
+
blockpath check .
|
|
84
|
+
blockpath check . --json # for CI
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Exit code is `0` when clean, `1` when there are findings (fails a build), `2` if the tool itself
|
|
88
|
+
errored -- so a crash is never mistaken for a clean run.
|
|
89
|
+
|
|
90
|
+
Optional configuration, in your own `pyproject.toml`:
|
|
91
|
+
|
|
92
|
+
```toml
|
|
93
|
+
[tool.blockpath]
|
|
94
|
+
tiers = ["error"] # error (default) | warning | off
|
|
95
|
+
entry-decorators = ["router.message"] # mark framework handlers as entry points
|
|
96
|
+
strict = false # also report blocking calls handed to unresolved callees
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## How it works
|
|
100
|
+
|
|
101
|
+
Six layers, each a small tested unit, and one idea that carries the whole thing.
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
collect -> symbols -> resolve -> callgraph -> oracle -> analysis
|
|
105
|
+
(walk & (names & (name -> (CALL/REF (what (three BFS,
|
|
106
|
+
parse) scopes) function) edges) blocks) the witness)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The idea is that a finding is the **intersection of two independent reachability questions**, and
|
|
110
|
+
that intersection is why the tool stays quiet:
|
|
111
|
+
|
|
112
|
+
1. **Backward** from every blocking leaf: which functions can reach a blocking call at all?
|
|
113
|
+
2. **Forward** from every coroutine: which functions can a coroutine reach?
|
|
114
|
+
3. A third pass over the intersection recovers the *witness* -- the exact chain of call sites.
|
|
115
|
+
|
|
116
|
+
Report only their intersection and `time.sleep` in a synchronous CLI script in the same repo
|
|
117
|
+
stays silent. Report either half alone and the tool cries wolf and gets switched off. The whole
|
|
118
|
+
analysis is `O(V+E)` -- three breadth-first searches, no Tarjan, no SCC, no fixpoint
|
|
119
|
+
([ADR 0002](docs/adr/0002-three-bfs-not-tarjan.md)).
|
|
120
|
+
|
|
121
|
+
The call graph is built from the AST **without importing or running your code**, so a project
|
|
122
|
+
that needs a database to import is analysed the same as any other.
|
|
123
|
+
|
|
124
|
+
## How good is it, honestly
|
|
125
|
+
|
|
126
|
+
Run over five of the surveyed repositories and adjudicated by hand, finding by finding, with a
|
|
127
|
+
second pass auditing the riskiest calls ([benchmark/adjudication.md](benchmark/adjudication.md)):
|
|
128
|
+
|
|
129
|
+
- **precision 110/112 = 98.2%** by the tool's literal claim (a blocking call reachable from a
|
|
130
|
+
coroutine), of which 99 are product-code bugs and 11 are real blocking calls in test code; or
|
|
131
|
+
**99/112 = 88.4%** counting product bugs only. Both numbers are in the table, and each of the
|
|
132
|
+
two false positives is explained with a permalink.
|
|
133
|
+
- **recall is a lower bound, never a point estimate.** The runtime verifier (`blockpath verify --
|
|
134
|
+
pytest`) records which functions actually ran on the loop and compares them to the static
|
|
135
|
+
findings, but it only sees the paths the tests exercised. It can prove a miss and can never
|
|
136
|
+
prove completeness, and it says nothing about precision -- only the hand adjudication does.
|
|
137
|
+
|
|
138
|
+
Precision is not a number the analyzer asserts about itself; it is what survived a human reading
|
|
139
|
+
each finding against the source.
|
|
140
|
+
|
|
141
|
+
## Design decisions, and where it loses
|
|
142
|
+
|
|
143
|
+
- **Only calls by name are resolved.** An attribute call on a value (`self.session.get()`) needs
|
|
144
|
+
type inference, which this does not do; the cost is measured (about half of all call sites on
|
|
145
|
+
one benchmark repo) rather than waved away ([ADR 0003](docs/adr/0003-attribute-calls-not-resolved.md)).
|
|
146
|
+
- **A reference is not a call.** A function passed to `run_in_executor(None, f)` runs in a
|
|
147
|
+
thread, so it is silent by construction -- no offload false positives -- while
|
|
148
|
+
`run_in_executor(None, f())` with parentheses is a real bug (BLK002)
|
|
149
|
+
([ADR 0007](docs/adr/0007-call-edges-only-ref-not-traversed.md)).
|
|
150
|
+
- **Unknown is quiet by default.** A path with an unresolved edge is not reported unless you ask
|
|
151
|
+
with `--strict` (BLK003) ([ADR 0006](docs/adr/0006-reexport-and-star-import-policy.md)).
|
|
152
|
+
- The [complete list of limitations](docs/limitations.md) is written as they were found, not at
|
|
153
|
+
the end.
|
|
154
|
+
|
|
155
|
+
### Prior art
|
|
156
|
+
|
|
157
|
+
- **ruff `ASYNC` rules** are written in Rust and run hundreds of times faster than this. They are
|
|
158
|
+
the right tool for the depth-1 case, and blockpath does not try to compete on speed. They work
|
|
159
|
+
per file, by an explicit architectural choice (parallel analysis without global state), so they
|
|
160
|
+
do not build a cross-module call graph and cannot print the path -- which is the entire reason
|
|
161
|
+
this exists. The 0-of-81 cross-check above is that difference, measured.
|
|
162
|
+
- **flake8-async** is likewise per-file and does not follow calls between modules.
|
|
163
|
+
|
|
164
|
+
blockpath is not a replacement for either; it is the cross-module analysis they deliberately do
|
|
165
|
+
not do.
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
uv sync --all-extras
|
|
171
|
+
make check # ruff, mypy --strict, pytest
|
|
172
|
+
make bench-depth # rebuild the depth histogram from the pinned corpus
|
|
173
|
+
./scripts/reproduce_benchmark.sh # rebuild the precision findings
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
This project is AI-assisted: written by a human, who made the product and architecture decisions
|
|
177
|
+
and signed off at every checkpoint, with an AI pair implementing under direction. [WORKLOG.md](WORKLOG.md)
|
|
178
|
+
keeps the estimate-versus-actual log from day one, including where the estimates were wrong.
|
|
179
|
+
|
|
180
|
+
Python 3.12+ (it uses `sys.monitoring`, [ADR 0001](docs/adr/0001-python-312-only.md)). MIT licensed.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# blockpath
|
|
2
|
+
|
|
3
|
+
[](https://github.com/iraettae/blockpath/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.python.org/downloads/)
|
|
5
|
+
|
|
6
|
+
Find the synchronous call that stalls your event loop, and print the whole path to it: from the
|
|
7
|
+
`async` handler down through `services/` and `clients/` to the blocking `requests.get`, across as
|
|
8
|
+
many files and modules as it takes.
|
|
9
|
+
|
|
10
|
+
Existing linters catch a blocking call only when it sits directly in the `async def` body. In
|
|
11
|
+
real code it almost never does -- it is three to five calls deep, in another module, behind a
|
|
12
|
+
couple of helpers nobody thinks about. That is where it hides, and that is what blockpath finds.
|
|
13
|
+
|
|
14
|
+
## The problem is real, and it was measured before a line of the analyzer was written
|
|
15
|
+
|
|
16
|
+
A survey of **57 public aiogram/FastAPI repositories**, with the protocol and thresholds
|
|
17
|
+
[registered in advance](benchmark/PREREGISTRATION.md) so the result could not be steered, asked
|
|
18
|
+
one question: when a coroutine can reach a blocking call, how deep is it?
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
depth 1 | ######################################## 77 (48.7%) <- a per-file linter sees these
|
|
22
|
+
depth 2 | ############### 28 (17.7%)
|
|
23
|
+
depth 3 | ################ 31 (19.6%)
|
|
24
|
+
depth 4 | ######### 17 (10.8%)
|
|
25
|
+
depth 5 | ## 3 ( 1.9%)
|
|
26
|
+
depth 6 | # 2 ( 1.3%)
|
|
27
|
+
|
|
28
|
+
| 158 reachable blocking call sites
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**51% of them sit at depth 2 or deeper**, across module boundaries, where no per-file linter can
|
|
32
|
+
follow. As a cross-check, `ruff --select ASYNC` run on the same repositories flags **0 of those
|
|
33
|
+
81 deep sites**. The histogram, and the fact that it rebuilds from scratch, is in
|
|
34
|
+
[benchmark/depth_histogram.md](benchmark/depth_histogram.md) (`make bench-depth`).
|
|
35
|
+
|
|
36
|
+
## What it looks like
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+
|
|
40
|
+
```console
|
|
41
|
+
$ blockpath check .
|
|
42
|
+
BLK001 blocking call reachable from a coroutine
|
|
43
|
+
app/handlers/order.py:11 return resolve_address(address) <- entry: async def
|
|
44
|
+
app/services/geo.py:6 return geocode(query)
|
|
45
|
+
app/clients/nominatim.py:7 response = requests.get(BASE_URL, params=...) <- blocks the event loop
|
|
46
|
+
hint: await asyncio.to_thread(resolve_address, ...)
|
|
47
|
+
|
|
48
|
+
1 finding(s): 1 BLK001
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The path through three files is the point: no existing linter prints it.
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install blockpath
|
|
57
|
+
blockpath check .
|
|
58
|
+
blockpath check . --json # for CI
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Exit code is `0` when clean, `1` when there are findings (fails a build), `2` if the tool itself
|
|
62
|
+
errored -- so a crash is never mistaken for a clean run.
|
|
63
|
+
|
|
64
|
+
Optional configuration, in your own `pyproject.toml`:
|
|
65
|
+
|
|
66
|
+
```toml
|
|
67
|
+
[tool.blockpath]
|
|
68
|
+
tiers = ["error"] # error (default) | warning | off
|
|
69
|
+
entry-decorators = ["router.message"] # mark framework handlers as entry points
|
|
70
|
+
strict = false # also report blocking calls handed to unresolved callees
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## How it works
|
|
74
|
+
|
|
75
|
+
Six layers, each a small tested unit, and one idea that carries the whole thing.
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
collect -> symbols -> resolve -> callgraph -> oracle -> analysis
|
|
79
|
+
(walk & (names & (name -> (CALL/REF (what (three BFS,
|
|
80
|
+
parse) scopes) function) edges) blocks) the witness)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The idea is that a finding is the **intersection of two independent reachability questions**, and
|
|
84
|
+
that intersection is why the tool stays quiet:
|
|
85
|
+
|
|
86
|
+
1. **Backward** from every blocking leaf: which functions can reach a blocking call at all?
|
|
87
|
+
2. **Forward** from every coroutine: which functions can a coroutine reach?
|
|
88
|
+
3. A third pass over the intersection recovers the *witness* -- the exact chain of call sites.
|
|
89
|
+
|
|
90
|
+
Report only their intersection and `time.sleep` in a synchronous CLI script in the same repo
|
|
91
|
+
stays silent. Report either half alone and the tool cries wolf and gets switched off. The whole
|
|
92
|
+
analysis is `O(V+E)` -- three breadth-first searches, no Tarjan, no SCC, no fixpoint
|
|
93
|
+
([ADR 0002](docs/adr/0002-three-bfs-not-tarjan.md)).
|
|
94
|
+
|
|
95
|
+
The call graph is built from the AST **without importing or running your code**, so a project
|
|
96
|
+
that needs a database to import is analysed the same as any other.
|
|
97
|
+
|
|
98
|
+
## How good is it, honestly
|
|
99
|
+
|
|
100
|
+
Run over five of the surveyed repositories and adjudicated by hand, finding by finding, with a
|
|
101
|
+
second pass auditing the riskiest calls ([benchmark/adjudication.md](benchmark/adjudication.md)):
|
|
102
|
+
|
|
103
|
+
- **precision 110/112 = 98.2%** by the tool's literal claim (a blocking call reachable from a
|
|
104
|
+
coroutine), of which 99 are product-code bugs and 11 are real blocking calls in test code; or
|
|
105
|
+
**99/112 = 88.4%** counting product bugs only. Both numbers are in the table, and each of the
|
|
106
|
+
two false positives is explained with a permalink.
|
|
107
|
+
- **recall is a lower bound, never a point estimate.** The runtime verifier (`blockpath verify --
|
|
108
|
+
pytest`) records which functions actually ran on the loop and compares them to the static
|
|
109
|
+
findings, but it only sees the paths the tests exercised. It can prove a miss and can never
|
|
110
|
+
prove completeness, and it says nothing about precision -- only the hand adjudication does.
|
|
111
|
+
|
|
112
|
+
Precision is not a number the analyzer asserts about itself; it is what survived a human reading
|
|
113
|
+
each finding against the source.
|
|
114
|
+
|
|
115
|
+
## Design decisions, and where it loses
|
|
116
|
+
|
|
117
|
+
- **Only calls by name are resolved.** An attribute call on a value (`self.session.get()`) needs
|
|
118
|
+
type inference, which this does not do; the cost is measured (about half of all call sites on
|
|
119
|
+
one benchmark repo) rather than waved away ([ADR 0003](docs/adr/0003-attribute-calls-not-resolved.md)).
|
|
120
|
+
- **A reference is not a call.** A function passed to `run_in_executor(None, f)` runs in a
|
|
121
|
+
thread, so it is silent by construction -- no offload false positives -- while
|
|
122
|
+
`run_in_executor(None, f())` with parentheses is a real bug (BLK002)
|
|
123
|
+
([ADR 0007](docs/adr/0007-call-edges-only-ref-not-traversed.md)).
|
|
124
|
+
- **Unknown is quiet by default.** A path with an unresolved edge is not reported unless you ask
|
|
125
|
+
with `--strict` (BLK003) ([ADR 0006](docs/adr/0006-reexport-and-star-import-policy.md)).
|
|
126
|
+
- The [complete list of limitations](docs/limitations.md) is written as they were found, not at
|
|
127
|
+
the end.
|
|
128
|
+
|
|
129
|
+
### Prior art
|
|
130
|
+
|
|
131
|
+
- **ruff `ASYNC` rules** are written in Rust and run hundreds of times faster than this. They are
|
|
132
|
+
the right tool for the depth-1 case, and blockpath does not try to compete on speed. They work
|
|
133
|
+
per file, by an explicit architectural choice (parallel analysis without global state), so they
|
|
134
|
+
do not build a cross-module call graph and cannot print the path -- which is the entire reason
|
|
135
|
+
this exists. The 0-of-81 cross-check above is that difference, measured.
|
|
136
|
+
- **flake8-async** is likewise per-file and does not follow calls between modules.
|
|
137
|
+
|
|
138
|
+
blockpath is not a replacement for either; it is the cross-module analysis they deliberately do
|
|
139
|
+
not do.
|
|
140
|
+
|
|
141
|
+
## Development
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
uv sync --all-extras
|
|
145
|
+
make check # ruff, mypy --strict, pytest
|
|
146
|
+
make bench-depth # rebuild the depth histogram from the pinned corpus
|
|
147
|
+
./scripts/reproduce_benchmark.sh # rebuild the precision findings
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
This project is AI-assisted: written by a human, who made the product and architecture decisions
|
|
151
|
+
and signed off at every checkpoint, with an AI pair implementing under direction. [WORKLOG.md](WORKLOG.md)
|
|
152
|
+
keeps the estimate-versus-actual log from day one, including where the estimates were wrong.
|
|
153
|
+
|
|
154
|
+
Python 3.12+ (it uses `sys.monitoring`, [ADR 0001](docs/adr/0001-python-312-only.md)). MIT licensed.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# WORKLOG
|
|
2
|
+
|
|
3
|
+
Estimate vs actual, per stage. Kept from day one, because a miss in estimation is itself
|
|
4
|
+
diagnostic and hiding it would waste the only evidence this file produces.
|
|
5
|
+
|
|
6
|
+
## How to read the "actual" column
|
|
7
|
+
|
|
8
|
+
This project is AI-assisted (see README). The `actual` column records **wall-clock time of
|
|
9
|
+
the working session**, not human keyboard-hours, and it is not comparable to the `estimate`
|
|
10
|
+
column, which is a human-hours budget. Both are recorded because the interesting quantity is
|
|
11
|
+
the *ratio between stages*: which stage blew its budget relative to the others, and whether
|
|
12
|
+
the stage flagged up front as the risky one (stage 2, the import resolver) is in fact the one
|
|
13
|
+
that did.
|
|
14
|
+
|
|
15
|
+
Every stage row also records the decisions taken and the ones reversed.
|
|
16
|
+
|
|
17
|
+
| Stage | Estimate (h) | Actual (wall-clock) | Delta | Notes |
|
|
18
|
+
|---|---|---|---|---|
|
|
19
|
+
| 0. Milestone 0: premise check | 3 | ~1 session, over budget | over | premise HOLDS, gate passed; see note below |
|
|
20
|
+
| 1. Skeleton + golden tests | 5 | ~part of a session | roughly on budget | uv/hatchling, ruff+mypy strict green, CI, 4 ADRs, 15 golden cases xfail-until-implemented |
|
|
21
|
+
| 2. collect + symbols + resolve | 20 | ~a session, incl. audit | roughly on budget | resolver green; symtable scopes; property test vs importlib; adversarial audit found 1 critical + majors, all fixed |
|
|
22
|
+
| 3. callgraph | 10 | ~part of a session | under budget | nodes + CALL/REF edges + consumer context; resolution tally; dogfooded on a 725-module repo (attribute calls ~51%) |
|
|
23
|
+
| 4. oracle.yaml | 5 | ~part of a session | under budget | 52 entries, 3 severity tiers, 4 offload primitives; YAML-boolean `off` trap handled |
|
|
24
|
+
| 5. analysis.py (three BFS) | 4 | ~part of a session | under budget | backward/forward/witness BFS + BLK002 + BLK003; **all 15 golden cases green**; 33 findings on a real repo, depth-5 witness |
|
|
25
|
+
| 6. reporters + CLI | 5 | ~part of a session | under budget | rich trace + JSON, exit codes 0/1/2, config in pyproject (ADR 0008), --strict/--tier/--no-config |
|
|
26
|
+
| 7. runtime/ (watchdog, sys.monitoring) | 12 | ~part of a session | under budget | sysmon/coverage coexistence proved empirically first; DISABLE pattern 1.09x vs 1.29x; predicate test reddens on co_flags |
|
|
27
|
+
| 8. corpus + adjudication | 12 | ~part of a session | under budget | 112 findings on 5 repos, hand-adjudicated; precision 110/112 (98.2%); audit HONEST, 0 overturned; runtime pipeline demoed (recall bound 100% on a built app) |
|
|
28
|
+
| 9. packaging + release | 8 | ~part of a session | under budget | README (histogram first, animated svg demo, prior art), CHANGELOG, PyPI Trusted-Publishing workflow, wheel verified in isolation, gitleaks clean, tag prepared |
|
|
29
|
+
| **Total** | **84** | one long build across sessions | see notes | 8/10 stages under or near budget; stage 0 over (the honesty task, not the mechanical one); every gate green, precision 110/112 measured |
|
|
30
|
+
|
|
31
|
+
## Stage 0 note (why it ran over 3 hours)
|
|
32
|
+
|
|
33
|
+
The 3-hour budget covered "clone repos, count depths, draw a histogram." It did not cover what
|
|
34
|
+
actually happened, which is the more valuable thing: the first histogram was produced by a
|
|
35
|
+
buggy instrument, an adversarial audit caught the bugs, and the number was rebuilt on a
|
|
36
|
+
corrected one.
|
|
37
|
+
|
|
38
|
+
Sequence:
|
|
39
|
+
- Pre-registered the protocol and oracle before any data (commits `a06a211`, `b6660a1`).
|
|
40
|
+
- Built the corpus (57 repos, pinned SHAs), ran the survey: pooled `D1` 0.679, but the cluster
|
|
41
|
+
bootstrap CI [0.31, 0.87] straddled the STOP line, so the number was not yet trustworthy.
|
|
42
|
+
- Put the survey through an adversarial audit: hostile hand-built cases aimed at the resolver,
|
|
43
|
+
a by-hand trace of every depth>=2 finding back to source, and an investigation of why 41
|
|
44
|
+
repos came back empty. It falsified PREREGISTRATION section 5's "measured D1 is an upper
|
|
45
|
+
bound" claim with two corpus-firing bugs (offload inlining, function-local-import dropping).
|
|
46
|
+
- Rewrote the survey (`0974978`): nested defs/lambdas as graph nodes, function-local import
|
|
47
|
+
resolution. 17-case regression suite, red before and green after. Corrected pooled `D1`
|
|
48
|
+
0.487, CI [0.17, 0.76], now entirely inside "premise holds."
|
|
49
|
+
- Re-adjudicated all 81 depth>=2 findings on corrected data: 75 real, 5 test-only, 1 dead
|
|
50
|
+
branch, 0 fabricated.
|
|
51
|
+
|
|
52
|
+
Diagnostic value of the overrun: the estimate was wrong because it priced the mechanical task,
|
|
53
|
+
not the honesty task. That is the same class of miss anticipated for stage 2 (the import
|
|
54
|
+
resolver, budgeted at 20h precisely because it is usually underestimated 2-3x), and it is the
|
|
55
|
+
first concrete evidence that the "measure your own tool" axis of this project has teeth.
|
|
56
|
+
|
|
57
|
+
## Reverts and abandoned approaches
|
|
58
|
+
|
|
59
|
+
Recorded here as they happen, with the commit that reverted them.
|
|
60
|
+
|
|
61
|
+
- The original inlining model for closures (`Site.inlined_nested`, PREREGISTRATION section 3.4)
|
|
62
|
+
did not survive the audit: it manufactured offload false positives. Replaced wholesale by the
|
|
63
|
+
nested-defs-as-nodes model in `0974978`. `inlined_nested` is now always False and kept only
|
|
64
|
+
for the raw-data schema -- a revert on contact with reality, left visible in the history
|
|
65
|
+
rather than squashed away.
|