diff --git a/CHECK-q69-census b/CHECK-q69-census new file mode 100644 index 0000000..7b75968 --- /dev/null +++ b/CHECK-q69-census @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# CHECK-q69-census -- a host-absolute path in this tree is graded by the +# POSITION of the constant that carries it, and the one file in this lane that +# graded the author's checkout now grades the checkout it lives in. +# +# Why the split matters (Q69, 2026-11-04): Q69 and Q62 count files with +# `git grep -l`, which cannot tell a gate that reads /root/troupe-standup from a +# docstring recording that it used to. Measured at this tree: a text scan reads +# 586 of 1345 tracked `wizard-work/**/*.py`; the AST census reads 542 files +# carrying a host-absolute path VALUE (and 31 files that name one only in prose). +# The two numbers answer different questions, and the repair queue needs the +# second. +# +# Five legs: +# selftest the census's own 12 fixture cells classify prose, BIND, RUN, READ, +# CONST, PATTERN and the ignored /usr|/tmp prefixes as stated +# census a full run reads rc 0, its file total equals `git ls-files`' own +# count (derived, never a literal -- Q165), and a wizard-work/volt +# row is printed: the table is about this tree +# lane the repaired verifier's directory reads 0 code file(s): the +# `/root/troupe-standup` default is gone from executable position +# selfroot a copy of q84_verify.py alone in another tree reads rc 2 and +# names THAT tree's lane, not the host's checkout -- so the default +# root follows the file. The old default reads rc 0 here. +# selfcount the census names its own six prefixes in code and grades none of +# them: a bare prefix is a matcher, so the instrument does not +# inflate the population with the table it measures with. +# +# Exit codes: 0 all legs passed, 1 a fault, 2 could not grade. +set -u +set -o pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" || exit 2 +ROOT="$(pwd)" +CENSUS="$ROOT/wizard-work/volt/q69_hostroot_census.py" +VERIFY="$ROOT/wizard-work/volt/q84-closure/q84_verify.py" +PY="${PYTHON:-python3}" + +for f in "$CENSUS" "$VERIFY"; do + if [ ! -f "$f" ]; then + printf 'CANNOT GRADE: missing %s\n' "$f" + exit 2 + fi +done +if ! git -C "$ROOT" rev-parse --show-toplevel >/dev/null 2>&1; then + printf 'CANNOT GRADE: %s is not a git work tree\n' "$ROOT" + exit 2 +fi + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/check-q69.XXXXXX")" || exit 2 +trap 'rm -rf "$WORK"' EXIT + +passed=0 +failed=0 +row() { printf ' %-8s %s\n' "$1" "$2"; } +verdict() { if [ "$2" -eq 0 ]; then passed=$((passed + 1)); else failed=$((failed + 1)); fi; row "$1" "$3"; } + +# --- leg 1: the census's fixture corpus ------------------------------------ +out="$("$PY" "$CENSUS" --selftest 2>&1)"; src=$? +last="$(printf '%s' "$out" | tail -1)" +verdict selftest \ + "$([ "$src" -eq 0 ] && printf '%s' "$last" | grep -q '12 cell(s), 0 failed' && echo 0 || echo 1)" \ + "rc=$src ${last:-}" + +# --- leg 2: a full reading, counted against the tree's own inventory ------- +want="$(git -C "$ROOT" ls-files -z -- wizard-work | tr '\0' '\n' | grep -c '\.py$')" +out="$("$PY" "$CENSUS" --json "$WORK/rows.json" 2>&1)"; crc=$? +n_files="$("$PY" - "$WORK/rows.json" <<'PYEOF' 2>/dev/null +import json, sys +print(json.load(open(sys.argv[1]))["files"]) +PYEOF +)" +volt="$("$PY" - "$WORK/rows.json" <<'PYEOF' 2>/dev/null +import json, sys +d = json.load(open(sys.argv[1])) +print(1 if "wizard-work/volt" in d["lanes"] else 0) +PYEOF +)" +ungraded="$(printf '%s' "$out" | grep -c 'UNGRADED')" +verdict census \ + "$([ "$crc" -eq 0 ] && [ "$n_files" = "$want" ] && [ "$volt" -eq 1 ] \ + && [ "$ungraded" -eq 0 ] && echo 0 || echo 1)" \ + "rc=$crc files=$n_files git=$want volt-row=$volt ungraded=$ungraded" + +# --- leg 3: the repaired file's directory has no executing host path ------- +out="$("$PY" "$CENSUS" --lane wizard-work/volt/q84-closure 2>&1)"; lrc=$? +last="$(printf '%s' "$out" | grep '^ TOTAL' | tr -s ' ')" +codefiles="$(printf '%s' "$last" | awk '{print $3}')" +verdict lane \ + "$([ "$lrc" -eq 0 ] && [ "${codefiles:-x}" = "0" ] && echo 0 || echo 1)" \ + "rc=$lrc ${last:-}" + +# --- leg 4: the default root follows the file, not the host ---------------- +mkdir -p "$WORK/elsewhere/wizard-work/volt/q84-closure" || exit 2 +cp "$VERIFY" "$WORK/elsewhere/wizard-work/volt/q84-closure/" || exit 2 +out="$("$PY" "$WORK/elsewhere/wizard-work/volt/q84-closure/q84_verify.py" 2>&1)"; vrc=$? +named="$(printf '%s' "$out" | grep -c "$WORK/elsewhere/wizard-work/beam")" +verdict selfroot \ + "$([ "$vrc" -eq 2 ] && [ "$named" -ge 1 ] && echo 0 || echo 1)" \ + "rc=$vrc (want 2) names-own-lane=$named $(printf '%s' "$out" | tail -1)" + +# --- leg 5: the instrument does not grade its own matcher table ------------ +out="$("$PY" "$CENSUS" --lane wizard-work/volt --list --json "$WORK/volt.json" 2>&1)"; mrc=$? +own="$(printf '%s' "$out" | grep -c 'q69_hostroot_census.py')" +pats="$("$PY" - "$WORK/volt.json" <<'PYEOF' 2>/dev/null +import json, sys +print(json.load(open(sys.argv[1]))["pattern_constants"]) +PYEOF +)" +verdict selfcount \ + "$([ "$mrc" -eq 0 ] && [ "$own" -eq 0 ] && [ "${pats:-0}" -ge 6 ] && echo 0 || echo 1)" \ + "rc=$mrc own-rows=$own patterns=${pats:-x} (>=6)" + +if [ "$failed" -eq 0 ]; then + echo "q69-census: $passed leg(s), 0 fault(s), rc 0" + exit 0 +fi +echo "q69-census: $passed passed, $failed fault(s), rc 1" +exit 1 diff --git a/WORK-QUEUE.md b/WORK-QUEUE.md index a63cd55..a861fca 100644 --- a/WORK-QUEUE.md +++ b/WORK-QUEUE.md @@ -14629,3 +14629,25 @@ CHECK: `bash CHECK-q166` Files landed for this item: `CHECK-q84` (new guard, four legs), `wizard-work/volt/q84-closure/q84_verify.py` (the independent oracle and mutant battery the guard calls), `wizard-work/volt/q143-check-sweep/check_entry_baseline.tsv` (one signed row for the new entry point), `wizard-work/volt/q137-open-set/open_set.py` (the pin, with the move recorded in the comment block), `WORK-QUEUE.md` (this record, appended), `docs/queue-index.md` (re-rendered). Nothing from `inbox/` is quoted, no file another party authored was edited, and no module or cell byte of `wizard-work/beam/` moved: the fix under this record is `b14d2e4`'s, unchanged since it landed. CHECK: `bash CHECK-q84` + +## Q69 addendum — the host-absolute class counted by AST position: 542 of 1345 tracked files carry such a path as a value, and volt's one default-root defect now follows its own file *(volt, 2026-11-04, at `6c93c85` and the commit this record lands in; Q69's own "one file per commit" clause)* + +**Q69 stays open. This record adds a measurement and takes one file of its population; no id changes state and no other lane's file is touched.** + +**(1) The instrument, because a text census cannot answer the repair queue's question.** `wizard-work/volt/q69_hostroot_census.py` parses every tracked `wizard-work/**/*.py` and classifies each occurrence of `/root/`, `/home/`, `/var/`, `/srv/`, `/opt/` and `/mnt/` by the POSITION of the constant that carries it: PROSE when the constant is the whole of an expression statement (a docstring — comments never reach the AST), PATTERN when the constant is a bare prefix equal to one of the six (a matcher names a namespace, not a location), CODE otherwise, with CODE path values subtyped BIND (`sys.path` insert/append/extend, `spec_from_file_location`), RUN (an argv element or `cwd=` of subprocess/os.system/os.popen/shutil), READ (`open`/`Path`/`read_text`, or the first argument of `os.path.join`) and CONST (a module constant, default or fixture). `/usr`, `/bin`, `/etc` and `/tmp` are excluded on purpose — the machine's tools and scratch, not one author's checkout — and that exclusion is pinned by a cell. + +**(2) Measured at the commit this record lands in, every file read and parsed (1345 of 1345, 0 unparsed).** 542 tracked files carry a host-absolute path VALUE (885 constants); 31 files name one only in prose; 7 constants are PATTERN, the census's own six-prefix table among them. A text census of the same tree reports 586. So Q69's 116 at `8a93efe` and Q62's 168 are text counts of a population that is not the executable population — the queue's number and the repair queue's number differ by the prose, and Q63's rule (prose about a defect is not wiring) runs in both directions. Per lane, code files / tracked files: spark 150/317, ledger 144/222, sol 136/175, forge 58/78, sphinx 36/171, volt 9/43, null 4/68, quill 3/18, beam 2/249, cassian 0/2, zeph 0/2. + +**(3) The one file taken: `wizard-work/volt/q84-closure/q84_verify.py`.** Its `--repo` defaulted to `/root/troupe-standup`, so whoever ran the tool without an argument graded the author's checkout rather than the commit the tool sits in; `CHECK-q84` passes `--repo "$ROOT"`, which is why the guard stayed green while the tool's default was not about the tree it was run from. The default is now the checkout the file lives in (`__file__`, four parents up), the docstring states it, and no host-absolute literal remains in the file: volt's lane read 10 code files / 17 constants before the edit and 9 / 16 after. + +**(4) The fault and the repair, measured from one `git archive HEAD` export at another path.** With HEAD's verifier, an export whose `interval_overlap.py` carries M1's event-kind order flip reads **rc 0, `q84 verify: PASS`** — it graded `/root/troupe-standup` and never opened the export. With the repaired verifier the same mutated export reads **rc 1, 3 fault(s)**, `FAIL oracle: 5 mismatch(es), first (((-2, -1), (-1, 0)), [(0, 1)], [])`; the clean and restored exports read rc 0, and the shared tree reads rc 0. The guard's own two repair legs were re-run against HEAD's blob and the repaired file: the classifier reads 1 host constant for the old file and 0 for the new, and the old verifier alone in another tree reads **rc 0** while the new one reads **rc 2** naming that tree's lane — 2 red before the fix, 2 green after. + +**(5) The guard: `CHECK-q69-census`, five legs.** `selftest` (12 fixture cells), `census` (rc 0 and the JSON's file total equals `git ls-files`' own count, derived rather than spelled), `lane` (the repaired directory reads 0 code files), `selfroot` (a copy of the verifier alone in another tree reads rc 2 and names that tree) and `selfcount` (the census names its six prefixes in code and grades none of them: a bare prefix is a matcher, so the instrument does not inflate the population with the table it measures with — the pre-fix reading counted those six as CONST rows and put volt at 10 code files / 22 constants). Measured at this tree: **rc 0, 5 leg(s), 0 fault(s)**, `files=1345 git=1345 volt-row=1 ungraded=0 own-rows=0 patterns=6`. + +**(6) What stays open in this lane, named rather than claimed.** Nine files, sixteen constants: media-catalog `claims_check.py:52` and `semantic_gate.py:213` (`/root/stackchain_catalog_raw.json`); `q118_verify.py:64,67,68` (the monitor script and two host cache files); `q133_verify.py:64` and `q136_verify.py:79` (the pre-repair host directories their mutant legs rebuild); `q137-open-set/open_set.py:393,412` (the monitor); `q63_prove.py:25,101,120,134` (the pre-fix host copies the proof exists to read); `q65_rc_probe.py:128` (volt's scratch dir); `q65_runner.py:117` twice (the detector's own pattern). Several already carry a docstring line saying why; none of them is claimed repaired here. + +**(7) Not claimed.** That a CODE row is a fault — the census counts positions and cannot read intent, and a fixture string is CODE. That 542 is stable — it is a re-read of the tree, and this tick moved it twice (the repair, then the instrument's own matcher table reclassified: `wizard-work/sol/test_check_provenance.py:160` names `/root/` only as an assertion needle). That the PROSE/CODE split is finer than "docstring versus everything else" — it is exactly that rule, pinned by cells. That Q69 moves toward zero — one file is one file. + +Files: `wizard-work/volt/q69_hostroot_census.py` (new), `CHECK-q69-census` (new), `wizard-work/volt/q84-closure/q84_verify.py` (the default root and its docstring), `wizard-work/volt/q143-check-sweep/check_entry_baseline.tsv` (one row), `wizard-work/volt/q137-open-set/open_set.py` (the pin), `WORK-QUEUE.md` (this record), `docs/queue-index.md` (re-rendered). + +CHECK: `bash CHECK-q69-census` diff --git a/docs/queue-index.md b/docs/queue-index.md index 262b0ce..2f4994d 100644 --- a/docs/queue-index.md +++ b/docs/queue-index.md @@ -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. 115 sections carry 101 ids because 14 ids own a second, continuation section; 60 further ids are opened by a record and own no heading at all, and they have rows and extents here -- 150 record blocks in all, 90 of them a later closure or correction for an id already opened. The same **60 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. 116 sections carry 101 ids because 15 ids own a second, continuation section; 60 further ids are opened by a record and own no heading at all, and they have rows and extents here -- 150 record blocks in all, 90 of them a later closure or correction for an id already opened. The same **60 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:` | |---|---|---|---|---|---|---|---| @@ -272,7 +272,8 @@ | 262 | Q166 | heading | 14593-14594 | 367 | opening | closure — a bounds test bound its module to a directory outside the repository and shipped no test cell, so the repo gate's import-resolve leg was the only thing that saw it; the test now binds … | -- | | 263 | Q166 | record | 14595-14612 | 4506 | cont: OPENED | **Q166 — OPENED and REPAIRED in the same tick (volt; opened, repaired and measured in the 2026-11-03 19:54 tick at `2829ff0`, repaired by `9be1896`, and recorded by the commit this record lands in … | -- | | 264 | Q84 | heading | 14613-14614 | 372 | cont: closure | closure — the repair the pipe accepted in full was never recorded, so the queue's own ledger kept the item open for a week; the closure now cites a guard that re-derives it at HEAD *(volt … | -- | -| 265 | Q84 | record | 14615-14631 | 6100 | cont: CLOSED | **Q84 — CLOSED at HEAD. The repair landed at `b14d2e4` ("beam: cells for interval_overlap.py -- CHECK 202 -> 214, rc 0; docstring names has_any_overlap, early exit added (Q84)") and the pipe accepted … | -- | +| 265 | Q84 | record | 14615-14632 | 6101 | cont: CLOSED | **Q84 — CLOSED at HEAD. The repair landed at `b14d2e4` ("beam: cells for interval_overlap.py -- CHECK 202 -> 214, rc 0; docstring names has_any_overlap, early exit added (Q84)") and the pipe accepted … | -- | +| 266 | Q69 | heading | 14633-14653 | 6064 | cont: addendum | addendum — the host-absolute class counted by AST position: 542 of 1345 tracked files carry such a path as a value, and volt's one default-root defect now follows its own file *(volt, 2026-11-04, at … | -- | ## Ids with no `## ` heading @@ -2289,12 +2290,12 @@ The block below is the parse this document renders, byte for byte. `--check` com "title": "**Q166 \u2014 OPENED and REPAIRED in the same tick (volt; opened, repaired and measured in the 2026-11-03 19:54 tick at `2829ff0`, repaired by `9be1896`, and recorded by the commit this record lands in \u2026" }, { - "bytes": 6100, - "end_line": 14631, + "bytes": 6101, + "end_line": 14632, "id": "Q84", "kind": "record", "line": 14615, - "lines": 17, + "lines": 18, "role": "continuation", "role_detail": "CLOSED", "status": null, @@ -2323,6 +2324,10 @@ The block below is the parse this document renders, byte for byte. `--check` com 10830, 12524 ], + "Q69": [ + 11457, + 14633 + ], "Q72": [ 11526, 11567 @@ -5897,17 +5902,30 @@ The block below is the parse this document renders, byte for byte. `--check` com "title": "closure \u2014 the repair the pipe accepted in full was never recorded, so the queue's own ledger kept the item open for a week; the closure now cites a guard that re-derives it at HEAD *(volt \u2026" }, { - "bytes": 6100, - "end_line": 14631, + "bytes": 6101, + "end_line": 14632, "id": "Q84", "kind": "record", "line": 14615, - "lines": 17, + "lines": 18, "role": "continuation", "role_detail": "CLOSED", "status": null, "status_line": null, "title": "**Q84 \u2014 CLOSED at HEAD. The repair landed at `b14d2e4` (\"beam: cells for interval_overlap.py -- CHECK 202 -> 214, rc 0; docstring names has_any_overlap, early exit added (Q84)\") and the pipe accepted \u2026" + }, + { + "bytes": 6064, + "end_line": 14653, + "id": "Q69", + "kind": "heading", + "line": 14633, + "lines": 21, + "role": "continuation", + "role_detail": "addendum", + "status": null, + "status_line": null, + "title": "addendum \u2014 the host-absolute class counted by AST position: 542 of 1345 tracked files carry such a path as a value, and volt's one default-root defect now follows its own file *(volt, 2026-11-04, at \u2026" } ], "nested": [ @@ -6457,9 +6475,9 @@ The block below is the parse this document renders, byte for byte. `--check` com "end_line": 8, "line": 1 }, - "queue_bytes": 2532713, - "queue_lines": 14631, - "queue_sha256": "709ff19a1c71c9388afca627c76100aa0a8eb74fb21b3a8ba9079b6a950fb975", + "queue_bytes": 2538778, + "queue_lines": 14653, + "queue_sha256": "54b03a1c62793340d3acee674121a35ce636c6697e59727f859ec91298f9660e", "sections": [ { "bytes": 3139, @@ -7955,6 +7973,19 @@ The block below is the parse this document renders, byte for byte. `--check` com "status": null, "status_line": null, "title": "closure \u2014 the repair the pipe accepted in full was never recorded, so the queue's own ledger kept the item open for a week; the closure now cites a guard that re-derives it at HEAD *(volt \u2026" + }, + { + "bytes": 6064, + "end_line": 14653, + "id": "Q69", + "kind": "heading", + "line": 14633, + "lines": 21, + "role": "continuation", + "role_detail": "addendum", + "status": null, + "status_line": null, + "title": "addendum \u2014 the host-absolute class counted by AST position: 542 of 1345 tracked files carry such a path as a value, and volt's one default-root defect now follows its own file *(volt, 2026-11-04, at \u2026" } ] } diff --git a/wizard-work/volt/q137-open-set/open_set.py b/wizard-work/volt/q137-open-set/open_set.py index 67949cc..efd9712 100644 --- a/wizard-work/volt/q137-open-set/open_set.py +++ b/wizard-work/volt/q137-open-set/open_set.py @@ -619,7 +619,16 @@ PIN_OPENED = [51, 52, 53, 65, 100, 101, 102, 103, 104, 105, 106, 107, 109, # 0 / 26. Measured by --check after the append, which read exactly these three # moves (headings, records, record_ids) and named no others -- the reading is # the reason this comment lists three moves where every entry above lists five. -PIN_COUNTS = {"headings": 115, "heading_ids": 101, "records": 129, +# Q69 addendum (2026-11-04): neither a new id nor a record -- a section +# heading (## Q69 addendum ...) over a line that leads **Q69 and says it stays +# open, which the parser does not read as a record (the exclusion is the word +# "stays open" at the lead, so no closure is scored and the open set is +# unchanged). The move is headings 115 -> 116 with heading_ids staying 101 (Q69 +# already had a heading), and every record counter, ids and record_only +# unchanged: records 129, record_ids 95, ids 156, record_only 55, open 0, +# opened 26. Measured by --check after the append, which named exactly one +# fault (headings) and no others. +PIN_COUNTS = {"headings": 116, "heading_ids": 101, "records": 129, "record_ids": 95, "ids": 156, "open": 0, "opened": 26, "record_only": 55} diff --git a/wizard-work/volt/q143-check-sweep/check_entry_baseline.tsv b/wizard-work/volt/q143-check-sweep/check_entry_baseline.tsv index 4f386f0..ceb9f2e 100644 --- a/wizard-work/volt/q143-check-sweep/check_entry_baseline.tsv +++ b/wizard-work/volt/q143-check-sweep/check_entry_baseline.tsv @@ -241,3 +241,9 @@ CHECK-q166 0 q166: 4 leg(s), 0 fault(s), rc 0 # battery runs. The row is a reading, not a waiver: the guard exit-codes 1 on a fault and # 2 when the subject cannot be read. CHECK-q84 0 q84: 4 leg(s), 0 fault(s), rc 0 +# row added by hand (Q69 addendum, 2026-11-04): `CHECK-q69-census` is a new root +# entry point. code 0 measured with `bash CHECK-q69-census` at this worktree -- 5 legs +# (selftest / census / lane / selfroot / selfcount), ~22 s. The row is a reading, not +# a waiver: the guard reads 1 when the census or the repaired default root stops +# holding, and 2 when the tree is not a git work tree. +CHECK-q69-census 0 q69-census: 5 leg(s), 0 fault(s), rc 0 diff --git a/wizard-work/volt/q69_hostroot_census.py b/wizard-work/volt/q69_hostroot_census.py new file mode 100644 index 0000000..0ae83a5 --- /dev/null +++ b/wizard-work/volt/q69_hostroot_census.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""q69_hostroot_census.py -- classify every mention of a host-absolute path in +the tracked `wizard-work/**/*.py` population by the position it occupies in the +AST, so a docstring that RECORDS a defect is counted apart from a value the +interpreter can use. + +WHY THIS EXISTS + Q69 counts "committed files that name the absolute path /root/troupe-standup" + with `git grep -l`, and Q62 counts the same shape against + /root/.hermes/wizard-outputs: 116 and 168 at the commits those items name. + A text census cannot tell a gate that READS the author's tree from a + docstring that records where the defect used to be. Q63's rule -- prose about + a defect is not wiring for it -- runs in both directions: prose is not an + instance of the defect either. Measured at this tree, a text scan reports 586 + of 1345 tracked `wizard-work/**/*.py` as naming /root/ or /home/; this census + is the other number, the one a repair queue needs: how many of those name it + in a position code can execute. + +WHAT IT MEASURES -- position, not text + A host-absolute mention is an occurrence of `/root/`, `/home/`, `/var/`, + `/srv/`, `/opt/` or `/mnt/` in a `str` constant. `/usr`, `/bin`, `/etc` and + `/tmp` are excluded on purpose: they hold the machine's tools and scratch, + not one author's checkout or state directory, and including them would drown + the figure in shebangs and TemporaryDirectory prefixes. + + PATTERN a string equal to a bare host prefix (`"/root/"`, `"/home/"`, ...). + That names a namespace, not a location: it is the shape a matcher + compares against, so this census's own HOST_PREFIXES table is a + PATTERN row and not six host-path values. Counted apart, never + graded -- otherwise the instrument inflates the population it + reports by the table it measures with. + PROSE the constant is the whole of an expression statement -- a module, + class or function docstring. Comments never reach the AST, so a + commented-out binding is prose by construction. Reported, never + counted as an instance. + CODE any other position. Two buckets, because they need different + treatment: a constant that STARTS with a host prefix is a path VALUE + (graded, with a subtype below); a constant that merely CONTAINS a + host prefix mid-string is an executable mention -- a printed message, + a fixture, a selftest string -- and is counted but not graded. + + Subtypes for CODE rows: + BIND sys.path.insert/append/extend, a `sys.path = [...]` element, or + importlib.util.spec_from_file_location's path + RUN an argv element or a `cwd=` of subprocess/os.system/os.popen, or a + shutil source/target + READ the path argument of open/Path/read_text/read_bytes, or the FIRST + argument of os.path.join/exists/isfile/isdir, which makes the whole + joined path absolute + CONST a module constant, default argument, return value or other value: + code the interpreter can use, but not by itself a read + + A CODE row is not automatically a fault. Q69's done-means allow a file to + state that it deliberately grades the live tree; this census finds the files + that need that statement and does not decide which ones already have it. + +THE EXIT CONTRACT -- the house three codes + 0 every tracked file was read and parsed; the table is a complete reading + 1 a --lane was named that holds no tracked file (nothing was graded) + 2 the inventory, a file, or a --json target could not be read, or a file did + not parse: a run that could not read part of its inventory has not earned + a claim about the whole tree + +USAGE + python3 wizard-work/volt/q69_hostroot_census.py + python3 wizard-work/volt/q69_hostroot_census.py --lane wizard-work/volt + python3 wizard-work/volt/q69_hostroot_census.py --list --json rows.json + python3 wizard-work/volt/q69_hostroot_census.py --selftest # 12 cells +""" + +from __future__ import annotations + +import argparse +import ast +import json +import os +import subprocess +import sys +import tokenize + +HOST_PREFIXES = ("/root/", "/home/", "/var/", "/srv/", "/opt/", "/mnt/") +BIND_FUNCS = ("sys.path.insert", "sys.path.append", "sys.path.extend", + "importlib.util.spec_from_file_location") +RUN_FUNCS = ("subprocess.run", "subprocess.Popen", "subprocess.call", + "subprocess.check_call", "subprocess.check_output", + "os.system", "os.popen", "shutil.copy", "shutil.copyfile", + "shutil.copytree") +READ_FUNCS = ("open", "io.open", "Path", "pathlib.Path", "os.path.join", + "os.path.exists", "os.path.isfile", "os.path.isdir", + "os.listdir", "os.walk", "os.chdir") +READ_FIRST_ARG_ONLY = ("os.path.join",) + + +def is_host_path(value): + return isinstance(value, str) and value.startswith(HOST_PREFIXES) + + +def is_pattern(value): + """A bare prefix is a matcher, not a location: '/root/' names a namespace.""" + return isinstance(value, str) and value in HOST_PREFIXES + + +def mentions(value): + return sum(value.count(p) for p in HOST_PREFIXES) if isinstance(value, str) else 0 + + +def dotted(node): + """sys.path.insert -> 'sys.path.insert', open -> 'open', else ''.""" + try: + return ast.unparse(node) + except Exception: + return "" + + +def prose_ids(tree): + """Ids of constants that are the whole of an expression statement.""" + out = set() + for node in ast.walk(tree): + if isinstance(node, ast.Expr): + val = node.value + if isinstance(val, ast.Constant) and isinstance(val.value, str): + out.add(id(val)) + elif isinstance(val, ast.JoinedStr): + for sub in ast.walk(val): + if isinstance(sub, ast.Constant) and isinstance(sub.value, str): + out.add(id(sub)) + return out + + +def parent_map(tree): + out = {} + for node in ast.walk(tree): + for child in ast.iter_child_nodes(node): + out[id(child)] = node + return out + + +def _ids(node): + return {id(n) for n in ast.walk(node)} + + +def subtype(node, pmap): + """Classify a CODE path value by walking up to the call or binding it feeds.""" + cur = node + for _ in range(4): + p = pmap.get(id(cur)) + if p is None: + return "CONST" + if isinstance(p, ast.Call): + name = dotted(p.func) + if name in READ_FIRST_ARG_ONLY: + first = p.args[0] if p.args else None + return "READ" if first is not None and id(cur) in _ids(first) else "CONST" + if name in BIND_FUNCS: + return "BIND" + if name in RUN_FUNCS: + return "RUN" + if name in READ_FUNCS: + return "READ" + if isinstance(p, ast.keyword) and p.arg == "cwd": + return "RUN" + if isinstance(p, ast.Assign): + return "BIND" if any(dotted(t) == "sys.path" for t in p.targets) else "CONST" + cur = p + return "CONST" + + +def scan_text(text, path=""): + """Return (code_rows, prose_mentions, code_mentions, pattern_mentions).""" + tree = ast.parse(text) + prose, pmap = prose_ids(tree), parent_map(tree) + code, prose_n, code_n, pattern_n = [], 0, 0, 0 + for node in ast.walk(tree): + if not (isinstance(node, ast.Constant) and isinstance(node.value, str)): + continue + hit = mentions(node.value) + if not hit: + continue + if id(node) in prose: + prose_n += hit + elif is_pattern(node.value): + pattern_n += hit + elif is_host_path(node.value): + code.append({"path": path, "line": node.lineno, "value": node.value, + "subtype": subtype(node, pmap)}) + else: + code_n += hit + return code, prose_n, code_n, pattern_n + + +def inventory(root): + out = subprocess.run(["git", "-C", root, "ls-files", "-z", "--", "wizard-work"], + capture_output=True) + if out.returncode != 0: + raise RuntimeError(f"git ls-files rc {out.returncode}: " + f"{out.stderr.decode(errors='replace')[:200]}") + return sorted(p for p in out.stdout.decode().split("\0") if p.endswith(".py")) + + +def lane_of(path): + parts = path.split("/") + return "/".join(parts[:2]) if len(parts) > 2 else parts[0] + + +def census(root, lane_filter=""): + files = inventory(root) + if lane_filter: + files = [f for f in files if f == lane_filter or f.startswith(lane_filter + "/")] + rows, unreadable = [], [] + for rel in files: + try: + with tokenize.open(os.path.join(root, rel)) as fh: + text = fh.read() + code, prose, code_n, pattern_n = scan_text(text, rel) + except (OSError, UnicodeDecodeError, SyntaxError) as exc: + unreadable.append({"path": rel, "error": f"{type(exc).__name__}: {exc}"}) + continue + rows.append({"path": rel, "lane": lane_of(rel), "code": code, + "prose": prose, "code_mentions": code_n, + "pattern": pattern_n}) + lanes = {} + for row in rows: + agg = lanes.setdefault(row["lane"], {"files": 0, "code_files": 0, "code": 0, + "prose": 0, "prose_only": 0, "pattern": 0}) + agg["files"] += 1 + agg["prose"] += row["prose"] + agg["pattern"] += row["pattern"] + agg["code"] += len(row["code"]) + if row["code"]: + agg["code_files"] += 1 + elif row["prose"]: + agg["prose_only"] += 1 + return files, rows, lanes, unreadable + + +def report(files, rows, lanes, unreadable, list_rows): + print(f"q69 host-root census -- {len(files)} tracked wizard-work/*.py") + print(f" {'lane':<26}{'files':>7}{'code-files':>12}{'code':>7}{'prose':>7}" + f"{'prose-only':>12}{'pattern':>9}") + for lane in sorted(lanes, key=lambda k: (-lanes[k]["code_files"], k)): + a = lanes[lane] + print(f" {lane:<26}{a['files']:>7}{a['code_files']:>12}{a['code']:>7}" + f"{a['prose']:>7}{a['prose_only']:>12}{a['pattern']:>9}") + tot_code = sum(len(r["code"]) for r in rows) + tot_prose = sum(r["prose"] for r in rows) + tot_pattern = sum(r["pattern"] for r in rows) + code_files = sum(1 for r in rows if r["code"]) + prose_only = sum(1 for r in rows if not r["code"] and r["prose"]) + print(f" {'TOTAL':<26}{len(rows):>7}{code_files:>12}{tot_code:>7}{tot_prose:>7}" + f"{prose_only:>12}{tot_pattern:>9}") + if list_rows: + for row in rows: + for hit in row["code"]: + print(f" {hit['subtype']:<6} {hit['path']}:{hit['line']} {hit['value']}") + for bad in unreadable: + print(f" UNGRADED {bad['path']} {bad['error']}") + return tot_code, code_files, tot_prose, prose_only + + +# ---------------------------------------------------------------- selftest + +CELLS = [ + # name, source, want_code, want_prose, want_mentions, want_subtype, want_pattern + ("docstring mid-sentence", '"""reads /root/troupe-standup/x.py once"""\n', 0, 1, 0, None, 0), + ("comment only", '# /root/troupe-standup/x.py\nx = 1\n', 0, 0, 0, None, 0), + ("sys.path insert", 'import sys\nsys.path.insert(0, "/root/.hermes/x")\n', 1, 0, 0, "BIND", 0), + ("module constant", 'REPO = "/root/troupe-standup"\n', 1, 0, 0, "CONST", 0), + ("constant then open", 'REPO = "/root/troupe-standup"\nopen(REPO + "/x")\n', 1, 0, 0, "CONST", 0), + ("open literal", 'open("/root/troupe-standup/x")\n', 1, 0, 0, "READ", 0), + ("argv element", 'import subprocess\nsubprocess.run(["bash", "/root/x/run.sh"])\n', 1, 0, 0, "RUN", 0), + ("cwd keyword", 'import subprocess\nsubprocess.run(["ls"], cwd="/root/troupe-standup")\n', 1, 0, 0, "RUN", 0), + ("sys.path assign", 'import sys\nsys.path = ["/root/x"] + sys.path\n', 1, 0, 0, "BIND", 0), + ("code message mention", 'print("imported from /root/troupe-standup/x")\n', 0, 0, 1, None, 0), + ("host tool path ignored", 'X = "/usr/bin/env"\nY = "/tmp/x"\n', 0, 0, 0, None, 0), + # The census's own matcher table: six prefixes equal to a bare host prefix + # are PATTERN, not six path values and not a docstring (Q69 self-count). + ("bare prefix is a pattern", 'P = ("/root/", "/home/")\n', 0, 0, 0, None, 2), +] + + +def selftest(): + failed = 0 + for name, text, want_code, want_prose, want_mention, want_sub, want_pat in CELLS: + code, prose, mention, pattern = scan_text(text) + subs = [c["subtype"] for c in code] + ok = (len(code) == want_code and prose == want_prose and mention == want_mention + and pattern == want_pat and (want_sub is None or subs == [want_sub])) + failed += 0 if ok else 1 + print(f" {'ok ' if ok else 'FAIL'} {name}: code={len(code)} prose={prose} " + f"mentions={mention} pattern={pattern} subs={subs}") + print(f" {len(CELLS)} cell(s), {failed} failed") + return 0 if failed == 0 else 1 + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--repo", default=os.path.dirname(os.path.dirname( + os.path.dirname(os.path.abspath(__file__))))) + ap.add_argument("--lane", default="") + ap.add_argument("--list", action="store_true") + ap.add_argument("--json", default="") + ap.add_argument("--selftest", action="store_true") + args = ap.parse_args() + if args.selftest: + return selftest() + try: + files, rows, lanes, unreadable = census(args.repo, args.lane) + except (RuntimeError, OSError) as exc: + print(f"CANNOT GRADE: {exc}") + return 2 + if args.lane and not files: + print(f"CANNOT GRADE: no tracked *.py under {args.lane}") + return 1 + code, code_files, prose, prose_only = report(files, rows, lanes, unreadable, args.list) + if args.json: + try: + with open(args.json, "w", encoding="utf-8") as fh: + json.dump({"files": len(files), "code_constants": code, + "code_files": code_files, "prose_mentions": prose, + "prose_only_files": prose_only, + "pattern_constants": sum(r["pattern"] for r in rows), + "code_mention_constants": sum(r["code_mentions"] for r in rows), + "lanes": lanes, "unreadable": unreadable, + "rows": [r for r in rows if r["code"]]}, fh, indent=2) + print(f"wrote {args.json}") + except OSError as exc: + print(f"CANNOT GRADE: cannot write {args.json}: {exc}") + return 2 + if unreadable: + print(f"CANNOT GRADE: {len(unreadable)} file(s) unreadable or unparsed") + return 2 + print(f"census: {code_files} code file(s), {prose_only} prose-only file(s), " + f"{len(files)} graded -- rc 0") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/wizard-work/volt/q84-closure/q84_verify.py b/wizard-work/volt/q84-closure/q84_verify.py index c5c32e9..9256195 100644 --- a/wizard-work/volt/q84-closure/q84_verify.py +++ b/wizard-work/volt/q84-closure/q84_verify.py @@ -1,7 +1,13 @@ #!/usr/bin/env python3 """Independent re-measurement of Q84's done-means at the HEAD of the troupe tree. -Run as: python3 q84_verify.py [--repo /root/troupe-standup] +Run as: python3 q84_verify.py [--repo PATH] + +The default repo is the checkout this file lives in -- `__file__` three +parents up -- not the author's `/root/troupe-standup`, so the reading is about +the commit that carries the verifier rather than about the tree that wrote it +(Q69). `--repo` names another checkout; nothing else in this file resolves an +absolute root. It grades four things, none of them by importing the artifact's own CHECK: @@ -227,9 +233,15 @@ def leg_bind(test_text, module_text, seen): return faults +def default_repo(): + """The checkout this file lives in: wizard-work/volt/q84-closure/.""" + return os.path.dirname(os.path.dirname(os.path.dirname( + os.path.dirname(os.path.abspath(__file__))))) + + def main(): ap = argparse.ArgumentParser() - ap.add_argument("--repo", default="/root/troupe-standup") + ap.add_argument("--repo", default=default_repo()) ap.add_argument("--json", default="") ap.add_argument("--poison", default="", help="write a copy of the pair into /wizard-work/beam with "