Files
troupe-standup/wizard-work/spark/test_line_counter.py
T
ledger a979e630a4 spark: one text-statistics tool; 25 counter and verifier files retired (Q125)
line_counter.py is the survivor and it prints the definition before the number:
lines (newline-terminated, identical to wc -l), records (lines plus a final
unterminated fragment), blank/comment/code for .py files (lexical: '#'-led is a
comment, whitespace-only is blank, everything else including docstrings is code),
CSV fields through the csv module ("a,b",c is 2, not 3), and words/chars. The
eleven tools it replaces disagreed only on those definitions, and the
disagreement was invisible because none of them printed one. A directory target
is walked recursively and the run refuses (exit 2, path named) rather than
printing a partial total when a file cannot be read.

Retired, and where each behaviour went:

  count_python.py          file/line walk -> `line_counter.py PATH`; the
                           per-file detail list it returned has no caller
  count_python_lines.py    code/comment/blank breakdown -> PATH report
  py_loc.py                same breakdown, same key names
  loc_counter.py           same breakdown; its argparse surface goes with it
  linecount_pycount.py     totals -> lines/records keys, its code count -> `code`
  code_stats.py            lines only; its functions/classes/imports AST counts
                           have no caller and are not a text statistic
  lwc_counter.py           -> `--words` (lines, words, chars)
  text_stats.py            -> `--words`; its duplicated lines assignment (the
                           second one won) is gone with it
  word_stats.py            -> `--words`; unique/top-5 tables have no caller
  file_stats.py            -> `--words`
  file_analyzer.py         -> `--words`; its size_bytes is `wc -c`
  quick_stats.py           -> `--words`; bytes is `wc -c`; unique/top-5 retired
  wordcount.py             -> `--words` (its JSON envelope is not kept)
  word_counter.py          -> `--words`
  word_counter_simple.py   -> `--words`
  word-counter.py          -> `--words`
  wordfreq.py              -> `--words`; frequency table retired, no caller
  word_freq.py             -> `--words`; frequency table retired, no caller

Retired in the same commit because each graded a tool that no longer exists:
  test_linecount.py, test_text_stats.py, verify_lines.py, verify_lwc.py,
  verify_wordcount.py, verify_word_freq.sh, check_word_count_report.py

verify_line_counter.sh is repaired rather than deleted. It pinned
/root/.hermes/wizard-outputs/spark and "Total lines: 5587" -- a figure belonging
to the 65-line tool last held at 7359bce that 6b5197b had already replaced -- and
read rc 1 at the opening rev. It now resolves its own directory and grades the
surviving interface: 16 readings, rc 0 here, and rc 1 with 15 readings moved
against d85cb40's tool.

test_line_counter.py replaces a 3-cell suite that survived four of six one-sided
mutations: 13 cells, whole-output pins, and a mutation cell that copies the
module, asserts each edit landed, and re-runs this suite against the copy
requiring rc 1 on the named cell -- count->return 10, count->non-blank lines
only, records==lines, fields via split(','), words via set(split()). Five
mutants, five killed. Against d85cb40's tool the same suite reads 0/13, rc 1.

Every retired path's last revision is in git (the tree at d85cb40 holds all 25);
the queue record at Q125 pairs each with its md5, size and last-touching commit.
tools/path_claim_baseline.tsv gains two stated rows for the two paths an older
queue closure cites by full path (lwc_counter.py, verify_lwc.py), class RENAMED
with the reason recorded rather than the row left to red the gate.

Files: wizard-work/spark/line_counter.py, test_line_counter.py,
verify_line_counter.sh (repaired), README.md, VERIFICATION.md, 25 deletions,
tools/path_claim_baseline.tsv.
2026-10-29 00:09:30 +00:00

353 lines
11 KiB
Python

#!/usr/bin/env python3
"""The cell for wizard-work/spark/line_counter.py (Q125).
WHAT THIS PINS
Every cell runs the shipped tool as a subprocess and compares its whole
stdout, so a number that moves for any reason is a red cell, not a rounding
difference in a review. The two cells the queue item names by hand are
`blank_lines_are_lines` (a file with blank lines must read 4, not 2) and
`final_unterminated_fragment_is_a_record` (a file whose last row has no
newline must read lines 3 / records 4, not line count == record count).
The last two cells are not tests of the tool, they are tests of *this file*:
each copies the module to a scratch directory, applies one named mutation
with an assertion that the edit landed, and re-runs this suite against the
copy expecting rc 1 and the expected cell among the failures. A suite that
survives those mutants is a suite that cannot fail, which is the defect the
queue item was opened over -- so the mutants are cells, not a one-off reading
in a report nobody re-runs.
USAGE
python3 wizard-work/spark/test_line_counter.py run everything
python3 wizard-work/spark/test_line_counter.py --no-mutants inner run
LINE_COUNTER=/path/to/line_counter.py python3 test_line_counter.py
grade a different copy of the tool (how the mutation cells do it)
EXIT CODES
0 every cell passed
1 at least one cell failed
2 the suite could not grade: the module or a fixture it needs is missing
"""
import json
import os
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
TOOL = os.environ.get("LINE_COUNTER") or os.path.join(HERE, "line_counter.py")
SELF = os.path.abspath(__file__)
CELLS = []
def cell(fn):
CELLS.append(fn)
return fn
def expect(condition, message):
if not condition:
raise AssertionError(message)
def run(*args):
return subprocess.run(
[sys.executable, TOOL, *args], capture_output=True, text=True
)
def exact(*args, expected):
result = run(*args)
expect(
result.returncode == 0,
f"exit {result.returncode}, stderr {result.stderr.strip()!r}",
)
expect(
result.stdout == expected,
f"stdout {result.stdout!r} != expected {expected!r}",
)
@cell
def blank_lines_are_lines():
"""`printf 'a\\n\\n\\nb\\n'` reads 4 lines and 4 records, not 2."""
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "blank.py")
with open(path, "wb") as handle:
handle.write(b"a\n\n\nb\n")
exact(
path,
expected=(
"files: 1\nlines: 4\nrecords: 4\n"
"blank: 2\ncomment: 0\ncode: 2\n"
),
)
@cell
def final_unterminated_fragment_is_a_record():
"""Without a trailing newline, lines and records must differ by one."""
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "noterm.py")
with open(path, "wb") as handle:
handle.write(b"a\n\n\nb")
exact(
path,
expected=(
"files: 1\nlines: 3\nrecords: 4\n"
"blank: 2\ncomment: 0\ncode: 2\n"
),
)
@cell
def committed_log_fixture():
"""test_log.txt: 10 rows of text, 9 newlines -- lines 9, records 10."""
path = os.path.join(HERE, "test_log.txt")
expect(os.path.isfile(path), f"fixture missing: {path}")
exact(
path,
expected=(
"files: 1\nlines: 9\nrecords: 10\n"
"blank: -\ncomment: -\ncode: -\n"
),
)
@cell
def committed_csv_fixture_fields():
"""test_data.csv holds five comma-separated items."""
path = os.path.join(HERE, "test_data.csv")
expect(os.path.isfile(path), f"fixture missing: {path}")
exact("--fields", path, expected="fields: 5\n")
@cell
def quoted_separator_is_data():
"""`"a,b",c` is 2 fields; str.split(',') reads 3."""
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "quoted.csv")
with open(path, "w", encoding="utf-8") as handle:
handle.write('"a,b",c\n')
exact("--fields", path, expected="fields: 2\n")
expect(
len('"a,b",c\n'.split("\n")[0].split(",")) == 3,
"the contrast this cell rests on no longer holds",
)
@cell
def directory_walk_sums_and_breaks_down():
"""Three files, two of them Python: sums and the lexical breakdown."""
with tempfile.TemporaryDirectory() as tmp:
nested = os.path.join(tmp, "sub")
os.makedirs(nested)
with open(os.path.join(tmp, "a.py"), "wb") as handle:
handle.write(b"x = 1\n# c\n\n")
with open(os.path.join(nested, "b.txt"), "wb") as handle:
handle.write(b"hello\n")
with open(os.path.join(nested, "c.py"), "wb") as handle:
handle.write(b"y = 2")
exact(
tmp,
expected=(
"files: 3\nlines: 4\nrecords: 5\n"
"blank: 1\ncomment: 1\ncode: 2\n"
),
)
@cell
def non_python_target_prints_dashes():
"""A target with no .py file must print '-' rather than an uncounted 0."""
with tempfile.TemporaryDirectory() as tmp:
with open(os.path.join(tmp, "only.txt"), "wb") as handle:
handle.write(b"one\ntwo\n")
exact(
tmp,
expected=(
"files: 1\nlines: 2\nrecords: 2\n"
"blank: -\ncomment: -\ncode: -\n"
),
)
@cell
def json_matches_the_text_report():
"""--json carries the same figures, nulls where the text prints '-'."""
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "blank.py")
with open(path, "wb") as handle:
handle.write(b"a\n\n\nb\n")
result = run("--json", path)
expect(result.returncode == 0, f"--json exited {result.returncode}")
data = json.loads(result.stdout)
expect(
data
== {
"path": path,
"files": 1,
"lines": 4,
"records": 4,
"blank": 2,
"comment": 0,
"code": 2,
},
f"--json payload differs: {data}",
)
text = run(path)
expect(
text.stdout.splitlines()
== [
"files: 1",
"lines: 4",
"records: 4",
"blank: 2",
"comment: 0",
"code: 2",
],
f"text report differs: {text.stdout!r}",
)
@cell
def missing_target_exits_two():
"""An unreadable target is rc 2 with the path named, never a partial total."""
with tempfile.TemporaryDirectory() as tmp:
missing = os.path.join(tmp, "nope.txt")
result = run(missing)
expect(result.returncode == 2, f"exit {result.returncode}, want 2")
expect(result.stdout == "", f"stdout {result.stdout!r}, want empty")
expect(missing in result.stderr, f"path not named: {result.stderr!r}")
@cell
def usage_error_exits_one():
"""No target at all is a usage error, not an empty report."""
result = run()
expect(result.returncode == 1, f"exit {result.returncode}, want 1")
expect("usage:" in result.stderr, f"no usage line: {result.stderr!r}")
@cell
def word_and_char_counts():
"""--words: three whitespace tokens with a repeat, and six characters."""
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "words.txt")
with open(path, "w", encoding="utf-8") as handle:
handle.write("a b a\n")
exact("--words", path, expected="files: 1\nwords: 3\nchars: 6\n")
@cell
def fields_on_a_directory_exits_two():
"""--fields needs one record, so a directory is a refusal."""
with tempfile.TemporaryDirectory() as tmp:
result = run("--fields", tmp)
expect(result.returncode == 2, f"exit {result.returncode}, want 2")
expect("--fields" in result.stderr, f"no reason: {result.stderr!r}")
MUTANTS = [
(
"count_returns_ten",
" lines = data.count(b\"\\n\")\n"
" records = lines + (1 if data and not data.endswith(b\"\\n\") else 0)\n"
" return lines, records",
" return 10, 10",
"blank_lines_are_lines",
),
(
"count_non_blank_lines_only",
" lines = data.count(b\"\\n\")\n"
" records = lines + (1 if data and not data.endswith(b\"\\n\") else 0)\n"
" return lines, records",
" nonblank = sum(1 for _r in data.split(b\"\\n\") if _r.strip())\n"
" return nonblank, nonblank",
"blank_lines_are_lines",
),
(
"records_equal_lines",
" records = lines + (1 if data and not data.endswith(b\"\\n\") else 0)",
" records = lines",
"final_unterminated_fragment_is_a_record",
),
(
"fields_by_str_split",
" return len(next(csv.reader(io.StringIO(first))))",
" return len(first.split(\",\"))",
"quoted_separator_is_data",
),
(
"words_by_unique",
" words += len(text.split())",
" words += len(set(text.split()))",
"word_and_char_counts",
),
]
@cell
def every_mutant_is_killed():
"""Each named mutation must turn this suite red on the cell that names it."""
if "--no-mutants" in sys.argv:
return
with open(TOOL, "r", encoding="utf-8") as handle:
original = handle.read()
for name, old, new, expected_cell in MUTANTS:
expect(old in original, f"mutant {name}: anchor not found in {TOOL}")
with tempfile.TemporaryDirectory() as tmp:
mutant = os.path.join(tmp, "line_counter.py")
with open(mutant, "w", encoding="utf-8") as handle:
handle.write(original.replace(old, new, 1))
with open(mutant, "r", encoding="utf-8") as handle:
expect(
old not in handle.read(),
f"mutant {name}: the edit did not land",
)
env = dict(os.environ, LINE_COUNTER=mutant)
inner = subprocess.run(
[sys.executable, SELF, "--no-mutants"],
capture_output=True,
text=True,
env=env,
)
expect(
inner.returncode == 1,
f"mutant {name} survived: rc {inner.returncode}\n{inner.stdout}",
)
expect(
f"FAIL: {expected_cell}" in inner.stdout,
f"mutant {name} was caught, but not by {expected_cell}:\n"
f"{inner.stdout}",
)
print(
f" ({len(MUTANTS)} mutants applied to scratch copies, "
f"{len(MUTANTS)} killed)"
)
def main(argv):
if not os.path.isfile(TOOL):
print(f"CANNOT GRADE: tool missing: {TOOL}", file=sys.stderr)
return 2
failures = 0
for fn in CELLS:
name = fn.__name__
try:
fn()
except AssertionError as exc:
failures += 1
print(f"FAIL: {name} -- {exc}")
else:
print(f"PASS: {name}")
print(f"\n{len(CELLS) - failures}/{len(CELLS)} cells passed")
print("ALL CELLS PASSED" if not failures else f"{failures} CELL(S) FAILED")
return 0 if not failures else 1
if __name__ == "__main__":
sys.exit(main(sys.argv))