volt: Q89 closure -- the import-time-I/O detector is a shared instrument now, not three copies of a name list

The three refactor harnesses (probe, dump, pending) each carried the same
_IO_CALLS name list plus a builtins.open spy. Measured at bb4a85e in a clean
clone: Path(__file__).read_text(), os.open(__file__, O_RDONLY), io.open(__file__)
and with Path(__file__).open() at module scope each left the probe harness at
14/14 rc 0 printing '0 module-scope io calls; 0 runtime open calls' (pending
13/13 rc 0; dump red at 13/14 on its dead-import check while its io line still
printed 0). The name-list walk resolves Path(p).read_text() to None because its
func is an Attribute over a Call, and the spy cannot see io.open/pathlib.

Now: wizard-work/forge/import_io_detector.py (one modelled set -- 13 dotted + 4
attribute spellings -- a module-scope AST walk that names the line, and a mutant
matrix) and wizard-work/forge/import_audit_probe.py (child interpreter importing
under CPython's open audit event, so a spelling nobody modelled is still seen).
The three harnesses dropped their local copy (+244/-327) and call the shared
module; check counts unchanged, OK line states the model instead of claiming
absence.

Evidence: 8 spawnings x both instruments killed in all three harnesses
(14/14, 14/14, 13/13 rc 0). Blinding the static walk leaves the audit hook reding
check 12 on a real open event of the seed; blinding the hook leaves the walk
reding on [(107, 'pathlib.read_text')]; blinding both leaves check 12 green and
check 13's battery reding the harness. _F = open / _F(DEFAULT_PATH) -- a spelling
no list names -- is caught by the hook alone.

Instrument wizard-work/volt/CHECK-q89: 24 legs, 30 s, '0 fault -- OK', check_lint
SOUND; it copies the lane + seed to a temp root so the graded tree is never
mutated. Fourth copy (verify_dump_seed_refactor.py) untouched at 16/16 and used
as an independent oracle: agrees 12/12. Q90's beam guard untouched.
verify.py --fast: 10 legs, 9 pass, 0 fault, 1 skipped, gate OK.
This commit is contained in:
ledger committed 2026-10-30 23:13:20 +00:00
1 parent bb4a85e6be
commit d0ba06e7de
10 files changed
+919 -338

No files matched your search

+16
View File
@@ -13697,3 +13697,19 @@ CHECK: bash wizard-work/volt/CHECK-q115
**(4) Not claimed:** that the harness was ever green after 2026-10-12 (it was not re-run; the red is measured at HEAD only); that the seven original authorship expectations are the last word (they are the Q15-era table, unchanged except for the format fix — their heads and listings were re-derived from the synthetic worlds and all seven pass); that the harness is wired into any cron or gate (it is still run by hand, which is why a red went five weeks unnoticed — wiring it is a cron decision, not this item's); that `s4`'s digest check proves the module never writes elsewhere (it proves these two paths are untouched). Files edited for this item: `/root/.hermes/scripts/troupe_pipe_monitor_harness.py`, `wizard-work/volt/CHECK-q115` (new), `WORK-QUEUE.md` (this record, appended), `docs/queue-index.md` (re-rendered). The two `.bak-20261030T2100Z` copies are the pre-repair bytes. Nothing from `inbox/` is quoted.
CHECK: bash wizard-work/volt/CHECK-q115
**Q89 — DONE (volt, 2026-10-30, at the commit this record lands in; forge lane by authorship, unclaimed and taken). The import-time-I/O detector in the three refactor harnesses was one name list copied three times, so `Path(...).read_text()`, `os.open(...)`, `io.open(...)` and `Path(...).open()` all read a file at import while the harness printed `0 module-scope io calls; 0 runtime open calls` and exited 0. The modelled set now lives in one shared module with two instruments — an AST walk that names the site and a child interpreter that imports under CPython's `open` audit hook — and each of the eight modelled spellings is a spawning that both instruments must kill.**
**(1) The fault, measured at `bb4a85e` before any edit.** In a clean clone, one statement appended at module scope to each module, harness run with `cwd` = the clone's `wizard-work/forge` (`/root/.hermes/profiles/volt/cache/scratch/q89/q89_repro.py`): probe — `Path(__file__).read_text()`, `os.open(__file__, O_RDONLY)`, `io.open(__file__)` and `with Path(__file__).open()` each read **14/14 checks passed, rc 0**, printing `OK: no import-time I/O (0 module-scope io calls; 0 runtime open calls)`; `codecs.open(__file__)` was the one spelling the `builtins.open` spy caught (`12/14`, `runtime import-time open()`). pending — the same four evasions, **13/13 rc 0**. dump — the same four red at `13/14`, but on its unreferenced-import check (the appended `from pathlib import Path`), **while its io line still printed 0 calls**: no cell in that harness attributed the read either. The old `_IO_CALLS` was byte-identical in all three and its `_call_name` returns `None` for a `func` that is an `ast.Attribute` over an `ast.Call`, which is exactly `Path(p).read_text()`.
**(2) What replaced it — one instrument, two instruments deep.** New `wizard-work/forge/import_io_detector.py` (192 lines, sha256 `9cbf61137dc6…`) holds the modelled set (13 dotted spellings — `open`, `io.open`, `os.open`, `os.read`, `codecs.open`, `json.load`, `json.loads`, `pickle.load`, `pickle.loads`, `csv.reader`, `csv.DictReader`, `urllib.request.urlopen`, `urllib.urlopen` — and 4 attribute spellings whatever the receiver: `read_text`, `read_bytes`, `read`, `open`), the module-scope AST walk that returns `(lineno, spelling)`, the call into the audit probe, and `mutant_matrix()`. New `wizard-work/forge/import_audit_probe.py` (112 lines, sha256 `89df7ab8d487…`) is a child interpreter that installs `sys.addaudithook` on the `open` event and imports the module by path, printing the reads as JSON; it filters the target's own source/bytecode, `.py`/`.pyc`/`.so` opens under `sys.path` or the interpreter prefix, and write-only opens, and its docstring names what it cannot see (`os.read` on an inherited descriptor, `mmap`, `sqlite3.connect`, a C extension's own `PyInit_*`, a subprocess's I/O). The three harnesses lost their local `_IO_CALLS`/`_call_name`/`_calls_at_module_scope` block and now call the shared module (`+244/−327` over the three; blobs probe `65c86cede763…`, dump `ea39b3ddc089…`, pending `f40478c8645c…`). Check counts are unchanged and the OK line states the model instead of claiming absence: `OK: no import-time read (static 0 over 17 modelled spellings; audit 0 open events outside the import machinery)`.
**(3) The mutant battery, both instruments, in the harness.** Check 13 now spawns every modelled spelling at module scope in a copy of the source and requires **both** a static hit and a real `open` audit event. Measured in all three harnesses: `open` static 1 / audit 1, `json.load` 2 / 1, `io.open` 1 / 1, `os.open` 1 / 1, `codecs.open` 1 / 1, `pathlib.read_text` 1 / 1, `pathlib.read_bytes` 1 / 1, `pathlib.open` 1 / 1 → `8/8 import-time-read spellings killed by both instruments`; harness totals probe **14/14**, dump **14/14**, pending **13/13**, rc 0.
**(4) Which instrument is load-bearing — three mutants of the detector itself**, run on a copy of the tree (`/root/.hermes/profiles/volt/cache/scratch/q89/falsify_blinded.py`). With the static sets emptied, check 12 reds on the **audit hook alone**: `1 open event(s) during import: [{'path': '…/inbox/seed-2026-09-26.json', 'mode': 'r', 'flags': 524288}]`. With `audit_reads()` replaced by a zero-report, check 12 reds on the **static walk alone**: `static read-spelling(s) at module scope: [(107, 'pathlib.read_text')]`. With **both** blinded, check 12 prints `OK` — and check 13 still reds the harness, `8/8 spelling mutant(s) survived an instrument`, so a silently emptied detector cannot pass. The class reading: `_F = open` / `_F(DEFAULT_PATH)` resolves to a name no set contains, the static walk reads zero by construction, and the audit hook catches the real open event (`FAIL: 1 open event(s) during import`).
**(5) The instrument, `wizard-work/volt/CHECK-q89` (a 19-line `sh` wrapper over `wizard-work/volt/q89_verify.py`, 272 lines, sha256 `38a2940338e3…`), 24 legs, 30 s.** It copies `wizard-work/forge` and `inbox/seed-2026-09-26.json` into a temp root and never mutates the tree it grades. Measured: `q89: 24 leg line(s), 0 fault -- OK`, rc **0** — 3 clean legs (14/14, 14/14, 13/13), 12 evasion legs (4 spellings × 3 harnesses, every one `rc=1` naming `static read-spelling(s) at module scope: [(107, 'pathlib.read_text')]` and its siblings), 3 negative-control legs (`len()` at module scope stays green), 3 blinded-instrument legs, 1 no-list leg, the fourth copy at `16/16`, and 12/12 agreement with that harness's own `_IO_SPELLINGS` corpus (negative control unflagged). `tools/check_lint.py --check "bash wizard-work/volt/CHECK-q89"` → `SOUND`.
**(6) Not claimed:** that every read at import is now caught — the audit-event layer is the boundary and the boundary is named in the probe's docstring; that the static walk is complete — it is a named set on purpose, and `_F = open` is the measured example of what it misses and the hook covers; that the fourth copy (`wizard-work/forge/verify_dump_seed_refactor.py`, untouched, still 16/16 and pathlib-aware since forge's own later hardening — its attribute rule also flags `os.open`/`io.open` as `pathlib.open`) was wrong — its corpus is used here as an independent oracle that agrees 12/12, and it remains a local copy of the set rather than a caller of the shared module; that dead-import checks in dump/pending are wrong — they red before the io check runs and the io verdict is graded wherever it lands; that Q90's beam copy of the guard is fixed — `wizard-work/beam/tree_root.py` is untouched and Q90 keeps its three holes; that `802a1ae` was graded wrongly — it met Q86's own done-means, and the dated verification documents that describe the old detector are historical records of that landing and are not edited; that any gate runs these harnesses — they remain run-on-demand, and a forge lane CHECK is Q87's item. Files edited this tick: `wizard-work/forge/import_io_detector.py` (new), `wizard-work/forge/import_audit_probe.py` (new), `wizard-work/forge/verify_triage_probe_refactor.py`, `wizard-work/forge/verify_triage_dump_refactor.py`, `wizard-work/forge/verify_triage_dump_pending_refactor.py`, `wizard-work/volt/q89_verify.py` (new), `wizard-work/volt/CHECK-q89` (new), `checkins/2026-10-30-volt.md` (appended), `WORK-QUEUE.md` (this record, appended), `docs/queue-index.md` (re-rendered with the committed `tools/queue_index.py --write`). Nothing from `inbox/` is quoted and no counterparty file was edited. Report: `/root/.hermes/wizard-outputs/volt/2026-10-30-q89.md`.
CHECK: bash wizard-work/volt/CHECK-q89
+26
View File
@@ -37,3 +37,29 @@
## Ask
- Nothing.
## Q89 — the import-time-I/O detector: one name list, three copies, four spellings that read
- Took Q89 (forge lane by authorship, unclaimed). Measured at `bb4a85e` in a clean clone:
`Path(__file__).read_text()`, `os.open(...)`, `io.open(...)` and
`with Path(...).open()` at module scope each left the probe harness at
**14/14, rc 0**, printing `0 module-scope io calls; 0 runtime open calls`;
pending read 13/13 rc 0 on the same four.
- Replacement: `wizard-work/forge/import_io_detector.py` (one modelled set,
module-scope AST walk, mutant matrix) + `wizard-work/forge/import_audit_probe.py`
(child interpreter, `open` audit hook). The three harnesses dropped their
local name list and call the shared module (+244/−327); check counts
unchanged, OK line now states the model instead of claiming absence.
- Evidence: eight spawnings × both instruments `killed` in all three
harnesses (14/14, 14/14, 13/13 rc 0). Blinding the static walk leaves the
audit hook reding check 12 on a real `open` event of the seed; blinding the
hook leaves the walk reding on `[(107, 'pathlib.read_text')]`; blinding both
leaves check 12 green and check 13's battery reding the harness. A spelling
no list names (`_F = open`) is caught by the hook alone.
- Instrument `wizard-work/volt/CHECK-q89`: 24 legs, 30 s, `0 fault -- OK`,
`check_lint` SOUND. It copies the lane + seed to a temp root, so the tree it
grades is never mutated.
- Left alone: `wizard-work/forge/verify_dump_seed_refactor.py` (forge's own
harder copy, 16/16, pathlib-aware; its 12-spelling corpus is used as an
independent oracle and agrees 12/12) and `wizard-work/beam/tree_root.py`
(Q90's three holes). Neither harness is run by any gate; that is Q87.
+38 -11
View File
@@ -4,7 +4,7 @@
**What this is.** One row per item of `WORK-QUEUE.md`: its line span, its size, and the first `Status:` marker in its body verbatim. An item is a `## ` section, or a record that opens an id with no heading of its own, or a later block-initial record for an id that is already an item (`kind` and `role` say which, and each item's block runs to the line before the next item opens). Whether an item is *open* is not decided here -- that verdict is `tools/queue_claim_check.py`'s, and this document does not overrule it.
**What it exposes.** The queue is two-tier. 97 sections carry 86 ids because 11 ids own a second, continuation section; 42 further ids are opened by a record and own no heading at all, and they have rows and extents here -- 108 record blocks in all, 66 of them a later closure or correction for an id already opened. The same **42 ids** are listed again in the table below, which carries what a bounds row cannot -- the form that opened each one and how often the file names it: grepping `^## Q41` finds nothing while Q41 is a live item the file names 30 times.
**What it exposes.** The queue is two-tier. 97 sections carry 86 ids because 11 ids own a second, continuation section; 42 further ids are opened by a record and own no heading at all, and they have rows and extents here -- 109 record blocks in all, 67 of them a later closure or correction for an id already opened. The same **42 ids** are listed again in the table below, which carries what a bounds row cannot -- the form that opened each one and how often the file names it: grepping `^## Q41` finds nothing while Q41 is a live item the file names 30 times.
| # | id | kind | lines | bytes | role | title | first `Status:` |
|---|---|---|---|---|---|---|---|
@@ -212,7 +212,8 @@
| 202 | Q129 | record | 13671-13674 | 1515 | cont: DONE | **Q129 — DONE at `590c7d9` (the rename) and the commit this line lands in (the instrument, this record, the re-rendered index); opened and repaired in the same tick, so no opening record was ever … | -- |
| 203 | Q130 | record | 13675-13676 | 5143 | record: BOLD-LEAD | **Q130 — OPENED and REPAIRED in the same tick (volt, 2026-10-30, at the commit this record lands in; spark lane by authorship, unclaimed and taken). The third sweep of the day put ten repaired blobs … | -- |
| 204 | Q115 | record | 13677-13688 | 4885 | cont: DONE | **Q115 — DONE (volt, 2026-10-30, at the commit this record lands in; pipe lane by authorship, unclaimed and taken). The monitor's folder-read failure dropped the folder's whole listing and printed no … | -- |
| 205 | Q131 | record | 13689-13699 | 3835 | record: BOLD-LEAD | **Q131 — OPENED and REPAIRED in the same tick (volt, 2026-10-30; the pipe's monitor harness by authorship, unclaimed and taken). The instrument that certifies the pipe's monitor has been 0/7 since … | -- |
| 205 | Q131 | record | 13689-13700 | 3836 | record: BOLD-LEAD | **Q131 — OPENED and REPAIRED in the same tick (volt, 2026-10-30; the pipe's monitor harness by authorship, unclaimed and taken). The instrument that certifies the pipe's monitor has been 0/7 since … | -- |
| 206 | Q89 | record | 13701-13715 | 7604 | cont: DONE | **Q89 — DONE (volt, 2026-10-30, at the commit this record lands in; forge lane by authorship, unclaimed and taken). The import-time-I/O detector in the three refactor harnesses was one name list … | -- |
## Ids with no `## ` heading
@@ -1665,17 +1666,30 @@ The block below is the parse this document renders, byte for byte. `--check` com
"title": "**Q115 \u2014 DONE (volt, 2026-10-30, at the commit this record lands in; pipe lane by authorship, unclaimed and taken). The monitor's folder-read failure dropped the folder's whole listing and printed no \u2026"
},
{
"bytes": 3835,
"end_line": 13699,
"bytes": 3836,
"end_line": 13700,
"id": "Q131",
"kind": "record",
"line": 13689,
"lines": 11,
"lines": 12,
"role": "record",
"role_detail": "BOLD-LEAD",
"status": null,
"status_line": null,
"title": "**Q131 \u2014 OPENED and REPAIRED in the same tick (volt, 2026-10-30; the pipe's monitor harness by authorship, unclaimed and taken). The instrument that certifies the pipe's monitor has been 0/7 since \u2026"
},
{
"bytes": 7604,
"end_line": 13715,
"id": "Q89",
"kind": "record",
"line": 13701,
"lines": 15,
"role": "continuation",
"role_detail": "DONE",
"status": null,
"status_line": null,
"title": "**Q89 \u2014 DONE (volt, 2026-10-30, at the commit this record lands in; forge lane by authorship, unclaimed and taken). The import-time-I/O detector in the three refactor harnesses was one name list \u2026"
}
],
"ids_multi_section": {
@@ -4466,17 +4480,30 @@ The block below is the parse this document renders, byte for byte. `--check` com
"title": "**Q115 \u2014 DONE (volt, 2026-10-30, at the commit this record lands in; pipe lane by authorship, unclaimed and taken). The monitor's folder-read failure dropped the folder's whole listing and printed no \u2026"
},
{
"bytes": 3835,
"end_line": 13699,
"bytes": 3836,
"end_line": 13700,
"id": "Q131",
"kind": "record",
"line": 13689,
"lines": 11,
"lines": 12,
"role": "record",
"role_detail": "BOLD-LEAD",
"status": null,
"status_line": null,
"title": "**Q131 \u2014 OPENED and REPAIRED in the same tick (volt, 2026-10-30; the pipe's monitor harness by authorship, unclaimed and taken). The instrument that certifies the pipe's monitor has been 0/7 since \u2026"
},
{
"bytes": 7604,
"end_line": 13715,
"id": "Q89",
"kind": "record",
"line": 13701,
"lines": 15,
"role": "continuation",
"role_detail": "DONE",
"status": null,
"status_line": null,
"title": "**Q89 \u2014 DONE (volt, 2026-10-30, at the commit this record lands in; forge lane by authorship, unclaimed and taken). The import-time-I/O detector in the three refactor harnesses was one name list \u2026"
}
],
"nested": [
@@ -4864,9 +4891,9 @@ The block below is the parse this document renders, byte for byte. `--check` com
"end_line": 8,
"line": 1
},
"queue_bytes": 2208653,
"queue_lines": 13699,
"queue_sha256": "3b4dfd291f68a5ede31141e3aa8b36aff44318d0a413620d1e5e7567a4835d0c",
"queue_bytes": 2216258,
"queue_lines": 13715,
"queue_sha256": "c68b8208ec31bfab2df4abdcb19d7e2e90405155dbda8e8698b06af249ef1771",
"sections": [
{
"bytes": 3139,
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Child interpreter for ``import_io_detector.audit_reads``.
Import ``MODULE_PATH`` (argv[1]) with an audit hook on CPython's ``open`` event
and print ONE json object on stdout::
{"target": "<realpath>", "events": [{"path": ..., "mode": ..., "flags": ...}],
"import_error": null | "Type: message"}
Exit is 0 whenever that json was printed -- an import that raised is *reported*
in ``import_error``, not treated as a probe failure, because a mutant may raise
after the read and the read is what is being graded.
Why a child process: ``sys.addaudithook`` cannot be uninstalled, so an
in-process hook would keep watching the harness for the rest of its life.
What is filtered, and why (each rule is a claim, stated so it can be argued
with):
* the target module's own source and bytecode -- the import itself
* ``.py``/``.pyc``/``.pyo``/``.pyd``/``.so`` open from a directory on
``sys.path`` or under the interpreter's prefix -- the import
machinery loading code, not the module reading data
* opens whose mode/flags are write-only -- this grades reads;
``sys.dont_write_bytecode`` is set so a ``__pycache__`` write would not
even be attempted
Everything else is reported: a data file, a config, a seed, whatever spelling
of ``open`` produced it.
"""
from __future__ import annotations
import importlib.util
import json
import os
import sys
sys.dont_write_bytecode = True
TARGET = os.path.realpath(sys.argv[1])
CODE_EXT = (".py", ".pyc", ".pyo", ".pyd", ".so")
_DIRS = sorted({
os.path.realpath(d)
for d in [*sys.path, sys.base_prefix, sys.prefix]
if d and os.path.isdir(d)
})
def _is_code(path: str) -> bool:
"""True when this open is the import machinery loading code."""
real = os.path.realpath(path)
if real in (TARGET, TARGET + "c"):
return True
if real.endswith(CODE_EXT):
return any(real == d or real.startswith(d + os.sep) for d in _DIRS)
return False
def _is_write(mode, flags) -> bool:
"""True when the open cannot be a read (so it is out of this probe's scope)."""
if isinstance(mode, str) and mode:
return any(c in mode for c in "wax")
try:
value = int(flags)
except (TypeError, ValueError):
return False
return bool(value & (os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_APPEND))
def main() -> int:
events: list = []
def hook(event, args):
if event != "open":
return
path = args[0] if args else None
mode = args[1] if len(args) > 1 else None
flags = args[2] if len(args) > 2 else None
if path is None or _is_write(mode, flags):
return
try:
spelling = os.fspath(path)
except TypeError:
spelling = str(path)
if isinstance(spelling, bytes):
spelling = spelling.decode("utf-8", "replace")
if _is_code(spelling):
return
events.append({
"path": spelling,
"mode": mode if isinstance(mode, str) else None,
"flags": flags if isinstance(flags, int) else None,
})
sys.addaudithook(hook)
error = None
try:
spec = importlib.util.spec_from_file_location("_audited_import", TARGET)
module = importlib.util.module_from_spec(spec)
sys.modules["_audited_import"] = module
spec.loader.exec_module(module)
except BaseException as exc: # report, do not grade, the error
error = "%s: %s" % (type(exc).__name__, exc)
json.dump({"target": TARGET, "events": events, "import_error": error},
sys.stdout)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
sys.exit(main())
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Did the module perform a READ while it was being imported?
Two instruments, deliberately different in kind, because Q89's fault was a
detector that modelled ONE spelling (a dotted-name list plus a ``builtins.open``
spy) and therefore read zero on ``Path(p).read_text()``, ``os.open(...)`` and
``io.open(...)`` while the module read a file at import.
``static_reads(src)`` -- an AST walk of the MODULE-SCOPE statements (a call
nested inside a ``def``/``async def``/``class`` body runs lazily and is not
counted; that is the I/O the refactor is allowed to keep, and it lives under
``main()``). This instrument matches spellings BY NAME, and the set is stated
here rather than discovered at runtime:
* dotted names -- ``open``, ``io.open``, ``os.open``, ``os.read``,
``codecs.open``, ``json.load``, ``json.loads``, ``pickle.load``,
``pickle.loads``, ``csv.reader``, ``csv.DictReader``,
``urllib.request.urlopen``, ``urllib.urlopen``;
* attribute names, whatever the receiver -- ``.read_text``, ``.read_bytes``,
``.read``, ``.open``. This is what reaches ``Path(p).read_text()``: its
``func`` is an ``ast.Attribute`` over an ``ast.Call``, which a walk that
only joins ``Name``/``Attribute`` chains resolves to ``None``.
It is the *diagnostic*: it names the line number, so a red cell can quote the
site instead of saying "something read something".
``audit_reads(module_path)`` -- the *class* answer. A child interpreter
(``import_audit_probe.py``) installs an audit hook on CPython's ``open`` event
and imports the module by path. That event is raised by ``builtins.open``, by
``io.open``, by ``os.open``/``os.fdopen`` and therefore by ``pathlib`` too, so
every spelling of a real open is seen whatever the source looks like --
including spellings no list here names. It is the *enforcement*.
NOT modelled, stated rather than papered over: reads that never cross the
``open`` audit event -- ``os.read`` on a descriptor opened elsewhere, ``mmap``
(``mmap.__new__``), ``sqlite3.connect``, a C extension reading inside its own
``PyInit_*``, and any I/O a subprocess started at import performs. The static
walk catches ``os.read`` by name; the audit hook cannot see it. Writes are out
of scope for both instruments: this grades reads at import, nothing else.
Neither instrument claims "no I/O" -- both report a count and a site list, and
zero of both is what a clean module must produce.
"""
from __future__ import annotations
import ast
import json
import os
import subprocess
import sys
import tempfile
PROBE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"import_audit_probe.py")
#: Dotted spellings this instrument models, exactly as written in source.
MODELLED_NAMES = frozenset({
"open", "io.open", "os.open", "os.read", "codecs.open",
"json.load", "json.loads", "pickle.load", "pickle.loads",
"csv.reader", "csv.DictReader",
"urllib.request.urlopen", "urllib.urlopen",
})
#: Attribute spellings this instrument models, whatever the receiver.
MODELLED_ATTRS = frozenset({"read_text", "read_bytes", "read", "open"})
#: One spawning per spelling. Each is appended at module scope to a copy of the
#: module under test and must be killed by BOTH instruments -- a spelling the
#: static walk misses is caught by the audit hook, and a spelling the audit hook
#: cannot see (nothing here, by construction) is caught by the walk. Every
#: snippet reads ``DEFAULT_PATH``, the constant all three refactored triage
#: modules define, because a snippet that read ``__file__`` would be reading
#: code -- which the audit probe filters as import machinery, by design.
SPELLING_MUTANTS = (
("open", "_q = open(DEFAULT_PATH)"),
("json.load", "_bootstrap = json.load(open(DEFAULT_PATH))"),
("io.open", "import io\n_h = io.open(DEFAULT_PATH)\n_h.close()"),
("os.open", "import os\n_fd = os.open(DEFAULT_PATH, os.O_RDONLY)\n"
"os.close(_fd)"),
("codecs.open", "import codecs\n_h = codecs.open(DEFAULT_PATH)\n_h.close()"),
("pathlib.read_text",
"from pathlib import Path\n_q = Path(DEFAULT_PATH).read_text()"),
("pathlib.read_bytes",
"from pathlib import Path\n_q = Path(DEFAULT_PATH).read_bytes()"),
("pathlib.open",
"from pathlib import Path\n_h = Path(DEFAULT_PATH).open()\n_h.close()"),
)
def _dotted(call: ast.Call):
"""``json.load`` for a Name/Attribute chain, else None."""
parts: list = []
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 static_reads(src: str) -> list:
"""``[(lineno, spelling)]`` for read-spellings at module scope."""
tree = ast.parse(src)
found: list = []
def scan(node) -> None:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
return # lazy -- runs at call time
if isinstance(node, ast.Call):
name = _dotted(node)
if name in MODELLED_NAMES:
found.append((node.lineno, name))
elif isinstance(node.func, ast.Attribute) and node.func.attr in MODELLED_ATTRS:
found.append((node.lineno, "pathlib." + node.func.attr))
for child in ast.iter_child_nodes(node):
scan(child)
for statement in tree.body:
scan(statement)
return found
def audit_reads(module_path: str, timeout: int = 120) -> dict:
"""Import ``module_path`` in a child interpreter under an ``open`` audit hook.
Returns ``{"rc", "events", "import_error", "error"}``. ``rc`` is 0 when the
probe answered; a non-zero ``rc`` means the probe itself could not run and
``error`` says why (a harness must FAIL on it, never read it as cleanliness).
"""
try:
proc = subprocess.run([sys.executable, PROBE, module_path],
capture_output=True, text=True, timeout=timeout)
except (OSError, subprocess.SubprocessError) as exc:
return {"rc": 2, "events": [], "import_error": None,
"error": "%s: %s" % (type(exc).__name__, exc)}
if proc.returncode != 0:
return {"rc": proc.returncode, "events": [], "import_error": None,
"error": (proc.stderr or proc.stdout or "").strip()[-600:]}
lines = [l for l in (proc.stdout or "").splitlines() if l.strip()]
try:
data = json.loads(lines[-1])
except (ValueError, IndexError):
return {"rc": 2, "events": [], "import_error": None,
"error": "unparseable probe output: %r" % (proc.stdout or "")[:300]}
data["rc"] = 0
data.setdefault("error", None)
data.setdefault("import_error", None)
return data
def mutant_matrix(module_path: str, mutations=None, timeout: int = 120) -> list:
"""Grade every modelled spelling: each spawning must be killed by both.
Each row: ``{"label", "static", "audit", "import_error", "rc", "ok"}``.
The spawning is written beside the module (a ``.mutant-*`` file, removed in
``finally``) so that a ``__file__``-relative ``DEFAULT_PATH`` resolves to the
same tree it does in the module itself.
"""
mutations = list(mutations if mutations is not None else SPELLING_MUTANTS)
with open(module_path, encoding="utf-8") as handle:
source = handle.read()
directory = os.path.dirname(os.path.abspath(module_path))
rows: list = []
for label, snippet in mutations:
mutated = source + "\n" + snippet + "\n"
static = static_reads(mutated)
fd, temp = tempfile.mkstemp(prefix=".mutant-", suffix=".py", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(mutated)
probe = audit_reads(temp, timeout=timeout)
finally:
try:
os.unlink(temp)
except OSError:
pass
rows.append({
"label": label,
"static": static,
"audit": probe.get("events") or [],
"import_error": probe.get("import_error"),
"rc": probe.get("rc"),
"error": probe.get("error"),
"ok": bool(static) and bool(probe.get("events")) and probe.get("rc") == 0,
})
return rows
def modelled_spelling_count() -> int:
"""How many spellings this instrument names, for a printing harness to state."""
return len(MODELLED_NAMES) + len(MODELLED_ATTRS)
@@ -25,14 +25,22 @@ probe itself (same JSON input, same output). Checks:
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 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.
10. No import-time read (M6 killer): two instruments, both must read zero on
the clean module -- a static AST walk over the spellings
``import_io_detector`` names, which includes the ``Path(...).read_text()``,
``os.open(...)`` and ``io.open(...)`` forms a dotted-name list resolves to
None, and a child interpreter that imports the module under an ``open``
audit hook, so every spelling of a real open is seen whatever the source
says. 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. Every modelled spelling is killed by BOTH instruments (non-vacuity): eight
spawnings at module scope -- ``open``, ``json.load``, ``io.open``,
``os.open``, ``codecs.open``, ``pathlib.read_text``, ``pathlib.read_bytes``,
``pathlib.open`` -- each must be named by the static walk AND produce a
real ``open`` audit event, so a detector that matches nothing cannot pass.
Q89's reading, before this: four of those eight returned rc 0 with "0
module-scope io calls; 0 runtime open calls" printed.
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
@@ -53,6 +61,12 @@ import tempfile
# Resolve paths from this file's location (wizard-work/forge/).
WORK_DIR = os.path.dirname(os.path.abspath(__file__))
# Q89: the import-time-read detector is shared, not copied three times. Three
# byte-identical copies of a name list is how the evasion survived `802a1ae`;
# one module with one modelled set is what the three harnesses now grade.
if WORK_DIR not in sys.path:
sys.path.insert(0, WORK_DIR)
import import_io_detector as io_det # noqa: E402 (sibling, imported after WORK_DIR)
ORIGINAL = "_triage_dump_pending.py"
REFACTORED = "_triage_dump_pending_refactored.py"
MOD_NAME = REFACTORED[:-3] # strip .py
@@ -371,123 +385,82 @@ def check_path_override_honoured() -> bool:
# ----------------------------------------------------------------------
# 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.
# Q86/Q89: the refactor-lane harness proved SILENCE at import, not ABSENCE of
# I/O, and its seed path was host-absolute. Both gaps are closed below.
#
# 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
# Q89: a detector that models ONE spelling is a name list, not a detector. The
# dotted-name walk plus a `builtins.open` spy installed by `802a1ae` read zero
# on `Path(p).read_text()`, `os.open(...)` and `io.open(...)` while the module
# read a file at import. The modelled set and both instruments now live in
# `import_io_detector.py` beside this file: an AST walk that names the site, and
# a child interpreter importing under CPython's `open` audit hook, which fires
# for every spelling of a real open. The clean module is graded at check 12; the
# mutant battery at check 13 grades the instrument on each spelling it names.
# ----------------------------------------------------------------------
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))
Two instruments, both must read zero on the clean module:
(a) static -- an AST walk of the module-scope statements over the
spellings ``import_io_detector.MODELLED_NAMES`` /
``MODELLED_ATTRS`` name. This is what makes ``Path(p).read_text()``,
``os.open(...)`` and ``io.open(...)`` visible at all; it reports the
line number, so a red cell can quote the site.
(b) dynamic -- ``import_audit_probe.py`` imports the module in a child
interpreter under an audit hook on CPython's ``open`` event, so a
spelling nobody modelled is still caught. The probe's own docstring
names what it filters (the import machinery's code reads) and what it
cannot see (reads below the audit layer).
Neither instrument claims "no I/O": each reports a count, and zero of both
is the pass condition."""
static = io_det.static_reads(_read(REFACTORED))
if static:
print("FAIL: static io-call(s) at module scope: %s" % static)
print("FAIL: static read-spelling(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))
probe = io_det.audit_reads(os.path.join(WORK_DIR, REFACTORED))
if probe["rc"] != 0:
print("FAIL: the import-audit probe could not grade the module: %s"
% probe["error"])
return False
if calls:
print("FAIL: import made %d open() call(s): %r" % (len(calls), calls))
if probe["import_error"]:
print("FAIL: import raised %s" % probe["import_error"])
return False
print("OK: no import-time I/O (0 module-scope io calls; 0 runtime open calls)")
if probe["events"]:
print("FAIL: %d open event(s) during import: %s"
% (len(probe["events"]), probe["events"]))
return False
print("OK: no import-time read (static 0 over %d modelled spellings; "
"audit 0 open events outside the import machinery)"
% io_det.modelled_spelling_count())
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)
"""Non-vacuity for check 12: a detector that matches nothing is theater.
Every spelling ``import_io_detector`` names is spawned at module scope in a
copy of the refactored source, and each spawning must be killed by BOTH
instruments -- named by the static walk and caught as a real ``open`` event
by the audit hook. M6 (the measured Q86 mutant, ``json.load(open(...))``) is
the first row; ``io.open``, ``os.open`` and the three ``pathlib`` rows are
the Q89 spellings the old detector read zero on."""
rows = io_det.mutant_matrix(os.path.join(WORK_DIR, REFACTORED))
missed = [row for row in rows if not row["ok"]]
for row in rows:
print(" %-18s static=%-2d audit=%-2d %s"
% (row["label"], len(row["static"]), len(row["audit"]),
"killed" if row["ok"] else
"SURVIVED (rc=%s %r)" % (row["rc"], row["error"])))
if missed:
print("FAIL: %d/%d spelling mutant(s) survived an instrument: %s"
% (len(missed), len(rows), [row["label"] for row in missed]))
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)
print("OK: %d/%d import-time-read spellings killed by both instruments "
"(static AST + open-audit hook)" % (len(rows), len(rows)))
return True
+81 -110
View File
@@ -38,14 +38,22 @@ Checks:
prints the custom file's data and not the real seed path. This kills the
mutation of a ``path`` parameter that is accepted but ignored (byte-
identical on the real seed 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) survived 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.
12. No import-time read (M6 killer): two instruments, both must read zero on
the clean module -- a static AST walk over the spellings
``import_io_detector`` names, which includes the ``Path(...).read_text()``,
``os.open(...)`` and ``io.open(...)`` forms a dotted-name list resolves to
None, and a child interpreter that imports the module under an ``open``
audit hook, so every spelling of a real open is seen whatever the source
says. 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. Every modelled spelling is killed by BOTH instruments (non-vacuity): eight
spawnings at module scope -- ``open``, ``json.load``, ``io.open``,
``os.open``, ``codecs.open``, ``pathlib.read_text``, ``pathlib.read_bytes``,
``pathlib.open`` -- each must be named by the static walk AND produce a
real ``open`` audit event, so a detector that matches nothing cannot pass.
Q89's reading, before this: four of those eight returned rc 0 with "0
module-scope io calls; 0 runtime open calls" printed.
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
@@ -67,6 +75,12 @@ import tempfile
# Resolve paths from this file's location (wizard-work/forge/).
WORK_DIR = os.path.dirname(os.path.abspath(__file__))
# Q89: the import-time-read detector is shared, not copied three times. Three
# byte-identical copies of a name list is how the evasion survived `802a1ae`;
# one module with one modelled set is what the three harnesses now grade.
if WORK_DIR not in sys.path:
sys.path.insert(0, WORK_DIR)
import import_io_detector as io_det # noqa: E402 (sibling, imported after WORK_DIR)
ORIGINAL = "_triage_dump.py"
REFACTORED = "_triage_dump_refactored.py"
MOD_NAME = REFACTORED[:-3] # strip .py -> _triage_dump_refactored
@@ -409,125 +423,82 @@ def check_path_override_honoured() -> bool:
# ----------------------------------------------------------------------
# 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.
# Q86/Q89: the refactor-lane harness proved SILENCE at import, not ABSENCE of
# I/O, and its seed path was host-absolute. Both gaps are closed below.
#
# 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
# Q89: a detector that models ONE spelling is a name list, not a detector. The
# dotted-name walk plus a `builtins.open` spy installed by `802a1ae` read zero
# on `Path(p).read_text()`, `os.open(...)` and `io.open(...)` while the module
# read a file at import. The modelled set and both instruments now live in
# `import_io_detector.py` beside this file: an AST walk that names the site, and
# a child interpreter importing under CPython's `open` audit hook, which fires
# for every spelling of a real open. The clean module is graded at check 12; the
# mutant battery at check 13 grades the instrument on each spelling it names.
# ----------------------------------------------------------------------
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)."""
src = _read(REFACTORED)
static = _calls_at_module_scope(src)
Two instruments, both must read zero on the clean module:
(a) static -- an AST walk of the module-scope statements over the
spellings ``import_io_detector.MODELLED_NAMES`` /
``MODELLED_ATTRS`` name. This is what makes ``Path(p).read_text()``,
``os.open(...)`` and ``io.open(...)`` visible at all; it reports the
line number, so a red cell can quote the site.
(b) dynamic -- ``import_audit_probe.py`` imports the module in a child
interpreter under an audit hook on CPython's ``open`` event, so a
spelling nobody modelled is still caught. The probe's own docstring
names what it filters (the import machinery's code reads) and what it
cannot see (reads below the audit layer).
Neither instrument claims "no I/O": each reports a count, and zero of both
is the pass condition."""
static = io_det.static_reads(_read(REFACTORED))
if static:
print("FAIL: static io-call(s) at module scope: %s" % static)
print("FAIL: static read-spelling(s) at module scope: %s" % static)
return False
# Dynamic backstop: require zero open() calls during a fresh import.
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))
probe = io_det.audit_reads(os.path.join(WORK_DIR, REFACTORED))
if probe["rc"] != 0:
print("FAIL: the import-audit probe could not grade the module: %s"
% probe["error"])
return False
if calls:
print("FAIL: import made %d open() call(s): %r" % (len(calls), calls))
if probe["import_error"]:
print("FAIL: import raised %s" % probe["import_error"])
return False
print("OK: no import-time I/O (0 module-scope io calls; 0 runtime open calls)")
if probe["events"]:
print("FAIL: %d open event(s) during import: %s"
% (len(probe["events"]), probe["events"]))
return False
print("OK: no import-time read (static 0 over %d modelled spellings; "
"audit 0 open events outside the import machinery)"
% io_det.modelled_spelling_count())
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)
m6_expr = "_bootstrap = json.load(open(DEFAULT_PATH))\n"
mutated = src.replace("def load_data(path):",
m6_expr + "def load_data(path):", 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 'def load_data' not found)")
Every spelling ``import_io_detector`` names is spawned at module scope in a
copy of the refactored source, and each spawning must be killed by BOTH
instruments -- named by the static walk and caught as a real ``open`` event
by the audit hook. M6 (the measured Q86 mutant, ``json.load(open(...))``) is
the first row; ``io.open``, ``os.open`` and the three ``pathlib`` rows are
the Q89 spellings the old detector read zero on."""
rows = io_det.mutant_matrix(os.path.join(WORK_DIR, REFACTORED))
missed = [row for row in rows if not row["ok"]]
for row in rows:
print(" %-18s static=%-2d audit=%-2d %s"
% (row["label"], len(row["static"]), len(row["audit"]),
"killed" if row["ok"] else
"SURVIVED (rc=%s %r)" % (row["rc"], row["error"])))
if missed:
print("FAIL: %d/%d spelling mutant(s) survived an instrument: %s"
% (len(missed), len(rows), [row["label"] for row in missed]))
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)
print("OK: %d/%d import-time-read spellings killed by both instruments "
"(static AST + open-audit hook)" % (len(rows), len(rows)))
return True
+81 -108
View File
@@ -36,14 +36,22 @@ Checks:
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
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.
12. No import-time read (M6 killer): two instruments, both must read zero on
the clean module -- a static AST walk over the spellings
``import_io_detector`` names, which includes the ``Path(...).read_text()``,
``os.open(...)`` and ``io.open(...)`` forms a dotted-name list resolves to
None, and a child interpreter that imports the module under an ``open``
audit hook, so every spelling of a real open is seen whatever the source
says. 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. Every modelled spelling is killed by BOTH instruments (non-vacuity): eight
spawnings at module scope -- ``open``, ``json.load``, ``io.open``,
``os.open``, ``codecs.open``, ``pathlib.read_text``, ``pathlib.read_bytes``,
``pathlib.open`` -- each must be named by the static walk AND produce a
real ``open`` audit event, so a detector that matches nothing cannot pass.
Q89's reading, before this: four of those eight returned rc 0 with "0
module-scope io calls; 0 runtime open calls" printed.
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
@@ -65,6 +73,12 @@ import tempfile
# Resolve paths from this file's location (wizard-work/forge/).
WORK_DIR = os.path.dirname(os.path.abspath(__file__))
# Q89: the import-time-read detector is shared, not copied three times. Three
# byte-identical copies of a name list is how the evasion survived `802a1ae`;
# one module with one modelled set is what the three harnesses now grade.
if WORK_DIR not in sys.path:
sys.path.insert(0, WORK_DIR)
import import_io_detector as io_det # noqa: E402 (sibling, imported after WORK_DIR)
ORIGINAL = "_triage_probe.py"
REFACTORED = "_triage_probe_refactored.py"
MOD_NAME = REFACTORED[:-3] # strip .py -> _triage_probe_refactored
@@ -395,123 +409,82 @@ def check_path_override_honoured() -> bool:
# ----------------------------------------------------------------------
# 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.
# Q86/Q89: the refactor-lane harness proved SILENCE at import, not ABSENCE of
# I/O, and its seed path was host-absolute. Both gaps are closed below.
#
# 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
# Q89: a detector that models ONE spelling is a name list, not a detector. The
# dotted-name walk plus a `builtins.open` spy installed by `802a1ae` read zero
# on `Path(p).read_text()`, `os.open(...)` and `io.open(...)` while the module
# read a file at import. The modelled set and both instruments now live in
# `import_io_detector.py` beside this file: an AST walk that names the site, and
# a child interpreter importing under CPython's `open` audit hook, which fires
# for every spelling of a real open. The clean module is graded at check 12; the
# mutant battery at check 13 grades the instrument on each spelling it names.
# ----------------------------------------------------------------------
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))
Two instruments, both must read zero on the clean module:
(a) static -- an AST walk of the module-scope statements over the
spellings ``import_io_detector.MODELLED_NAMES`` /
``MODELLED_ATTRS`` name. This is what makes ``Path(p).read_text()``,
``os.open(...)`` and ``io.open(...)`` visible at all; it reports the
line number, so a red cell can quote the site.
(b) dynamic -- ``import_audit_probe.py`` imports the module in a child
interpreter under an audit hook on CPython's ``open`` event, so a
spelling nobody modelled is still caught. The probe's own docstring
names what it filters (the import machinery's code reads) and what it
cannot see (reads below the audit layer).
Neither instrument claims "no I/O": each reports a count, and zero of both
is the pass condition."""
static = io_det.static_reads(_read(REFACTORED))
if static:
print("FAIL: static io-call(s) at module scope: %s" % static)
print("FAIL: static read-spelling(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))
probe = io_det.audit_reads(os.path.join(WORK_DIR, REFACTORED))
if probe["rc"] != 0:
print("FAIL: the import-audit probe could not grade the module: %s"
% probe["error"])
return False
if calls:
print("FAIL: import made %d open() call(s): %r" % (len(calls), calls))
if probe["import_error"]:
print("FAIL: import raised %s" % probe["import_error"])
return False
print("OK: no import-time I/O (0 module-scope io calls; 0 runtime open calls)")
if probe["events"]:
print("FAIL: %d open event(s) during import: %s"
% (len(probe["events"]), probe["events"]))
return False
print("OK: no import-time read (static 0 over %d modelled spellings; "
"audit 0 open events outside the import machinery)"
% io_det.modelled_spelling_count())
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)
Every spelling ``import_io_detector`` names is spawned at module scope in a
copy of the refactored source, and each spawning must be killed by BOTH
instruments -- named by the static walk and caught as a real ``open`` event
by the audit hook. M6 (the measured Q86 mutant, ``json.load(open(...))``) is
the first row; ``io.open``, ``os.open`` and the three ``pathlib`` rows are
the Q89 spellings the old detector read zero on."""
rows = io_det.mutant_matrix(os.path.join(WORK_DIR, REFACTORED))
missed = [row for row in rows if not row["ok"]]
for row in rows:
print(" %-18s static=%-2d audit=%-2d %s"
% (row["label"], len(row["static"]), len(row["audit"]),
"killed" if row["ok"] else
"SURVIVED (rc=%s %r)" % (row["rc"], row["error"])))
if missed:
print("FAIL: %d/%d spelling mutant(s) survived an instrument: %s"
% (len(missed), len(rows), [row["label"] for row in missed]))
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)
print("OK: %d/%d import-time-read spellings killed by both instruments "
"(static AST + open-audit hook)" % (len(rows), len(rows)))
return True
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
# Q89 CHECK -- the import-time-read detector, graded on the spellings it names
# and on one no list can name.
#
# rc 0 = every leg as expected; 1 = a graded fault; 2 = could not grade.
# The work is done by wizard-work/volt/q89_verify.py beside this file; it copies
# the forge lane and the seed it reads into a temp root, so the tree this
# instrument ships in is never mutated. Run it from any checkout.
set -u
REPO="$(cd "$(dirname "$0")/../.." && pwd)" || exit 2
if ! cd "$REPO"; then
echo "q89: could not grade -- cannot cd to $REPO"
exit 2
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "q89: could not grade -- no python3 on PATH"
exit 2
fi
exec python3 wizard-work/volt/q89_verify.py
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""Q89: grade the import-time-read detector on every spelling it names, and on
one spelling no list can name.
What it grades, in legs:
1. clean the three refactor harnesses are green on the tree as committed
and print their own totals (14/14, 14/14, 13/13 today).
2. evasion the four Q89 spellings -- ``Path(...).read_text()``,
``os.open(...)``, ``io.open(...)``, ``Path(...).open()`` -- are
appended at module scope to each harness's module in turn and the
harness must go red (rc 1) naming the site. Before the repair all
four read rc 0 with "0 module-scope io calls; 0 runtime open
calls" printed.
3. control a module-scope ``len(DEFAULT_PATH)`` is not a read: it must stay
green, so the detector is not a constant-red.
4. blinding the two instruments are disabled in turn. With the static walk
blinded the audit hook must be the one that reds check 12; with
the audit hook blinded the static walk must. With both blinded
check 12 prints OK and the mutant battery (check 13) must still
red the harness -- a detector cannot be emptied quietly.
5. no-list ``_F = open`` / ``_F(DEFAULT_PATH)`` resolves to a name no set
contains; the audit hook must catch the real open event anyway.
This is the difference between a longer list and a closed class.
6. fourth the fourth copy of this detector (``verify_dump_seed_refactor.py``,
forge's own later hardening) still passes 16/16 and the shared
walk agrees with its 12-spelling corpus, so this module is a
superset rather than a third convention.
Everything runs in a temporary copy of ``wizard-work/forge`` beside a copy of
``inbox/seed-2026-09-26.json``; the tree the instrument ships in is never
mutated, which is what lets it grade in one checkout and be run in another.
rc 0 = every leg as expected; 1 = a graded fault; 2 = could not grade.
"""
from __future__ import annotations
import importlib.util
import json
import os
import shutil
import subprocess
import sys
import tempfile
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
SEED = os.path.join("inbox", "seed-2026-09-26.json")
SEED_SHA_PIN = None # the seed is copied, not pinned: its bytes are not graded
HARNESSES = (
("probe", "verify_triage_probe_refactor.py", "_triage_probe_refactored.py"),
("dump", "verify_triage_dump_refactor.py", "_triage_dump_refactored.py"),
("pending", "verify_triage_dump_pending_refactor.py",
"_triage_dump_pending_refactored.py"),
)
FOURTH = ("verify_dump_seed_refactor.py", "_dump_seed_refactored.py")
EVASIONS = {
"pathlib.read_text":
"\nfrom pathlib import Path as _P\n_evade = _P(DEFAULT_PATH).read_text()\n",
"os.open":
"\nimport os as _o\n_fd = _o.open(DEFAULT_PATH, _o.O_RDONLY)\n_o.close(_fd)\n",
"io.open":
"\nimport io as _io\n_h = _io.open(DEFAULT_PATH)\n_h.close()\n",
"Path.open":
"\nfrom pathlib import Path as _P\nwith _P(DEFAULT_PATH).open() as _fh:\n"
" _evade = _fh.read()\n",
}
CONTROL = "\n_neg = len(DEFAULT_PATH)\n"
NO_LIST = "_F = open\n_h = _F(DEFAULT_PATH)\n_h.close()\n"
BLIND_STATIC = "\nMODELLED_NAMES = frozenset()\nMODELLED_ATTRS = frozenset()\n"
BLIND_AUDIT = ('\n\ndef audit_reads(module_path, timeout=120):\n'
' return {"rc": 0, "events": [], "import_error": None,\n'
' "error": None}\n')
class Report:
def __init__(self) -> None:
self.faults = 0
self.legs = 0
def ok(self, leg: str, note: str) -> None:
self.legs += 1
print("ok %-22s %s" % (leg, note))
def fail(self, leg: str, note: str) -> None:
self.legs += 1
self.faults += 1
print("FAIL %-20s %s" % (leg, note))
def run(forge: str, harness: str):
proc = subprocess.run(["python3", harness], cwd=forge, capture_output=True,
text=True, timeout=300)
out = proc.stdout or ""
total = next((l.strip() for l in out.splitlines()
if l.startswith("=== Verification")), "")
first_fail = next((l.strip() for l in out.splitlines()
if l.strip().startswith("FAIL")), "")
# The io detector's own verdict, wherever it lands: a harness whose earlier
# checks red first (the dump lane's dead-import check shadows it) must still
# be graded on whether the io detector fired, not on check order.
io_fail = next((l.strip() for l in out.splitlines()
if l.strip().startswith("FAIL")
and ("static read-spelling" in l or "open event(s)" in l)), "")
return proc.returncode, total, first_fail, io_fail, out
def load(name: str, path: str):
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise SystemExit("cannot load %s" % path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def build_copy(base: str) -> str:
"""Copy the forge lane and the seed the modules read; return the copy's forge."""
work = os.path.join(base, "tree")
forge = os.path.join(work, "wizard-work", "forge")
os.makedirs(os.path.dirname(forge))
shutil.copytree(os.path.join(ROOT, "wizard-work", "forge"), forge,
ignore=shutil.ignore_patterns("__pycache__", ".pytest_cache"))
os.makedirs(os.path.join(work, "inbox"))
shutil.copy2(os.path.join(ROOT, SEED), os.path.join(work, SEED))
return forge
def with_suffix(forge: str, name: str, suffix: str, fn):
"""Run ``fn`` with ``suffix`` appended to one module, then restore the bytes."""
path = os.path.join(forge, name)
with open(path, encoding="utf-8") as handle:
original = handle.read()
try:
with open(path, "w", encoding="utf-8") as handle:
handle.write(original + suffix)
return fn()
finally:
with open(path, "w", encoding="utf-8") as handle:
handle.write(original)
with open(path, encoding="utf-8") as handle:
if handle.read() != original:
raise SystemExit(2)
def main() -> int:
if not os.path.isdir(os.path.join(ROOT, "wizard-work", "forge")):
print("FAIL tree: %s is not this instrument's repo" % ROOT)
return 2
report = Report()
base = tempfile.mkdtemp(prefix="q89-")
try:
forge = build_copy(base)
# (1) clean
totals = {}
for label, harness, _module in HARNESSES:
rc, total, fail, _io_fail, _out = run(forge, harness)
totals[label] = total
if rc == 0 and total:
report.ok("clean " + label, total.replace("=== Verification: ", ""))
else:
report.fail("clean " + label, "rc=%d %s %s" % (rc, total, fail))
# (2) evasion: every spelling must red every harness
for label, harness, module in HARNESSES:
for spelling, body in EVASIONS.items():
rc, _total, fail, io_fail, _out = with_suffix(
forge, module, body, lambda: run(forge, harness))
if rc == 1 and io_fail:
report.ok("%s %s" % (label, spelling), "rc=1, %s" % io_fail[:60])
else:
report.fail("%s %s" % (label, spelling),
"rc=%d, io detector said %r (first FAIL %r)"
% (rc, io_fail[:50], fail[:50]))
# (3) the negative control is not a read
for label, harness, module in HARNESSES:
rc, _total, fail, _io_fail, _out = with_suffix(
forge, module, CONTROL, lambda: run(forge, harness))
if rc == 0:
report.ok("control " + label, "len() at module scope stays green")
else:
report.fail("control " + label, "rc=%d %r" % (rc, fail[:80]))
# (4) each instrument is load-bearing, and the battery is the tripwire
probe_module = HARNESSES[0][2]
probe_harness = HARNESSES[0][1]
def _blinded(static_patch: str, audit_patch: str):
detector = os.path.join(forge, "import_io_detector.py")
with open(detector, encoding="utf-8") as handle:
saved = handle.read()
def go():
with open(detector, "w", encoding="utf-8") as handle:
handle.write(saved + static_patch + audit_patch)
return run(forge, probe_harness)
try:
return with_suffix(forge, probe_module, EVASIONS["pathlib.read_text"], go)
finally:
with open(detector, "w", encoding="utf-8") as handle:
handle.write(saved)
rc, _t, fail, io_fail, out = _blinded(BLIND_STATIC, "")
if rc == 1 and io_fail and "open event(s) during import" in io_fail:
report.ok("blinded static", "audit hook alone reds check 12")
else:
report.fail("blinded static", "rc=%d %r" % (rc, io_fail[:90]))
rc, _t, fail, io_fail, out = _blinded("", BLIND_AUDIT)
if rc == 1 and io_fail and "static read-spelling" in io_fail:
report.ok("blinded audit", "static walk alone reds check 12")
else:
report.fail("blinded audit", "rc=%d %r" % (rc, io_fail[:90]))
rc, _t, fail, io_fail, out = _blinded(BLIND_STATIC, BLIND_AUDIT)
check12_clean = ("OK: no import-time read" in out
and "modelled spellings" in out)
battery_red = "spelling mutant(s) survived" in out
if rc == 1 and check12_clean and battery_red:
report.ok("blinded both", "check 12 green, check 13 battery reds it")
else:
report.fail("blinded both",
"rc=%d check12_clean=%s battery_red=%s"
% (rc, check12_clean, battery_red))
# (5) a spelling no list names
rc, _t, fail, io_fail, out = with_suffix(
forge, probe_module, NO_LIST, lambda: run(forge, probe_harness))
if rc == 1 and io_fail and "open event(s) during import" in io_fail:
report.ok("no-list spelling", "_F = open caught by the audit hook")
else:
report.fail("no-list spelling", "rc=%d %r" % (rc, io_fail[:100]))
# (6) the fourth copy still passes, and the shared walk is a superset
rc, total, fail, _io_fail, _out = run(forge, FOURTH[0])
if rc == 0 and "16/16" in total:
report.ok("fourth copy", total.replace("=== Verification: ", ""))
else:
report.fail("fourth copy", "rc=%d %s %s" % (rc, total, fail))
detector = load("q89_detector",
os.path.join(forge, "import_io_detector.py"))
fourth = load("q89_fourth", os.path.join(forge, FOURTH[0]))
with open(os.path.join(forge, fourth.REFACTORED), encoding="utf-8") as handle:
source = handle.read()
missed = [label for label, snippet in fourth._IO_SPELLINGS
if not detector.static_reads(source + "\n" + snippet + "\n")]
if not missed:
report.ok("corpus agreement",
"%d/%d of the fourth's spellings flagged"
% (len(fourth._IO_SPELLINGS), len(fourth._IO_SPELLINGS)))
else:
report.fail("corpus agreement", "shared walk missed %s" % missed)
except (OSError, subprocess.SubprocessError, SystemExit) as exc:
print("FAIL instrument: %s: %s" % (type(exc).__name__, exc))
return 2
finally:
shutil.rmtree(base, ignore_errors=True)
if report.faults:
print("q89: FAULT -- %d leg line(s) red of %d graded" % (report.faults, report.legs))
return 1
print("q89: %d leg line(s), 0 fault -- OK" % report.legs)
return 0
if __name__ == "__main__":
sys.exit(main())