Commit 81eb4dc0 by PLN (Algolia)

feat(lint): pvlint — the bugs that silenced this rig, encoded as rules

Three evenings this week went to bugs that were VISIBLE IN THE TEXT and that
no eye caught mid-set, because none of them produce an error message. They
produce silence, on stage:

  * desire.tidal — `off 0.125 (|+ note 12 . (|* gain 0.6))`. `.` binds looser
    than application, so this composes a FUNCTION into a ValueMap. GHC refuses
    the whole block, and since a blank line is Tidal's block separator, ONE
    character silenced nine orbits while the file looked perfect on screen.
  * you_my_sunshine.tidal — a local `let gF3 = (# djfbus 3 (range 0.05 0.95
    "^51"))` shadowing the global helper we had already fixed. SuperDirt maps
    djf through linexp(0, 0.5, 20, 10000): bypass is 0.5, and 0.05 is a ~26 Hz
    low-pass. Four orbits (d5/d7/d9/d11) inaudible until a knob crossed centre.
  * the #55 mute-bomb — an untouched `^NN` yields NO events, so `range a b
    "^NN"` is `silence`, not a number.

All three are static properties of the file, so they belong in a linter.

Seven rules, each earned by a real failure: PV001 local djfbus shadow (with an
autofix that deletes the line so the track inherits the corrected global),
PV002 operator-section composition (autofix adds the missing parens), PV003
double-applied global gain, PV004 shared cut group, PV005 duplicate orbit in
one block, PV006 sample index past the end of its folder, PV007 orbit
inventory (the input to the orphan-orbit transition check, `--setlist`).

Three of my own rules were wrong on first contact with the corpus, and the
corpus caught all three — which is the argument for running a linter against
694 real files before trusting it:

  * PV002 was string-blind and called four mini-notation `.` separators
    compile-killers (`"k ~ ~ k . ~"` is grouping, not composition). 13 findings
    -> 0, all of them false.
  * PV003 claimed the whole 77-84 fader bank was "owned by Ardour" and raised
    1109 errors. BootTidal itself reads `^77` (midiGGlobal); reading a fader CC
    is the documented convention. Narrowed to the actual hazard — ^77 applied
    twice — and demoted to a warning.
  * PV005 flagged every re-declaration of an orbit, but keeping several
    evaluable versions per file is normal live practice. Scoped to one block:
    576 findings -> 32.
  * PV006 pooled all indices in an orbit against all folders and invented a
    finding on d8 (n=24 is org_jungle_breaks', not breaks165's).

Also fixed the parser: ORBIT_RE demanded a same-line `$` and so dropped d5
from you_my_sunshine's inventory WHILE THE TRACK WAS PLAYING IT — PLN writes
`d5  -- comment` with the `$` on the continuation line, which Haskell's layout
rule allows. A linter that under-reports is worse than none.

Corpus after the corrections: 694 tracks, 316 errors (all PV001 shadows, in
archive material), 281 warnings. First real run found a live one — d5 and d11
of you_my_sunshine share cut group 5, so the voice and the chop truncate each
other.

33 unit tests, every negative case a real corpus line that an earlier rule
version wrongly flagged.
parent 2788722c
"""pvlint — static checks for ParVagues .tidal tracks.
Every rule here encodes a bug that has silenced this rig on stage or in
rehearsal. See rules.py for the catalogue.
"""
from .core import Finding, Track, load # noqa: F401
from .rules import RULES, check # noqa: F401
"""pvlint — the ParVagues track linter.
python3 tools/pvlint live/**/*.tidal # check
python3 tools/pvlint --fix live/foo.tidal # black-style autofix in place
python3 tools/pvlint --setlist live/a.tidal live/b.tidal # + orphan check
Exit codes: 0 clean, 1 errors found, 2 warnings only (with --strict), 3 usage.
Design note — why autofix is conservative
-----------------------------------------
This runs on files that are performed live. A fix that is *usually* right is
worse than no fix, because the failure mode is silence in front of an audience.
So only rules that carry an exact replacement offer `--fix`, and a rule offers
one only when the correct output is unambiguous (delete a shadowing `let`, add
the missing parens around an operator section). Everything else reports and
lets a human decide.
"""
from __future__ import annotations
import argparse
import os
import sys
try: # `python3 -m tools.pvlint` / imported as a package
from .core import Track, load
from .rules import check
except ImportError: # `python3 tools/pvlint …` — dir-as-script, no parent package
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from pvlint.core import Track, load
from pvlint.rules import check
COLOR = {"error": "\033[31m", "warning": "\033[33m", "info": "\033[36m"}
RESET = "\033[0m"
BOLD = "\033[1m"
def render(track: Track, findings: list, show_info: bool, use_color: bool) -> None:
shown = [f for f in findings if show_info or f.severity != "info"]
if not shown:
return
c = (lambda s, k: f"{COLOR[k]}{s}{RESET}") if use_color else (lambda s, k: s)
b = (lambda s: f"{BOLD}{s}{RESET}") if use_color else (lambda s: s)
print(b(track.path))
for f in shown:
loc = f"{f.line}:"
sev = c(f.severity.upper().ljust(7), f.severity)
print(f" {loc:>6} {sev} {f.rule} {f.message}")
if f.detail:
for ln in f.detail.split("\n"):
print(f" {ln}")
print()
def apply_fixes(track: Track, findings: list) -> tuple[str, int]:
"""Apply line-replacement fixes bottom-up so earlier line numbers stay valid."""
lines = track.text.splitlines()
fixable = [f for f in findings if f.fix is not None]
applied = 0
for f in sorted(fixable, key=lambda f: -f.line):
idx = f.line - 1
if idx < 0 or idx >= len(lines):
continue
if f.fix == "":
del lines[idx]
else:
lines[idx] = f.fix
applied += 1
text = "\n".join(lines)
if track.text.endswith("\n"):
text += "\n"
return text, applied
def orphan_report(tracks: list[Track]) -> int:
"""Cross-track check: what does each transition leave playing?"""
print(f"{BOLD}orphan-orbit transitions{RESET}")
worst = 0
for a, b in zip(tracks, tracks[1:]):
left = sorted(a.declared_orbits() - b.declared_orbits())
if left:
worst = max(worst, len(left))
names = ", ".join(f"d{o}" for o in left)
print(f" {a.path.split('/')[-1]} -> {b.path.split('/')[-1]}: "
f"{COLOR['warning']}{names} keep playing{RESET}")
else:
print(f" {a.path.split('/')[-1]} -> {b.path.split('/')[-1]}: clean")
print()
return worst
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(prog="pvlint", description=__doc__.split("\n")[0])
ap.add_argument("paths", nargs="+", help=".tidal files to check")
ap.add_argument("--fix", action="store_true", help="apply safe autofixes in place")
ap.add_argument("--info", action="store_true", help="also show info findings")
ap.add_argument("--strict", action="store_true", help="warnings fail too")
ap.add_argument("--setlist", action="store_true",
help="treat paths as an ordered setlist and report orphan orbits")
ap.add_argument("--quiet", action="store_true", help="only print a summary")
ap.add_argument("--no-color", action="store_true")
args = ap.parse_args(argv)
use_color = not args.no_color and sys.stdout.isatty()
tracks: list[Track] = []
errors = warnings = fixed = 0
for path in args.paths:
try:
track = load(path)
except OSError as e:
print(f"{path}: cannot read ({e})", file=sys.stderr)
errors += 1
continue
tracks.append(track)
findings = check(track)
if args.fix:
text, n = apply_fixes(track, findings)
if n:
with open(path, "w", encoding="utf-8") as fh:
fh.write(text)
fixed += n
track = load(path)
tracks[-1] = track
findings = check(track)
if not args.quiet:
render(track, findings, args.info, use_color)
errors += sum(1 for f in findings if f.severity == "error")
warnings += sum(1 for f in findings if f.severity == "warning")
if args.setlist and len(tracks) > 1:
orphan_report(tracks)
n = len(tracks)
summary = f"pvlint: {n} track(s), {errors} error(s), {warnings} warning(s)"
if args.fix:
summary += f", {fixed} autofix(es) applied"
print(summary)
if errors:
return 1
if warnings and args.strict:
return 2
return 0
if __name__ == "__main__":
sys.exit(main())
"""pvlint core — parse a .tidal track into the shapes the rules reason about.
Why this exists (2026-07-28, J-7 to OPAL)
-----------------------------------------
Three separate evenings were lost to bugs that were *visible in the text* and
that no human eye caught mid-set:
* `desire.tidal` -> `off 0.125 (|+ note 12 . (|* gain 0.6))` parsed as a
function composed into a ValueMap. One character. NINE orbits silent, and
because a blank line is Tidal's block separator, the whole track failed to
compile while looking perfectly fine on screen.
* `you_my_sunshine.tidal` -> a local `let gF3 = (# djfbus 3 (range 0.05 0.95
"^51"))` shadowing the fixed global helper. `djf` 0.05 is a ~26 Hz
low-pass, i.e. silence, and that is where a low knob rests. FOUR orbits
(d5/d7/d9/d11) inaudible until a knob crossed centre.
* An untouched `"^NN"` yields NO events, so `range a b "^NN"` is `silence`,
not a number — the #55 mute-bomb.
Every one of these is a STATIC property of the file. None of them produce an
error message; they produce silence, on stage, with a crowd waiting. So they
belong in a linter, not in a runbook.
The parse model
---------------
A `.tidal` file is a sequence of BLOCKS separated by blank lines. Tidal
evaluates a block atomically: one syntax error anywhere in a block takes the
entire block down, which is why "9 orbits went silent" is a *compile* symptom
and not an audio one. Inside a block, an orbit is declared by `dN $ ...` at
column 0 and continues until the next such declaration or the end of the block.
That three-level model (file -> block -> orbit) is all the rules need.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Iterator
SEVERITIES = ("error", "warning", "info")
# `d1 $ ...` at column 0 — but the `$` need NOT be on the same line. Haskell's
# layout rule makes this legal, and PLN writes it live all the time:
#
# d5 -- The Voice of Love
# $ midiOn "^58" (...)
#
# An earlier version of this regex required a same-line `$` and silently dropped
# d5 from the orbit inventory of a track that was playing it — a linter that
# under-reports is worse than none, so the lookahead accepts `$`, a comment, or
# end-of-line.
ORBIT_RE = re.compile(r"^(d(\d{1,2}))(?=\s*(?:\$|--|$))")
# A Haskell line comment. Not string-aware on purpose: `--` inside a mini-notation
# string is not valid Tidal anyway, and being conservative here costs nothing.
COMMENT_RE = re.compile(r"--.*$")
@dataclass
class Finding:
"""One lint result, anchored to a 1-indexed line."""
rule: str
severity: str
line: int
message: str
detail: str = ""
# Autofix: replace line `line` (1-indexed) entirely with `fix`. None = no fix.
# A fix of "" means "delete this line".
fix: str | None = None
def __post_init__(self) -> None:
if self.severity not in SEVERITIES:
raise ValueError(f"bad severity {self.severity!r}")
@dataclass
class Orbit:
"""One `dN $ ...` declaration and its continuation lines."""
number: int
start: int # 1-indexed line of the `dN $`
lines: list[str] = field(default_factory=list)
@property
def text(self) -> str:
return "\n".join(self.lines)
def code(self) -> str:
"""The orbit's text with comments stripped — what Tidal actually sees."""
return "\n".join(strip_comment(ln) for ln in self.lines)
@dataclass
class Block:
"""A blank-line-delimited unit. Tidal compiles this atomically."""
start: int # 1-indexed line of the first line
lines: list[str] = field(default_factory=list)
@property
def text(self) -> str:
return "\n".join(self.lines)
@dataclass
class Track:
path: str
text: str
@property
def lines(self) -> list[str]:
return self.text.splitlines()
def blocks(self) -> list[Block]:
out: list[Block] = []
cur: Block | None = None
for i, ln in enumerate(self.lines, start=1):
if ln.strip() == "":
cur = None
continue
if cur is None:
cur = Block(start=i)
out.append(cur)
cur.lines.append(ln)
return out
def orbits(self) -> list[Orbit]:
"""Every `dN $` declaration in the file, with its continuation lines.
An orbit ends at the next column-0 `dN $`, at a blank line, or at EOF.
Later declarations of the same N are kept separately: re-declaring `d5`
in one file is itself worth flagging, and silently merging them would
hide it.
"""
out: list[Orbit] = []
cur: Orbit | None = None
for i, ln in enumerate(self.lines, start=1):
m = ORBIT_RE.match(ln)
if m:
cur = Orbit(number=int(m.group(2)), start=i, lines=[ln])
out.append(cur)
continue
if ln.strip() == "":
cur = None
continue
if cur is not None:
cur.lines.append(ln)
return out
def declared_orbits(self) -> set[int]:
return {o.number for o in self.orbits()}
def strip_comment(line: str) -> str:
"""Remove a trailing Haskell comment.
Deliberately naive: we do not attempt to respect `--` inside a string
literal. Tidal mini-notation has no legitimate `--`, and a false strip can
only ever cause a MISSED finding, never a wrong autofix (autofixing rules
re-match against the raw line).
"""
return COMMENT_RE.sub("", line)
def iter_code_lines(track: Track) -> Iterator[tuple[int, str]]:
"""(1-indexed line number, comment-stripped text) for non-blank lines."""
for i, ln in enumerate(track.lines, start=1):
code = strip_comment(ln)
if code.strip():
yield i, code
def load(path: str) -> Track:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
return Track(path=path, text=fh.read())
"""Unit tests for pvlint rules.
Every test here is a REGRESSION: the positive cases are bugs that actually
silenced the rig, and the negative cases are real lines from the corpus that an
earlier version of a rule wrongly flagged. A rule that cries wolf on a pre-gig
gate is worse than no rule, so the negative cases carry equal weight.
"""
from __future__ import annotations
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))))
from pvlint.core import Track, strip_comment # noqa: E402
from pvlint.rules import check # noqa: E402
def lint(src: str, rule: str | None = None):
findings = check(Track(path="<test>", text=src))
if rule:
findings = [f for f in findings if f.rule == rule]
return findings
# ---------------------------------------------------------------- core parse
def test_orbit_needs_no_same_line_dollar():
"""Haskell layout lets the `$` start the next line — and PLN writes that.
The first ORBIT_RE required a same-line `$` and silently dropped d5 from
you_my_sunshine's inventory while the track was playing it.
"""
src = 'd5 -- The Voice of Love\n $ midiOn "^58" (# n 1)\n # cut 5\n'
assert Track(path="t", text=src).declared_orbits() == {5}
def test_blank_line_splits_blocks():
src = "d1 $ s \"bd\"\n\nd2 $ s \"sn\"\n"
blocks = Track(path="t", text=src).blocks()
assert len(blocks) == 2
def test_orbit_continuation_lines_are_captured():
src = 'd1 $ s "bd"\n # gain 1.2\n # cut 1\n'
orb = Track(path="t", text=src).orbits()[0]
assert len(orb.lines) == 3
def test_strip_comment():
assert strip_comment('d1 $ s "bd" -- kick').strip() == 'd1 $ s "bd"'
# ------------------------------------------------------------------- PV001
def test_pv001_flags_local_djfbus_shadow():
"""The 2026-07-28 bug: four orbits inaudible until a knob crossed centre."""
src = 'let gF3 = (# djfbus 3 (range 0.05 0.95 "^51"))\n'
f = lint(src, "PV001")
assert len(f) == 1 and f[0].severity == "error"
def test_pv001_autofix_deletes_the_line():
f = lint('let gF3 = (# djfbus 3 (range 0.05 0.95 "^51"))\n', "PV001")
assert f[0].fix == ""
def test_pv001_ignores_a_local_gmask_without_djfbus():
"""Redefining gMask locally is a musical choice, not the footgun."""
assert lint('let gMask = (midiOn "^41" (mask "t f"))\n', "PV001") == []
# ------------------------------------------------------------------- PV002
def test_pv002_flags_the_desire_bug():
"""`(|+ note 12 . (|* gain 0.6))` — killed nine orbits in one block."""
src = 'd1 $ off 0.125 (|+ note 12 . (|* gain 0.6)) $ s "bd"\n'
f = lint(src, "PV002")
assert len(f) == 1 and f[0].severity == "error"
def test_pv002_autofix_adds_the_missing_parens():
src = 'd1 $ off 0.125 (|+ note 12 . (|* gain 0.6)) $ s "bd"\n'
f = lint(src, "PV002")
assert f[0].fix is not None
assert "((|+ note 12) . (|* gain 0.6))" in f[0].fix
def test_pv002_accepts_the_corrected_form():
src = 'd1 $ off 0.125 ((|+ note 12) . (|* gain 0.6)) $ s "bd"\n'
assert lint(src, "PV002") == []
@pytest.mark.parametrize("src", [
' $ whenTrap (|<| "k ~ ~ k . ~")',
' $ whenmod 16 15 (# gain "0 . 0 . [0 . 1] . 1")',
' $ every 4 (# gain "0 . 0 1")',
' $ always (|+ note "0 . <[-2 -4] [5 0]>")',
])
def test_pv002_ignores_mininotation_dots(src):
"""`.` inside a string is mini-notation grouping, NOT Haskell composition.
All four of these are real corpus lines that the first, string-blind
scanner reported as compile-killers.
"""
assert lint(src + "\n", "PV002") == []
def test_pv002_ignores_decimal_points():
assert lint('d1 $ s "bd" # (|* gain 0.6)\n', "PV002") == []
# ------------------------------------------------------------------- PV003
def test_pv003_flags_the_global_gain_cc():
f = lint('d1 $ s "bd" # gain "^77"\n', "PV003")
assert len(f) == 1 and f[0].severity == "warning"
@pytest.mark.parametrize("cc", [49, 51, 76, 78, 80, 84, 91])
def test_pv003_allows_every_other_cc_including_the_fader_bank(cc):
"""Reading a fader CC in a track is the documented convention, not a bug.
Flagging the whole 77-84 bank produced 1109 corpus errors, all wrong.
"""
assert lint(f'd1 $ s "bd" # gain "^{cc}"\n', "PV003") == []
# ------------------------------------------------------------------- PV004
def test_pv004_flags_shared_cut_group_across_orbits():
"""you_my_sunshine: d5 (voice) and d11 both `cut 5` — they truncate each other."""
src = 'd5 $ s "voice" # cut 5\n\nd11 $ s "chop" # cut 5\n'
f = lint(src, "PV004")
assert {x.line for x in f} == {1, 3}
def test_pv004_one_finding_per_orbit_not_per_occurrence():
src = 'd5 $ s "a" # cut 5\n # cut 5\n\nd11 $ s "b" # cut 5\n'
assert len(lint(src, "PV004")) == 2
def test_pv004_allows_distinct_groups():
src = 'd5 $ s "a" # cut 5\n\nd11 $ s "b" # cut 11\n'
assert lint(src, "PV004") == []
# ------------------------------------------------------------------- PV005
def test_pv005_flags_duplicate_orbit_in_one_block():
src = 'd1 $ s "bd"\nd1 $ s "sn"\n'
f = lint(src, "PV005")
assert len(f) == 1 and f[0].line == 2
def test_pv005_allows_redeclaration_in_a_later_block():
"""Keeping several evaluable versions of an orbit is normal live practice."""
src = 'd1 $ s "bd"\n\nd1 $ s "sn"\n'
assert lint(src, "PV005") == []
# ------------------------------------------------------------------- PV006
def test_pv006_skips_when_sample_root_is_absent(monkeypatch, tmp_path):
monkeypatch.setenv("PVLINT_SAMPLE_ROOTS", str(tmp_path / "nope"))
assert lint('d1 $ s "ghostfolder:99"\n', "PV006") == []
def test_pv006_flags_index_past_end_of_folder(monkeypatch, tmp_path):
d = tmp_path / "twohit"
d.mkdir()
(d / "a.wav").write_bytes(b"")
(d / "b.wav").write_bytes(b"")
monkeypatch.setenv("PVLINT_SAMPLE_ROOTS", str(tmp_path))
f = lint('d1 $ s "twohit:5"\n', "PV006")
assert len(f) == 1 and "5" in f[0].message
def test_pv006_accepts_in_range_index(monkeypatch, tmp_path):
d = tmp_path / "twohit"
d.mkdir()
(d / "a.wav").write_bytes(b"")
(d / "b.wav").write_bytes(b"")
monkeypatch.setenv("PVLINT_SAMPLE_ROOTS", str(tmp_path))
assert lint('d1 $ s "twohit:1"\n', "PV006") == []
def test_pv006_does_not_pool_indices_across_folders(monkeypatch, tmp_path):
"""d8 of you_my_sunshine: n=24 belongs to org_jungle_breaks, not breaks165.
Pooling every index in an orbit against every folder invented a finding.
"""
one = tmp_path / "breaks165"
one.mkdir()
(one / "a.wav").write_bytes(b"")
many = tmp_path / "org_jungle_breaks"
many.mkdir()
for i in range(30):
(many / f"{i}.wav").write_bytes(b"")
monkeypatch.setenv("PVLINT_SAMPLE_ROOTS", str(tmp_path))
src = 'd8 $ s "org_jungle_breaks:24" # s "breaks165"\n'
assert lint(src, "PV006") == []
# ------------------------------------------------------------------- PV007
def test_pv007_reports_the_orbit_inventory():
src = 'd1 $ s "bd"\n\nd7 $ s "sn"\n'
f = lint(src, "PV007")
assert len(f) == 1 and "d1,d7" in f[0].message
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