mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-29 08:07:37 +09:00
The raw-subprocess census and the thread-ownership guard walked the filesystem under backend/app, which on a dev box also holds *installed* extension copies under app/plugins/ that git does not track. The census therefore measured 28 on dev machines but 24 on CI's clean checkout — four sites of invisible ceiling slack, a stale thread classification for the locally-installed analytics extension, and a red migration-inventory freshness test on every CI run since the range landed. Both instruments now intersect their walk with git ls-files (falling back to walking everything when git cannot answer), the analytics thread entry is removed (extension repos own their own threads), the ceiling ratchets down 28 -> 24, and the inventory is regenerated. Proving test: test_untracked_files_are_not_counted plants an untracked file with a raw call inside app/ and asserts the census does not see it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
89 lines
2.9 KiB
Python
89 lines
2.9 KiB
Python
"""Structural guard for intentional raw-thread ownership."""
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
from app.jobs.thread_ownership import (
|
|
LIFECYCLE_BOUNDED_DELIVERY,
|
|
LIFECYCLE_BOUNDED_FANOUT,
|
|
LIFECYCLE_DURABLE_CANDIDATE,
|
|
LIFECYCLE_PROCESS_LOOP,
|
|
LIFECYCLE_REQUEST_STREAM,
|
|
THREAD_OWNERSHIP,
|
|
)
|
|
|
|
|
|
APP_ROOT = Path(__file__).resolve().parents[1] / 'app'
|
|
BACKEND_ROOT = APP_ROOT.parent
|
|
|
|
|
|
def _raw_thread_sites():
|
|
# Tracked files only: installed extension copies under app/plugins/ exist
|
|
# on dev boxes but not in a clean checkout, and a classification entry for
|
|
# one reads as stale on CI (extension repos own their own threads).
|
|
from tracked_files import is_tracked, tracked_py_files
|
|
tracked = tracked_py_files(str(BACKEND_ROOT))
|
|
sites = set()
|
|
for path in APP_ROOT.rglob('*.py'):
|
|
if not is_tracked(str(path), tracked):
|
|
continue
|
|
tree = ast.parse(path.read_text(encoding='utf-8'), filename=str(path))
|
|
parents = {}
|
|
for node in ast.walk(tree):
|
|
for child in ast.iter_child_nodes(node):
|
|
parents[child] = node
|
|
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.Call):
|
|
continue
|
|
func = node.func
|
|
qualified = (
|
|
isinstance(func, ast.Attribute)
|
|
and func.attr == 'Thread'
|
|
and isinstance(func.value, ast.Name)
|
|
and func.value.id == 'threading'
|
|
)
|
|
direct = isinstance(func, ast.Name) and func.id == 'Thread'
|
|
if not (qualified or direct):
|
|
continue
|
|
|
|
parent = parents.get(node)
|
|
enclosing = '<module>'
|
|
while parent is not None:
|
|
if isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
enclosing = parent.name
|
|
break
|
|
parent = parents.get(parent)
|
|
|
|
target = next(
|
|
(ast.unparse(kw.value) for kw in node.keywords if kw.arg == 'target'),
|
|
'?',
|
|
)
|
|
relative = path.relative_to(BACKEND_ROOT).as_posix()
|
|
sites.add(f'{relative}:{enclosing}:{target}')
|
|
return sites
|
|
|
|
|
|
def test_every_raw_thread_has_an_exact_owner_classification():
|
|
discovered = _raw_thread_sites()
|
|
classified = set(THREAD_OWNERSHIP)
|
|
assert discovered == classified, (
|
|
f'unclassified={sorted(discovered - classified)}; '
|
|
f'stale={sorted(classified - discovered)}'
|
|
)
|
|
|
|
|
|
def test_thread_classifications_are_actionable():
|
|
valid_lifecycles = {
|
|
LIFECYCLE_PROCESS_LOOP,
|
|
LIFECYCLE_REQUEST_STREAM,
|
|
LIFECYCLE_BOUNDED_DELIVERY,
|
|
LIFECYCLE_BOUNDED_FANOUT,
|
|
LIFECYCLE_DURABLE_CANDIDATE,
|
|
}
|
|
for site, decision in THREAD_OWNERSHIP.items():
|
|
assert decision['owner'].strip(), site
|
|
assert decision['lifecycle'] in valid_lifecycles, site
|
|
assert decision['rationale'].strip(), site
|
|
|