Commit ff569232 by PLN (Algolia)

feat(rules): one parser for .tidal, enforced by a shrink-only ratchet

I wrote a THIRD .tidal parser this session -- an ad-hoc regex census of which
synths the set calls -- while tools/setlist_samples.py already answered exactly
that. It produced garbage twice: a substring grep matched the synth `gfunk`
against the `gfunk_*` SAMPLE banks, then the regex version read `s "k"` (58
files) as a missing sample and I reported it as a possible bug. PLN caught both
in seconds. `#` is `|>` -- structure from the left, values from the RIGHT -- so
`s "k" # s "jazz"` plays jazz and `k` is a rhythm skeleton that must never
resolve. Nothing was broken.

His diagnosis is the one worth keeping: a CLAUDE.md line alone would be a
bandage, because the cause is not ignorance but COST ASYMMETRY. tools/ holds ~40
scripts with no index and the canonical resolvers are human-facing CLIs, not
importable functions, so writing a regex cost 30 seconds and finding the parser
cost a search. The cheap path wins under pressure. What black teaches Python
devs is not taste, it is that the decision is gone.

So: a rule in CLAUDE.md that states the trap concretely (with the `s "k" # s
"jazz"` example, since the rule is useless if you don't believe it), plus
structural enforcement in tools/tests/test_one_tidal_parser.py.

The test matches the SHAPE of a Tidal call-site regex, not a blocklist of
filenames, so a new file is covered the day it is written. It is a RATCHET, not
an amnesty: KNOWN_DEBT may only shrink. A file not on the list that starts
parsing fails immediately; an entry that stops offending ALSO fails, so paying
one off must be recorded. Mutation-verified in both directions -- a planted
regex turns it red, and a fictitious paid-off entry turns it red -- because a
green test is not evidence that it can fail.

Enforcement immediately found FIVE pre-existing hand-rolled parsers I did not
know about, plus a sixth once the markers were tightened. They are not the same
mistake, so they are listed individually rather than waved through by
directory: tidal_score.py, sample_tfidf.py and pattern_ngrams.py genuinely
duplicate bank extraction and are what #23 should absorb; at/lens.py reads
`mask "..."`, pvlint/rules.py reads `# n "..."` and deshadow-helpers.py rewrites
whole source lines -- different concepts, and forcing those through a bank
resolver would be the wrong API.

Precision here is load-bearing: a looser first draft flagged
tools/setlist.py's "^(sound\s*check|line\s*check|balance)$", which is about a
stage soundcheck and has nothing to do with Tidal. A rule that cries wolf gets
switched off.

Also fixes a fake test inside the hollow-suite guard itself: test_suite_hygiene's
`test_files` was a HELPER, collected by pytest as a test that passed by
returning a list without asserting anything -- the exact shape that file exists
to catch. Renamed to _gather_test_files. That was the suite's only
PytestReturnNotNone warning.

903 passed, 0 failed, 28 warnings.
parent fa7aef0b
......@@ -54,6 +54,40 @@ ParVagues' main livecoding workspace.
Verify there and cite the path — don't copy strings from intermediate scripts (e.g.
`split_bandcamp.py`'s ALBUM was wrong). If a fact isn't in the canonical source, ask.
## Ethos — the zen art of simple maintenance
- **"Is this screw useful?"** Before adding a file, check, flag, service or
layer, say what failure it prevents, how likely that is, and what carrying it
costs. "It feels safer" means it's a screw with no nut. Prefer removing a part
to adding one. Rationalize; don't accumulate.
- **Measure before mechanism.** "We don't know yet" is a legitimate answer and
usually cheaper than machinery built to cover for not knowing. An artifact
nobody compares against reality is worse than none — it lends false
confidence (see `preload.scd`, generated + gitignored + never compared, which
debuted as a crackle at the venue).
- **Read the context before calling something broken.** `grep -C 4` until the
surrounding lines explain why the code is there. Assume it had a reason —
Chesterton's fence — and go find it. Reporting a scary number about PLN's own
work costs his attention; spend a tool call first. (`s "k"` looked like 58
broken tracks; `#` is `|>`, so `s "k" # s "jazz"` plays jazz and `k` is pure
rhythm skeleton. Nothing was wrong.)
- **Never read `.tidal` files with your own regex.** A `.tidal` file is a
program, not data — its meaning is Tidal's to compute. `#` is `|>`
(structure from the left, **values from the right**), so `s "k" # s "jazz"`
plays *jazz* and `k` is a rhythm skeleton that must never resolve. Rules like
that are endless, and a regex always returns something, so the failures are
silent. **Import `tools/setlist_samples.py`**`extract_names`,
`build_index`, `resolve_track`, `read_setlist`, `bank_file_count`. An
unresolved name is NORMAL (a synth, or structure overridden downstream) and
must never be reported as an error. Enforced by
`tools/tests/test_one_tidal_parser.py`, a shrink-only ratchet: new hand-rolled
parsers fail the suite, and paying off a `KNOWN_DEBT` entry is required to be
recorded. Prefer *evaluating* over parsing where a rig is available —
`tools/silent-eval.py` asks Tidal itself.
- **One vocabulary.** Nouns (Mix, Bank, Sample, Set, Take, Orbit, Gig, Stem) and
verbs (Check, Gen, Read, Lint, Grade, Pack) in one fixed word order, in
filenames and symbol names alike, so a name can be guessed without an index.
## Design system (armada tools)
The armada UIs share one design language — **the Ship's Bridge**. Canonical:
......
#!/usr/bin/env python3
"""One parser for .tidal files. Everything else imports it.
WHAT BITES. A `.tidal` file is not data — it is ghci input whose meaning is
defined by Tidal's evaluator. Every rule you hand-implement is one you get
subtly wrong, and the failures are quiet because a regex always returns
something. Two real ones, found the same afternoon:
* a substring grep for the synth `gfunk` matched the `gfunk_*` SAMPLE banks,
and `wobble` matched a bank of its own — so a "which synths does the set
use" census reported load-bearing synths that were nothing of the kind;
* a fresh regex read `s "k"` (58 files) as a missing sample and called it a
possible bug. But `#` is `|>` — structure from the LEFT, values from the
RIGHT — so `s "k" # s "jazz"` plays jazz and `k` is a pure rhythm skeleton
that must never resolve. Nothing was broken.
Both are impossible if you ask Tidal instead of guessing, and both were
possible only because writing a regex was CHEAPER than finding the parser that
already existed. That cost asymmetry, not ignorance, is the bug this file
attacks: a rule you must remember fails exactly when you are deep in a task.
So the invariant is structural: exactly one module extracts sample/bank names
from .tidal sources, and nothing else may. Unresolved names are NORMAL (a synth
name, or structure overridden downstream) and must never be reported as an
error — that reports our own misunderstanding.
Run standalone: python3 tools/tests/test_one_tidal_parser.py
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
SKIP_PARTS = {".venv", "node_modules", ".git", "worktrees", "site-packages", "__pycache__"}
# THE sanctioned parser. Consumers import from it; its internals are its own
# business. Named here rather than in prose so the allowlist cannot drift from
# the rule. When #23 replaces the regexes with evaluation, this path moves and
# nothing else has to.
PARSER = "tools/setlist_samples.py"
# A file may opt out with this marker on the same line, for a genuine one-off.
# It is deliberately loud: an opt-out you can grep for beats one you cannot.
OPT_OUT = "tidal-parse-ok"
# KNOWN DEBT, and this is a RATCHET, not an amnesty. These files already read
# Tidal syntax with their own regexes. The list may only ever SHRINK: a file
# that stops offending must be removed from it (the test fails if it lingers),
# and any file NOT on it that starts offending fails immediately. So the rule
# binds all new code today, without a refactor standing between us and a gig.
#
# Not all of these are the same mistake, which is why they are listed
# individually rather than waved through by directory:
# * tidal_score.py, sample_tfidf.py, pattern_ngrams.py genuinely duplicate
# BANK-NAME extraction — they are what #23 should absorb.
# * lens.py reads `mask "..."` and pvlint/rules.py reads `# n "..."`. Those
# are different concepts, not sample banks; forcing them through a bank
# resolver would be the wrong API. They belong to whatever #23 exposes for
# control/note patterns, and until that exists their regexes stay.
KNOWN_DEBT = {
"armada/tide-table/pattern_ngrams.py",
"armada/tide-table/tidal_score.py",
"tools/at/lens.py",
"tools/pvlint/rules.py",
"tools/sample_tfidf.py",
# A .tidal REWRITER: matches whole source lines to comment/uncomment steps.
# Not bank extraction at all, and chain-step boundaries must be paren-aware
# for it to be correct -- a known trap. Surfaced only when the marker set was
# tightened, which is itself the argument for a shape-based rule.
"tools/deshadow-helpers.py",
}
# Regex source fragments that only appear when someone is reading Tidal syntax:
# the mini-notation call sites and the operator chain. Matching the SHAPE, not a
# blocklist of filenames, so a new file is covered the day it is written.
# Each marker must imply a Tidal CALL SITE — a parameter name adjacent to a
# quoted mini-notation string. An earlier, looser version flagged
# tools/setlist.py's "^(sound\\s*check|line\\s*check|balance)$", which is about
# a stage soundcheck and has nothing to do with Tidal: a rule that cries wolf
# gets switched off, so precision here is load-bearing.
TIDAL_REGEX_MARKERS = (
'"([^"]*)"', # the quoted mini-notation harvest
'\\bsound\\b', # sound "..." call sites
'\\bs\\b', # s "..." call sites
'mask\\s', # mask "..." call sites
)
def _looks_like_tidal_regex(value: str) -> bool:
"""A quoted-string harvest, or a param name next to one."""
if '"([^"]*)"' in value:
return True
return any(m in value for m in TIDAL_REGEX_MARKERS[1:]) and '"' in value
def _py_files():
return sorted(
p for p in REPO.rglob("*.py")
if not any(part in SKIP_PARTS for part in p.parts)
)
def _string_constants(tree):
"""Every string literal in the file, with its line number."""
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
yield node.lineno, node.value
def _offenders():
"""Files outside the parser that embed Tidal-syntax regexes."""
out = []
for path in _py_files():
rel = path.relative_to(REPO).as_posix()
if rel == PARSER or path.resolve() == Path(__file__).resolve():
continue
try:
src = path.read_text(errors="replace")
tree = ast.parse(src)
except SyntaxError:
continue
lines = src.splitlines()
for lineno, value in _string_constants(tree):
if not _looks_like_tidal_regex(value):
continue
line = lines[lineno - 1] if lineno <= len(lines) else ""
if OPT_OUT in line:
continue
out.append((rel, lineno, value[:60]))
return out
OFFENDERS = _offenders()
def test_the_scan_actually_looked_at_the_repo():
"""A guard that scans nothing passes everything."""
files = _py_files()
assert len(files) >= 30, f"only {len(files)} python files found under {REPO}"
assert (REPO / PARSER).exists(), f"the sanctioned parser {PARSER} is missing"
def test_no_new_file_parses_tidal_itself():
"""The rule that binds today: nothing NEW may hand-roll a .tidal regex."""
new = sorted({r for r, _, _ in OFFENDERS} - KNOWN_DEBT)
detail = "\n".join(f" {r}:{n} {v!r}" for r, n, v in OFFENDERS
if r not in KNOWN_DEBT)
assert not new, (
"these files embed Tidal-syntax regexes instead of importing the one parser:\n"
+ detail
+ f"\n\nimport from {PARSER} instead (extract_names / build_index /"
f" resolve_track / read_setlist / bank_file_count).\nFor a genuine one-off,"
f" put '{OPT_OUT}' in a comment on the offending line. Do NOT add it to"
" KNOWN_DEBT — that list only shrinks."
)
def test_the_debt_list_only_shrinks():
"""A ratchet that never tightens is a comment. Paid-off entries must go."""
offending = {r for r, _, _ in OFFENDERS}
stale = sorted(KNOWN_DEBT - offending)
assert not stale, (
"these are on KNOWN_DEBT but no longer parse .tidal themselves — good news:\n"
+ "\n".join(f" {r}" for r in stale)
+ "\n\nremove them from KNOWN_DEBT in this file to lock the win in."
)
def main():
failures = 0
for name, fn in sorted(globals().items()):
if name.startswith("test_") and callable(fn):
try:
fn()
print(f"PASS {name}")
except AssertionError as exc:
failures += 1
print(f"FAIL {name}\n{exc}")
print(f"\n{len(_py_files())} python files scanned, {len(OFFENDERS)} offender(s)")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
......@@ -45,7 +45,7 @@ def _skip(path: Path) -> bool:
return any(part in SKIP_PARTS for part in path.parts)
def test_files() -> list[Path]:
def _gather_test_files() -> list[Path]:
return sorted(p for p in REPO.rglob("test_*.py") if not _skip(p))
......@@ -95,7 +95,7 @@ def _audit() -> tuple[list[str], list[str], list[str]]:
conf = _conftest_fixtures()
hollow, uncallable_, module_exit = [], [], []
for path in test_files():
for path in _gather_test_files():
rel = path.relative_to(REPO)
tree = _parse(path)
if tree is None:
......@@ -129,7 +129,7 @@ HOLLOW, UNCALLABLE, MODULE_EXIT = _audit()
def test_the_audit_actually_scanned_something():
"""The guard's own hollowness check. If the glob breaks, every test below
passes vacuously — which is the exact failure being guarded against."""
files = test_files()
files = _gather_test_files()
assert len(files) >= 40, f"only {len(files)} test files found under {REPO}"
......@@ -160,7 +160,7 @@ def test_no_suite_exits_at_import():
if __name__ == "__main__":
print(f"scanned {len(test_files())} test files under {REPO}")
print(f"scanned {len(_gather_test_files())} test files under {REPO}")
for label, rows in (("HOLLOW", HOLLOW), ("UNCALLABLE", UNCALLABLE),
("EXITS AT IMPORT", MODULE_EXIT)):
print(f"\n{label} ({len(rows)}):")
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment