forge: Q86 closure for the probe and dump_pending pairs -- both refactored modules resolve the seed from the tree (os.path.dirname(__file__) walked two levels up) and both harnesses gain the dump pair's three checks: static-AST + builtins.open-spy import-time-IO refusal, M6 non-vacuity, and a seed-portability assertion; measured 14/14 probe, 13/13 pending, 14/14 dump (python3 and -O), M6 killed in all three pairs
This commit is contained in:
1 parent
6e9aeeefb7
commit
802a1aea33
4 files changed
+431
-14
No files matched your search
@@ -17,8 +17,12 @@ Smells fixed in this refactor:
|
|||||||
* **Hardcoded path at module scope** -- the JSON path was a bare module-level
|
* **Hardcoded path at module scope** -- the JSON path was a bare module-level
|
||||||
assignment (``path = "/root/troupe-standup/..."``). It is promoted to the
|
assignment (``path = "/root/troupe-standup/..."``). It is promoted to the
|
||||||
named constant ``DEFAULT_PATH`` and made the ``path`` parameter of ``main``,
|
named constant ``DEFAULT_PATH`` and made the ``path`` parameter of ``main``,
|
||||||
so callers (including tests) can override it without editing source. The
|
so callers (including tests) can override it without editing source.
|
||||||
default is unchanged, so output is byte-identical when run with no args.
|
``DEFAULT_PATH`` is derived from this file's location (walked up to the repo
|
||||||
|
root + ``inbox/seed-...json``) rather than a host-absolute literal, so a
|
||||||
|
clone grades its own inbox instead of this host's (Q86). On this host the
|
||||||
|
resolution is identical to the old literal, so output is byte-identical
|
||||||
|
when run with no args.
|
||||||
|
|
||||||
* **No function extraction** -- the three inline print blocks (summary
|
* **No function extraction** -- the three inline print blocks (summary
|
||||||
header, decided iteration, pending iteration) are extracted into
|
header, decided iteration, pending iteration) are extracted into
|
||||||
@@ -36,10 +40,17 @@ Output is byte-identical to the original on the current inbox seed
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
DEFAULT_PATH = "/root/troupe-standup/inbox/seed-2026-09-26.json"
|
# Resolve the seed from THIS file's location so the probe grades the tree it
|
||||||
|
# lives in, not this host's inbox (Q86 portability gap). wizard-work/forge/ is
|
||||||
|
# two levels under the repo root, which holds inbox/.
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
DEFAULT_PATH = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(_HERE)), "inbox", "seed-2026-09-26.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_data(path: str) -> dict[str, Any]:
|
def load_data(path: str) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -24,8 +24,12 @@ Smells fixed in this refactor (behavior-preserving on the probe itself):
|
|||||||
* **Hardcoded path at module scope.** The JSON path was a bare module-level
|
* **Hardcoded path at module scope.** The JSON path was a bare module-level
|
||||||
assignment with no override. It is promoted to the named constant
|
assignment with no override. It is promoted to the named constant
|
||||||
``DEFAULT_PATH`` and made the ``path`` parameter of ``main``, so callers
|
``DEFAULT_PATH`` and made the ``path`` parameter of ``main``, so callers
|
||||||
(including tests) can override it without editing source. The default is
|
(including tests) can override it without editing source. ``DEFAULT_PATH``
|
||||||
unchanged, so output is byte-identical when run with no args.
|
is derived from this file's location (walked up to the repo root +
|
||||||
|
``inbox/seed-...json``) rather than a host-absolute literal, so a clone
|
||||||
|
grades its own inbox instead of this host's (Q86). On this host the
|
||||||
|
resolution is identical to the old literal, so output is byte-identical
|
||||||
|
when run with no args.
|
||||||
|
|
||||||
* **No function extraction / repeated ``d.get``.** The inline print blocks
|
* **No function extraction / repeated ``d.get``.** The inline print blocks
|
||||||
(header, decided iteration, pending iteration) are extracted into the pure
|
(header, decided iteration, pending iteration) are extracted into the pure
|
||||||
@@ -39,8 +43,15 @@ Output is byte-identical to the original on the current inbox seed
|
|||||||
``49d9b87be277211a29c6ba6f685e8516df0f0235c6fa332f52a9d3f00b779f34``.
|
``49d9b87be277211a29c6ba6f685e8516df0f0235c6fa332f52a9d3f00b779f34``.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
DEFAULT_PATH = "/root/troupe-standup/inbox/seed-2026-09-26.json"
|
# Resolve the seed from THIS file's location so the probe grades the tree it
|
||||||
|
# lives in, not this host's inbox (Q86 portability gap). wizard-work/forge/ is
|
||||||
|
# two levels under the repo root, which holds inbox/.
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
DEFAULT_PATH = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(_HERE)), "inbox", "seed-2026-09-26.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_data(path):
|
def load_data(path):
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ probe itself (same JSON input, same output). Checks:
|
|||||||
prints the custom file's contents. This kills the mutation of a
|
prints the custom file's contents. This kills the mutation of a
|
||||||
``path`` parameter that is accepted but ignored (byte-identical on the
|
``path`` parameter that is accepted but ignored (byte-identical on the
|
||||||
real seed would not catch this).
|
real seed would not catch this).
|
||||||
|
10. No import-time I/O (M6 killer): a static AST walk of the top-level
|
||||||
|
statements plus a runtime ``builtins.open`` spy both require zero calls.
|
||||||
|
Mutant M6 (``_bootstrap = json.load(open(DEFAULT_PATH))`` at module scope,
|
||||||
|
no print) survives check 5 (rc 0, zero output) by being *silent*, not
|
||||||
|
clean -- the read happened but was invisible to the silence test.
|
||||||
|
11. M6 is detected by check 10's io-detector (non-vacuity): the detector fires
|
||||||
|
on the measured mutant applied to a temp copy, so it is sensitive rather
|
||||||
|
than theater.
|
||||||
|
12. The seed path is tree-portable: no host-absolute literal in the refactored
|
||||||
|
source, and ``DEFAULT_PATH`` resolves inside the tree. Closes the Q86
|
||||||
|
"a clone grades the host's inbox" gap -- on this host the resolution is
|
||||||
|
identical to the old literal, so check 2 stays byte-identical.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -44,6 +56,16 @@ WORK_DIR = os.path.dirname(os.path.abspath(__file__))
|
|||||||
ORIGINAL = "_triage_dump_pending.py"
|
ORIGINAL = "_triage_dump_pending.py"
|
||||||
REFACTORED = "_triage_dump_pending_refactored.py"
|
REFACTORED = "_triage_dump_pending_refactored.py"
|
||||||
MOD_NAME = REFACTORED[:-3] # strip .py
|
MOD_NAME = REFACTORED[:-3] # strip .py
|
||||||
|
SEED_FILENAME = "seed-2026-09-26.json"
|
||||||
|
# The original probe's literal -- used only to assert the ORIGINAL still carries
|
||||||
|
# the host-absolute smell (clone-independent, unlike comparing against a resolved
|
||||||
|
# real-seed path).
|
||||||
|
HOST_SEED_LITERAL = "/root/troupe-standup/inbox/seed-2026-09-26.json"
|
||||||
|
# The seed is derived from THIS file's location (walked up to the repo root),
|
||||||
|
# not a host-absolute literal, so a clone grades its own inbox (Q86). On this
|
||||||
|
# host the resolution is identical to the old literal -> check 2 stays green.
|
||||||
|
REAL_SEED_PATH = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(WORK_DIR)), "inbox", SEED_FILENAME)
|
||||||
|
|
||||||
|
|
||||||
def _run(*args: str) -> subprocess.CompletedProcess:
|
def _run(*args: str) -> subprocess.CompletedProcess:
|
||||||
@@ -251,17 +273,38 @@ def check_main_path_parameter() -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _original_hardcodes_absolute_seed(src: str) -> bool:
|
||||||
|
"""True if the original assigns an absolute path literal to ``path`` at
|
||||||
|
module scope -- the host-absolute smell the refactor removes.
|
||||||
|
|
||||||
|
AST-based so the test is clone-independent: the original's literal is
|
||||||
|
host-absolute by design, which would break a naive ``REAL_SEED_PATH in
|
||||||
|
code`` test on any checkout whose REAL_SEED_PATH is tree-relative (i.e.
|
||||||
|
everywhere, after this fix).
|
||||||
|
"""
|
||||||
|
tree = ast.parse(src)
|
||||||
|
for stmt in tree.body:
|
||||||
|
if (isinstance(stmt, ast.Assign)
|
||||||
|
and isinstance(stmt.value, ast.Constant)
|
||||||
|
and isinstance(stmt.value.value, str)
|
||||||
|
and stmt.value.value.startswith("/")):
|
||||||
|
for t in stmt.targets:
|
||||||
|
if isinstance(t, ast.Name) and t.id == "path":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def check_original_still_smells() -> bool:
|
def check_original_still_smells() -> bool:
|
||||||
"""The original must still carry the smells we removed (non-vacuous)."""
|
"""The original must still carry the smells we removed (non-vacuous)."""
|
||||||
src = _read(ORIGINAL)
|
src = _read(ORIGINAL)
|
||||||
code = _code_only(src)
|
|
||||||
problems = []
|
problems = []
|
||||||
# The original must LACK a __main__ guard (that is the smell we fixed).
|
# The original must LACK a __main__ guard (that is the smell we fixed).
|
||||||
if '__name__ == "__main__"' in src:
|
if '__name__ == "__main__"' in src:
|
||||||
problems.append("original already has __main__ guard (refactor not needed)")
|
problems.append("original already has __main__ guard (refactor not needed)")
|
||||||
# The hardcoded path must still be at module scope in the original.
|
# The hardcoded path must still be at module scope in the original.
|
||||||
if "/root/troupe-standup/inbox/seed-2026-09-26.json" not in code:
|
if not _original_hardcodes_absolute_seed(src):
|
||||||
problems.append("hardcoded path missing from original (expected)")
|
problems.append("module-scope absolute `path = <literal>` missing from "
|
||||||
|
"original (expected)")
|
||||||
# The original must execute the probe at import time: module-level for-loop.
|
# The original must execute the probe at import time: module-level for-loop.
|
||||||
tree = ast.parse(src)
|
tree = ast.parse(src)
|
||||||
has_module_level_for = any(isinstance(n, ast.For) for n in tree.body)
|
has_module_level_for = any(isinstance(n, ast.For) for n in tree.body)
|
||||||
@@ -315,7 +358,7 @@ def check_path_override_honoured() -> bool:
|
|||||||
print(f"FAIL: main(path=custom) did not read the custom file "
|
print(f"FAIL: main(path=custom) did not read the custom file "
|
||||||
f"(rc={rc}, output={combined!r})")
|
f"(rc={rc}, output={combined!r})")
|
||||||
return False
|
return False
|
||||||
if "/root/troupe-standup/inbox/seed-2026-09-26.json" in combined:
|
if HOST_SEED_LITERAL in combined:
|
||||||
print("FAIL: main(path=custom) printed data from DEFAULT_PATH, not "
|
print("FAIL: main(path=custom) printed data from DEFAULT_PATH, not "
|
||||||
"the custom path")
|
"the custom path")
|
||||||
return False
|
return False
|
||||||
@@ -327,6 +370,158 @@ def check_path_override_honoured() -> bool:
|
|||||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Q86: the refactor-lane harness proved SILENCE at import, not ABSENCE of I/O,
|
||||||
|
# and its seed path was host-absolute. The two fixes below close both gaps.
|
||||||
|
#
|
||||||
|
# A Call to any of these at MODULE SCOPE runs while the module is imported --
|
||||||
|
# the M6 mutant (`_bootstrap = json.load(open(DEFAULT_PATH))`, no print) hides
|
||||||
|
# from check 5 because it emits no stdout, so it needs its own detector.
|
||||||
|
_IO_CALLS = frozenset({
|
||||||
|
"open", "json.load", "json.loads", "pickle.load", "pickle.loads",
|
||||||
|
"csv.reader", "csv.DictReader", "urllib.request.urlopen", "codecs.open",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _call_name(call: ast.Call):
|
||||||
|
"""Dotted name of a Call's function (``json.load``, ``open``, ...), or
|
||||||
|
None for a callable expression this detector does not model."""
|
||||||
|
parts = []
|
||||||
|
cur = call.func
|
||||||
|
while isinstance(cur, ast.Attribute):
|
||||||
|
parts.append(cur.attr)
|
||||||
|
cur = cur.value
|
||||||
|
if isinstance(cur, ast.Name):
|
||||||
|
parts.append(cur.id)
|
||||||
|
return ".".join(reversed(parts))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _calls_at_module_scope(src: str) -> list:
|
||||||
|
"""``[(lineno, name)]`` for io-reading Call nodes at module scope.
|
||||||
|
|
||||||
|
A Call counts as "module scope" only when it is NOT nested inside a
|
||||||
|
FunctionDef / AsyncFunctionDef / ClassDef body -- those run lazily, at call
|
||||||
|
time, and are exactly the I/O the refactor is allowed to keep (it lives
|
||||||
|
under ``main()``). The ``if __name__ == "__main__"`` guard's ``main()``
|
||||||
|
call resolves to ``main`` (not in the io set), so it is correctly ignored.
|
||||||
|
"""
|
||||||
|
tree = ast.parse(src)
|
||||||
|
found: list = []
|
||||||
|
|
||||||
|
def scan(node):
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||||
|
return # do not descend -- calls inside a def body are lazy
|
||||||
|
if isinstance(node, ast.Call):
|
||||||
|
name = _call_name(node)
|
||||||
|
if name in _IO_CALLS:
|
||||||
|
found.append((node.lineno, name))
|
||||||
|
for child in ast.iter_child_nodes(node):
|
||||||
|
scan(child)
|
||||||
|
|
||||||
|
for stmt in tree.body:
|
||||||
|
scan(stmt)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def check_no_import_time_io() -> bool:
|
||||||
|
"""Check 5 proves the import is SILENT, not that no I/O happens. M6
|
||||||
|
(`_bootstrap = json.load(open(DEFAULT_PATH))` at module scope, no print)
|
||||||
|
survives check 5 (rc 0, zero output) while reading the seed at import.
|
||||||
|
Two independent detectors, both must read zero on the clean module:
|
||||||
|
(a) static: AST-walk the top-level statements for an open/json.load call;
|
||||||
|
(b) dynamic: patch builtins.open to a recorder, re-import the module, and
|
||||||
|
require zero calls. The import machinery reads .py source via
|
||||||
|
_io.FileIO (not builtins.open), so this does NOT false-positive on a
|
||||||
|
clean module -- measured separately (0 calls on a fresh import)."""
|
||||||
|
static = _calls_at_module_scope(_read(REFACTORED))
|
||||||
|
if static:
|
||||||
|
print("FAIL: static io-call(s) at module scope: %s" % static)
|
||||||
|
return False
|
||||||
|
import builtins
|
||||||
|
calls: list = []
|
||||||
|
real_open = builtins.open
|
||||||
|
|
||||||
|
def _spy_open(*args, **kwargs):
|
||||||
|
calls.append(args[0] if args else kwargs.get("file"))
|
||||||
|
raise RuntimeError("__check_no_import_time_io__ import-time open()")
|
||||||
|
|
||||||
|
builtins.open = _spy_open
|
||||||
|
try:
|
||||||
|
_mod, exc, _out, _err = _safe_import()
|
||||||
|
finally:
|
||||||
|
builtins.open = real_open
|
||||||
|
if exc is not None:
|
||||||
|
# A RuntimeError carrying our marker is the very failure we reject;
|
||||||
|
# any other exception is a real import error and is ungradable here.
|
||||||
|
if isinstance(exc, RuntimeError) and "__check_no_import_time_io__" in str(exc):
|
||||||
|
print("FAIL: runtime import-time open() on %r (rc 2)" % calls)
|
||||||
|
return False
|
||||||
|
print("FAIL: import raised %s: %r (stdout=%r stderr=%r)"
|
||||||
|
% (type(exc).__name__, exc, _out, _err))
|
||||||
|
return False
|
||||||
|
if calls:
|
||||||
|
print("FAIL: import made %d open() call(s): %r" % (len(calls), calls))
|
||||||
|
return False
|
||||||
|
print("OK: no import-time I/O (0 module-scope io calls; 0 runtime open calls)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_m6_mutant_killed() -> bool:
|
||||||
|
"""Non-vacuity for check 10: a detector that matches nothing is theater.
|
||||||
|
Apply the measured M6 mutation -- a silent module-scope
|
||||||
|
``json.load(open(DEFAULT_PATH))`` inserted before the first def -- to a temp
|
||||||
|
copy of the source and confirm the static io-detector fires on it. The
|
||||||
|
clean module has zero such calls (check 10); the mutant has two, so the
|
||||||
|
detector is sensitive rather than vacuous. (M6 is the mutant measured for
|
||||||
|
Q86: it returns 10/10 under the old check 5.)"""
|
||||||
|
src = _read(REFACTORED)
|
||||||
|
anchor = "def load_data(path: str) -> dict[str, Any]:"
|
||||||
|
m6_expr = "_bootstrap = json.load(open(DEFAULT_PATH))\n"
|
||||||
|
mutated = src.replace(anchor, m6_expr + anchor, 1)
|
||||||
|
if m6_expr not in mutated:
|
||||||
|
# guard against the insertion anchor drifting in the source
|
||||||
|
print("FAIL: M6 mutation could not be applied (anchor %r not found)" % anchor)
|
||||||
|
return False
|
||||||
|
sites = _calls_at_module_scope(mutated)
|
||||||
|
if not sites:
|
||||||
|
print("FAIL: M6 mutant NOT detected by the io-detector (check 10 is vacuous?)")
|
||||||
|
return False
|
||||||
|
print("OK: M6 mutant detected at module scope: %s" % sites)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_seed_path_portable() -> bool:
|
||||||
|
"""Q86 gap #2: the seed path must derive from the tree, not a host-absolute
|
||||||
|
literal, so a clone grades its own inbox. Measured against two claims:
|
||||||
|
(a) the refactored source holds no host-absolute seed literal;
|
||||||
|
(b) importing the module, its DEFAULT_PATH resolves to a path INSIDE this
|
||||||
|
file's tree, and that resolution matches the harness's REAL_SEED_PATH.
|
||||||
|
A clone with its inbox moved aside now grades the clone (and fails on a
|
||||||
|
missing seed) instead of silently grading /root/... on the host."""
|
||||||
|
src = _read(REFACTORED)
|
||||||
|
if HOST_SEED_LITERAL in src:
|
||||||
|
print("FAIL: refactored still hardcodes a host-absolute seed path")
|
||||||
|
return False
|
||||||
|
mod, exc, out, err = _safe_import()
|
||||||
|
if exc is not None or mod is None:
|
||||||
|
print("FAIL: import raised %r (stdout=%r stderr=%r)" % (exc, out, err))
|
||||||
|
return False
|
||||||
|
resolved = os.path.realpath(mod.DEFAULT_PATH)
|
||||||
|
repo_root = os.path.realpath(os.path.dirname(os.path.dirname(WORK_DIR)))
|
||||||
|
if not (resolved == repo_root
|
||||||
|
or resolved.startswith(repo_root + os.sep)):
|
||||||
|
print("FAIL: DEFAULT_PATH %r is outside the tree root %r"
|
||||||
|
% (resolved, repo_root))
|
||||||
|
return False
|
||||||
|
if os.path.realpath(REAL_SEED_PATH) != resolved:
|
||||||
|
print("FAIL: harness REAL_SEED_PATH (%r) != module DEFAULT_PATH (%r)"
|
||||||
|
% (os.path.realpath(REAL_SEED_PATH), resolved))
|
||||||
|
return False
|
||||||
|
print("OK: seed path is tree-portable (resolves inside tree): %s" % resolved)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
checks = [
|
checks = [
|
||||||
check_py_compile,
|
check_py_compile,
|
||||||
@@ -339,6 +534,9 @@ def main() -> None:
|
|||||||
check_main_path_parameter,
|
check_main_path_parameter,
|
||||||
check_original_still_smells,
|
check_original_still_smells,
|
||||||
check_path_override_honoured,
|
check_path_override_honoured,
|
||||||
|
check_no_import_time_io,
|
||||||
|
check_m6_mutant_killed,
|
||||||
|
check_seed_path_portable,
|
||||||
]
|
]
|
||||||
results = [fn() for fn in checks]
|
results = [fn() for fn in checks]
|
||||||
passed = sum(results)
|
passed = sum(results)
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ Checks:
|
|||||||
prints the custom file's contents. This kills the mutation of a ``path``
|
prints the custom file's contents. This kills the mutation of a ``path``
|
||||||
parameter that is accepted but ignored (byte-identical on the real seed
|
parameter that is accepted but ignored (byte-identical on the real seed
|
||||||
would not catch this).
|
would not catch this).
|
||||||
|
12. No import-time I/O (M6 killer): a static AST walk of the top-level
|
||||||
|
statements plus a runtime ``builtins.open`` spy both require zero calls.
|
||||||
|
Mutant M6 (``_bootstrap = json.load(open(DEFAULT_PATH))`` at module scope,
|
||||||
|
no print) survives check 5 (rc 0, zero output) by being *silent*, not
|
||||||
|
clean -- the read happened but was invisible to the silence test.
|
||||||
|
13. M6 is detected by check 12's io-detector (non-vacuity): the detector fires
|
||||||
|
on the measured mutant applied to a temp copy, so it is sensitive rather
|
||||||
|
than theater.
|
||||||
|
14. The seed path is tree-portable: no host-absolute literal in the refactored
|
||||||
|
source, and ``DEFAULT_PATH`` resolves inside the tree. Closes the Q86
|
||||||
|
"a clone grades the host's inbox" gap -- on this host the resolution is
|
||||||
|
identical to the old literal, so check 2 stays byte-identical.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -56,7 +68,16 @@ WORK_DIR = os.path.dirname(os.path.abspath(__file__))
|
|||||||
ORIGINAL = "_triage_probe.py"
|
ORIGINAL = "_triage_probe.py"
|
||||||
REFACTORED = "_triage_probe_refactored.py"
|
REFACTORED = "_triage_probe_refactored.py"
|
||||||
MOD_NAME = REFACTORED[:-3] # strip .py -> _triage_probe_refactored
|
MOD_NAME = REFACTORED[:-3] # strip .py -> _triage_probe_refactored
|
||||||
REAL_SEED_PATH = "/root/troupe-standup/inbox/seed-2026-09-26.json"
|
SEED_FILENAME = "seed-2026-09-26.json"
|
||||||
|
# The original probe's literal -- used only to assert the ORIGINAL still carries
|
||||||
|
# the host-absolute smell (clone-independent, unlike comparing against a resolved
|
||||||
|
# real-seed path).
|
||||||
|
HOST_SEED_LITERAL = "/root/troupe-standup/inbox/seed-2026-09-26.json"
|
||||||
|
# The seed is derived from THIS file's location (walked up to the repo root),
|
||||||
|
# not a host-absolute literal, so a clone grades its own inbox (Q86). On this
|
||||||
|
# host the resolution is identical to the old literal -> check 2 stays green.
|
||||||
|
REAL_SEED_PATH = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(WORK_DIR)), "inbox", SEED_FILENAME)
|
||||||
|
|
||||||
|
|
||||||
def _run(*args: str) -> subprocess.CompletedProcess:
|
def _run(*args: str) -> subprocess.CompletedProcess:
|
||||||
@@ -279,15 +300,36 @@ def check_dead_sys_import_removed() -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _original_hardcodes_absolute_seed(src: str) -> bool:
|
||||||
|
"""True if the original assigns an absolute path literal to ``path`` at
|
||||||
|
module scope -- the host-absolute smell the refactor removes.
|
||||||
|
|
||||||
|
AST-based so the test is clone-independent: the original's literal is
|
||||||
|
host-absolute by design (``/root/.../inbox/seed-....json``), which would
|
||||||
|
break a naive ``REAL_SEED_PATH in code`` test on any checkout whose
|
||||||
|
REAL_SEED_PATH is tree-relative (i.e. everywhere, after this fix).
|
||||||
|
"""
|
||||||
|
tree = ast.parse(src)
|
||||||
|
for stmt in tree.body:
|
||||||
|
if (isinstance(stmt, ast.Assign)
|
||||||
|
and isinstance(stmt.value, ast.Constant)
|
||||||
|
and isinstance(stmt.value.value, str)
|
||||||
|
and stmt.value.value.startswith("/")):
|
||||||
|
for t in stmt.targets:
|
||||||
|
if isinstance(t, ast.Name) and t.id == "path":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def check_original_still_smells() -> bool:
|
def check_original_still_smells() -> bool:
|
||||||
"""The original must still carry the smells we removed (non-vacuous)."""
|
"""The original must still carry the smells we removed (non-vacuous)."""
|
||||||
src = _read(ORIGINAL)
|
src = _read(ORIGINAL)
|
||||||
code = _code_only(src)
|
|
||||||
problems = []
|
problems = []
|
||||||
if '__name__ == "__main__"' in src:
|
if '__name__ == "__main__"' in src:
|
||||||
problems.append("original already has __main__ guard (refactor not needed)")
|
problems.append("original already has __main__ guard (refactor not needed)")
|
||||||
if REAL_SEED_PATH not in code:
|
if not _original_hardcodes_absolute_seed(src):
|
||||||
problems.append("hardcoded path missing from original (expected)")
|
problems.append("module-scope absolute `path = <literal>` missing from "
|
||||||
|
"original (expected)")
|
||||||
tree = ast.parse(src)
|
tree = ast.parse(src)
|
||||||
has_module_level_for = any(isinstance(n, ast.For) for n in tree.body)
|
has_module_level_for = any(isinstance(n, ast.For) for n in tree.body)
|
||||||
if not has_module_level_for:
|
if not has_module_level_for:
|
||||||
@@ -352,6 +394,158 @@ def check_path_override_honoured() -> bool:
|
|||||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Q86: the refactor-lane harness proved SILENCE at import, not ABSENCE of I/O,
|
||||||
|
# and its seed path was host-absolute. The two fixes below close both gaps.
|
||||||
|
#
|
||||||
|
# A Call to any of these at MODULE SCOPE runs while the module is imported --
|
||||||
|
# the M6 mutant (`_bootstrap = json.load(open(DEFAULT_PATH))`, no print) hides
|
||||||
|
# from check 5 because it emits no stdout, so it needs its own detector.
|
||||||
|
_IO_CALLS = frozenset({
|
||||||
|
"open", "json.load", "json.loads", "pickle.load", "pickle.loads",
|
||||||
|
"csv.reader", "csv.DictReader", "urllib.request.urlopen", "codecs.open",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _call_name(call: ast.Call):
|
||||||
|
"""Dotted name of a Call's function (``json.load``, ``open``, ...), or
|
||||||
|
None for a callable expression this detector does not model."""
|
||||||
|
parts = []
|
||||||
|
cur = call.func
|
||||||
|
while isinstance(cur, ast.Attribute):
|
||||||
|
parts.append(cur.attr)
|
||||||
|
cur = cur.value
|
||||||
|
if isinstance(cur, ast.Name):
|
||||||
|
parts.append(cur.id)
|
||||||
|
return ".".join(reversed(parts))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _calls_at_module_scope(src: str) -> list:
|
||||||
|
"""``[(lineno, name)]`` for io-reading Call nodes at module scope.
|
||||||
|
|
||||||
|
A Call counts as "module scope" only when it is NOT nested inside a
|
||||||
|
FunctionDef / AsyncFunctionDef / ClassDef body -- those run lazily, at call
|
||||||
|
time, and are exactly the I/O the refactor is allowed to keep (it lives
|
||||||
|
under ``main()``). The ``if __name__ == "__main__"`` guard's ``main()``
|
||||||
|
call resolves to ``main`` (not in the io set), so it is correctly ignored.
|
||||||
|
"""
|
||||||
|
tree = ast.parse(src)
|
||||||
|
found: list = []
|
||||||
|
|
||||||
|
def scan(node):
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||||
|
return # do not descend -- calls inside a def body are lazy
|
||||||
|
if isinstance(node, ast.Call):
|
||||||
|
name = _call_name(node)
|
||||||
|
if name in _IO_CALLS:
|
||||||
|
found.append((node.lineno, name))
|
||||||
|
for child in ast.iter_child_nodes(node):
|
||||||
|
scan(child)
|
||||||
|
|
||||||
|
for stmt in tree.body:
|
||||||
|
scan(stmt)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def check_no_import_time_io() -> bool:
|
||||||
|
"""Check 5 proves the import is SILENT, not that no I/O happens. M6
|
||||||
|
(`_bootstrap = json.load(open(DEFAULT_PATH))` at module scope, no print)
|
||||||
|
survives check 5 (rc 0, zero output) while reading the seed at import.
|
||||||
|
Two independent detectors, both must read zero on the clean module:
|
||||||
|
(a) static: AST-walk the top-level statements for an open/json.load call;
|
||||||
|
(b) dynamic: patch builtins.open to a recorder, re-import the module, and
|
||||||
|
require zero calls. The import machinery reads .py source via
|
||||||
|
_io.FileIO (not builtins.open), so this does NOT false-positive on a
|
||||||
|
clean module -- measured separately (0 calls on a fresh import)."""
|
||||||
|
static = _calls_at_module_scope(_read(REFACTORED))
|
||||||
|
if static:
|
||||||
|
print("FAIL: static io-call(s) at module scope: %s" % static)
|
||||||
|
return False
|
||||||
|
import builtins
|
||||||
|
calls: list = []
|
||||||
|
real_open = builtins.open
|
||||||
|
|
||||||
|
def _spy_open(*args, **kwargs):
|
||||||
|
calls.append(args[0] if args else kwargs.get("file"))
|
||||||
|
raise RuntimeError("__check_no_import_time_io__ import-time open()")
|
||||||
|
|
||||||
|
builtins.open = _spy_open
|
||||||
|
try:
|
||||||
|
_mod, exc, _out, _err = _safe_import()
|
||||||
|
finally:
|
||||||
|
builtins.open = real_open
|
||||||
|
if exc is not None:
|
||||||
|
# A RuntimeError carrying our marker is the very failure we reject;
|
||||||
|
# any other exception is a real import error and is ungradable here.
|
||||||
|
if isinstance(exc, RuntimeError) and "__check_no_import_time_io__" in str(exc):
|
||||||
|
print("FAIL: runtime import-time open() on %r (rc 2)" % calls)
|
||||||
|
return False
|
||||||
|
print("FAIL: import raised %s: %r (stdout=%r stderr=%r)"
|
||||||
|
% (type(exc).__name__, exc, _out, _err))
|
||||||
|
return False
|
||||||
|
if calls:
|
||||||
|
print("FAIL: import made %d open() call(s): %r" % (len(calls), calls))
|
||||||
|
return False
|
||||||
|
print("OK: no import-time I/O (0 module-scope io calls; 0 runtime open calls)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_m6_mutant_killed() -> bool:
|
||||||
|
"""Non-vacuity for check 12: a detector that matches nothing is theater.
|
||||||
|
Apply the measured M6 mutation -- a silent module-scope
|
||||||
|
``json.load(open(DEFAULT_PATH))`` inserted before the first def -- to a temp
|
||||||
|
copy of the source and confirm the static io-detector fires on it. The
|
||||||
|
clean module has zero such calls (check 12); the mutant has two, so the
|
||||||
|
detector is sensitive rather than vacuous. (M6 is the mutant measured for
|
||||||
|
Q86: it returns 11/11 under the old check 5.)"""
|
||||||
|
src = _read(REFACTORED)
|
||||||
|
anchor = "def load_data(path):"
|
||||||
|
m6_expr = "_bootstrap = json.load(open(DEFAULT_PATH))\n"
|
||||||
|
mutated = src.replace(anchor, m6_expr + anchor, 1)
|
||||||
|
if m6_expr not in mutated:
|
||||||
|
# guard against the insertion anchor drifting in the source
|
||||||
|
print("FAIL: M6 mutation could not be applied (anchor %r not found)" % anchor)
|
||||||
|
return False
|
||||||
|
sites = _calls_at_module_scope(mutated)
|
||||||
|
if not sites:
|
||||||
|
print("FAIL: M6 mutant NOT detected by the io-detector (check 12 is vacuous?)")
|
||||||
|
return False
|
||||||
|
print("OK: M6 mutant detected at module scope: %s" % sites)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_seed_path_portable() -> bool:
|
||||||
|
"""Q86 gap #2: the seed path must derive from the tree, not a host-absolute
|
||||||
|
literal, so a clone grades its own inbox. Measured against two claims:
|
||||||
|
(a) the refactored source holds no host-absolute seed literal;
|
||||||
|
(b) importing the module, its DEFAULT_PATH resolves to a path INSIDE this
|
||||||
|
file's tree, and that resolution matches the harness's REAL_SEED_PATH.
|
||||||
|
A clone with its inbox moved aside now grades the clone (and fails on a
|
||||||
|
missing seed) instead of silently grading /root/... on the host."""
|
||||||
|
src = _read(REFACTORED)
|
||||||
|
if HOST_SEED_LITERAL in src:
|
||||||
|
print("FAIL: refactored still hardcodes a host-absolute seed path")
|
||||||
|
return False
|
||||||
|
mod, exc, out, err = _safe_import()
|
||||||
|
if exc is not None or mod is None:
|
||||||
|
print("FAIL: import raised %r (stdout=%r stderr=%r)" % (exc, out, err))
|
||||||
|
return False
|
||||||
|
resolved = os.path.realpath(mod.DEFAULT_PATH)
|
||||||
|
repo_root = os.path.realpath(os.path.dirname(os.path.dirname(WORK_DIR)))
|
||||||
|
if not (resolved == repo_root
|
||||||
|
or resolved.startswith(repo_root + os.sep)):
|
||||||
|
print("FAIL: DEFAULT_PATH %r is outside the tree root %r"
|
||||||
|
% (resolved, repo_root))
|
||||||
|
return False
|
||||||
|
if os.path.realpath(REAL_SEED_PATH) != resolved:
|
||||||
|
print("FAIL: harness REAL_SEED_PATH (%r) != module DEFAULT_PATH (%r)"
|
||||||
|
% (os.path.realpath(REAL_SEED_PATH), resolved))
|
||||||
|
return False
|
||||||
|
print("OK: seed path is tree-portable (resolves inside tree): %s" % resolved)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
checks = [
|
checks = [
|
||||||
check_py_compile,
|
check_py_compile,
|
||||||
@@ -365,6 +559,9 @@ def main() -> None:
|
|||||||
check_dead_sys_import_removed,
|
check_dead_sys_import_removed,
|
||||||
check_original_still_smells,
|
check_original_still_smells,
|
||||||
check_path_override_honoured,
|
check_path_override_honoured,
|
||||||
|
check_no_import_time_io,
|
||||||
|
check_m6_mutant_killed,
|
||||||
|
check_seed_path_portable,
|
||||||
]
|
]
|
||||||
results = [fn() for fn in checks]
|
results = [fn() for fn in checks]
|
||||||
passed = sum(results)
|
passed = sum(results)
|
||||||
|
|||||||
Reference in new issue
Block a user