Skip to content

Commit 462de4d

Browse files
nschneirclaude
andcommitted
merge(shared-code): the shared-code aftermath plan — two tasks
The tail of the 2026-08-10 shared-code plan, in two parts. - Task 1 collapses the duplications that plan's inventory could not see: it was taken at 6dfba7c, before the 2026-08-08 branch landed, so every pair that branch introduced was invisible to it. Seven rows, re-inventoried by grep rather than re-verified from the table — which dropped row 3 (its three per-file "left running" hits turn out to be rows 5+7 plus an unpaired sentence) and inverted row 2 (it is the ops-level ValueError the CLI cannot reach, not the other way round: the CLI guard answers --samples 0 without demanding a session, so both survive, each commented with why the other exists). The shared spines land in the library — ops.RUNAWAY_ROUTINE, ops.profile_hazard, ops.key_state_note, ops.stopped_wait_diagnosis, charset.parse_charset_file, charset.check_label — with the halves that must differ per front end passed in as arguments, and cli._wait_verdict collapses the two-sample logic the error-contract branch left triplicated inline. Every one of the twenty user-visible messages renders byte-identically; that was verified twice, independently, against the pre-change literals. - Task 2 closes the same branch's deferred minors: six coverage tests (basic type --json, protocol.op_name's empty-mask contract, ops.session_ref's labels=None default, disk.get_file's ALPHA -> ALPHA.prg docstring claim, the profile runaway clause, audio.sid_report's silent-omission branch), one invariant collapsed — sid_report now raises rather than silently omitting "peak", so the rule stops being spread across three files — plus the comment, docstring and annotation pass. A final-review fix wave pins the per-front-end halves the shared spines made swappable: four slot-anchored assertions, mutation-proved in both directions, and mcp_server now spells its own check_label flag with the library default removed so no caller can be correct by accident. Suite 2059 passed / 1 skipped (baseline 2039/1). ruff and pyright clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SASautcihGg9ZuUDhhbbFV
2 parents 67cfc9f + 45485c9 commit 462de4d

20 files changed

Lines changed: 726 additions & 193 deletions

‎docs/cli.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,8 @@ List all checkpoints with hit counts.
414414
JSON: `{"breakpoints": [{"id", "address", "end", "op", "enabled", "hits",
415415
"has_condition"}, ...]}`. `op` is the **string** `exec`, `load` or `store` —
416416
several joined by `|` in that order (`load|store`), never the raw bitmask.
417+
A checkpoint with none of the three bits set reports the empty string `""`
418+
rather than a placeholder word; VICE has never been seen to produce one.
417419
`c64_break_list` reports the same string.
418420

419421
### `c64 break remove` (alias: `c64 break rm`)

‎src/c64lib/audio.py‎

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1336,10 +1336,18 @@ def sid_report(log_path, outdir, wav_path=None, ref_path=None, *,
13361336
`peak_hz` adds a `"peak"` key measuring the recording's loudest
13371337
frequency, and needs a `wav_path` — a dominant partial is a property of
13381338
the recording, not of the register log. Off by default because it is one
1339-
rFFT over the whole file. Refusing the combination is the caller's, and
1340-
both front ends do refuse it, each naming its own flags; with no WAV
1341-
here there is nothing to measure, so no `"peak"` is attached.
1339+
rFFT over the whole file. Without a WAV it raises `ValueError` rather
1340+
than returning a report with no `"peak"` in it: `peak_hz=True` promises
1341+
the key, and the CLI renders it unconditionally. Both front ends refuse
1342+
the combination first in their own flag names, so this is a backstop for
1343+
a library caller and no user ever reads it.
13421344
"""
1345+
if peak_hz and wav_path is None:
1346+
# Before anything is parsed or written, like both front ends' own
1347+
# refusals: a call that cannot produce what it asked for costs nothing
1348+
# and leaves no half-written `outdir` behind.
1349+
raise ValueError("peak_hz needs a wav_path: a dominant partial is a "
1350+
"property of the recording, not of the register log")
13431351
import yaml
13441352

13451353
# Imported here, not at module scope: this pulls in numpy and Pillow, and
@@ -1362,7 +1370,7 @@ def sid_report(log_path, outdir, wav_path=None, ref_path=None, *,
13621370
# Neither an OSError nor a ValueError, so it would reach a front end
13631371
# as a traceback; a hand-written score is exactly where a typo lands.
13641372
raise AudioError(f"{ref_path} is not readable YAML ({e})") from e
1365-
metrics = spectrogram = None
1373+
metrics = spectrogram = peak = None
13661374
if wav_path is not None:
13671375
try:
13681376
metrics = sid_analysis.wav_metrics(wav_path)
@@ -1372,6 +1380,16 @@ def sid_report(log_path, outdir, wav_path=None, ref_path=None, *,
13721380
# Not an OSError and not a ValueError, so it would reach a front
13731381
# end as a traceback: a truncated or non-RIFF file is a report.
13741382
raise AudioError(f"{wav_path} is not a readable WAV ({e})") from e
1383+
if peak_hz:
1384+
# Measured HERE, inside the only block that knows there is a WAV,
1385+
# rather than beside the key it becomes: the guard at the top of
1386+
# this function makes "peak_hz was asked for" and "there is a
1387+
# recording to measure" one condition, and this is where a reader
1388+
# (and a type checker) can see that they are. Read through the
1389+
# module imported at the top of this function, for the startup
1390+
# reason stated there — the same reason both front ends used to
1391+
# import `dominant_partial_hz` by hand at their call sites.
1392+
peak = sid_analysis.dominant_partial_hz(wav_path)
13751393
# After the metrics, not before: one of the anomaly checks reads a note the
13761394
# log calls sounding against the levels the recording actually reached, so
13771395
# the WAV has to have been measured first. Without one it is skipped and
@@ -1406,14 +1424,11 @@ def sid_report(log_path, outdir, wav_path=None, ref_path=None, *,
14061424
if metrics is not None else None),
14071425
**timing,
14081426
}
1409-
# `and wav_path is not None` re-states what both front ends already
1410-
# refused, so a library caller that skipped the refusal gets a report
1411-
# without a peak rather than a crash inside the FFT.
1412-
if peak_hz and wav_path is not None:
1413-
# Through the module already imported at the top of this function, for
1414-
# the startup reason stated there — which is the same reason both front
1415-
# ends used to import `dominant_partial_hz` by hand at the call site.
1416-
out["peak"] = sid_analysis.dominant_partial_hz(wav_path)
1427+
# No second `wav_path is not None` here: the guard at the top of this
1428+
# function is the whole rule, so `"peak"` is in the payload exactly when
1429+
# it was asked for, which is what lets a front end index it unconditionally.
1430+
if peak_hz:
1431+
out["peak"] = peak
14171432
return out
14181433

14191434

‎src/c64lib/charset.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
from __future__ import annotations
1717

18+
import re
19+
from pathlib import Path
1820
from typing import NamedTuple
1921

2022
_MC_LEGEND = {".": 0b00, "1": 0b01, "2": 0b10, "3": 0b11}
@@ -136,6 +138,46 @@ def close(lineno: int) -> None:
136138
return glyphs
137139

138140

141+
def parse_charset_file(path: str | Path,
142+
multicolor: bool = True) -> list[Glyph]:
143+
"""Read an authored sheet from disk and split it into `Glyph` blocks.
144+
145+
The charset twin of `sprites.encode_sheet_file`: the read, and the naming
146+
of the file in whatever it raises, happen once — `c64 charset encode` and
147+
the c64_charset_encode tool had one each, worded identically, and only the
148+
rendering of a failure is the front ends' own half.
149+
150+
`read_text` on a .prg or a .png raises UnicodeDecodeError — which IS a
151+
ValueError and is NOT an OSError, exactly the trap the CLI twin once
152+
leaked a traceback through — and whose own message is a byte offset and a
153+
codec: true, and no help in saying which of the paths was the wrong one.
154+
The emptiness check needs no line here; `parse_charset` already raises
155+
"no glyphs found" for a sheet that holds none.
156+
"""
157+
try:
158+
text = Path(path).read_text()
159+
except (OSError, ValueError) as e:
160+
raise ValueError(f"cannot read charset sheet {path}: {e}") from None
161+
return parse_charset(text, multicolor=multicolor)
162+
163+
164+
def check_label(label: str, flag: str) -> None:
165+
"""Reject a block label ca65 could not assemble, naming the caller's flag.
166+
167+
Beside `format_glyphs` because that is what emits the label — as `NAME:`
168+
and `NAME_end:`, so anything the assembler will not take as an identifier
169+
produces a file that cannot be included rather than an error here. `flag`
170+
is the only thing either front end passes: the CLI says `--label`, the
171+
tool says `label`, and each caller is told about the one it used. It has
172+
no default on purpose — a default is one front end's spelling silently
173+
lent to the other, which is the drift this helper exists to prevent.
174+
"""
175+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", label):
176+
raise CharsetError(
177+
f"{flag} {label!r} is not an assembler identifier (letters, digits "
178+
f"and underscore, not starting with a digit)")
179+
180+
139181
def encode_row(row: str, multicolor: bool = True) -> int:
140182
"""Pack one art row into one charset byte (MSB = leftmost pixel)."""
141183
legend, bits = (_MC_LEGEND, 2) if multicolor else (_HIRES_LEGEND, 1)

0 commit comments

Comments
 (0)