Repository navigation
tech-debt: 308 non-test class names are defined in more than one module — one pair had drifted into two different security policies #16177
Description
308 non-test class names are defined in more than one module
Measured 2026-09-10 on origin/Dev_new_gui, over 3,227 non-test Python files
(tests, /tests/, test_* and node_modules excluded):
| Colliding class names | 308 |
| Worst offenders | ServiceStatus ×10 · DeploymentMode ×9 · SearchResult ×9 · ServiceRegistry ×8 · ValidationResult ×7 · MeshDB ×6 · Claim ×5 · ServiceHealth ×5 · SourceType ×5 |
Found while closing #15757, which was one instance of exactly this: two classes
named PasswordChangeRateLimiter in different modules, with different rate-limit
policies. A reader at either call site could not tell which one they had, and an
edit to "the" limiter could land on the wrong security surface.
Why this is a maintainability defect, not a style preference
A shared name across modules makes three ordinary operations unsafe:
- Reading a call site.
PasswordChangeRateLimiter()said nothing about which
policy applied — 5 attempts per 300s, or 3 per 1800s. - Grepping.
git grep ServiceStatusreturns ten unrelated concepts; there is no
query that finds "the one I mean". - Editing. A change reasoned about one definition and applied to the other is
invisible to review, because the diff reads correctly against the wrong class.
In #15757 the two policies had already drifted apart, and nobody had noticed
because both spellings were correct.
What this is NOT
Test doubles are excluded and should stay excluded: _FakeRedis ×13, _FakeSession
×12, _FakeAsyncRedis ×12 are local fixtures whose whole purpose is to be local. A
guard on all duplicate names would report ~384 and drown the 308 that matter.
Not every one of the 308 is a defect either. Two ValidationResult dataclasses in
genuinely unrelated domains are less dangerous than two ServiceRegistry classes
that both look like the registry. Triage is the work, and this issue is the
enumeration that makes triage possible.
Acceptance criteria
- The 308 are triaged into: same concept (consolidate), related concept (rename to disambiguate), genuinely unrelated (leave, record why)
- The consolidate set is fixed, highest-collision-count first —
ServiceStatus,ServiceRegistryandServiceHealthlook like one concept spread across ten files and are the place to start - Any pair guarding a security or policy surface is fixed regardless of count — tech-debt(security): two PasswordChangeRateLimiter classes with different policies and one name #15757 is the worked example of why
- A ratchet freezes the post-triage count so a new collision fails; test doubles excluded by the same rule used here
- The ratchet is proven to fire by adding a duplicate and watching it fail, not by reasoning that it would
Reproduce
python3 - <<'PY'
import ast, collections, subprocess, pathlib
files = [f for f in subprocess.run(["git","ls-files","*.py"],capture_output=True,text=True).stdout.split()
if "node_modules" not in f and not f.endswith("_test.py")
and "/tests/" not in f and "/test_" not in f]
seen = collections.defaultdict(set)
for rel in files:
try: tree = ast.parse(pathlib.Path(rel).read_text(encoding="utf-8"))
except Exception: continue
for node in tree.body:
if isinstance(node, ast.ClassDef): seen[node.name].add(rel)
dup = {k: sorted(v) for k, v in seen.items() if len(v) > 1}
print(len(files), "files;", len(dup), "colliding names")
PY
Activity