Commit 6d7df300 by PLN (Algolia)

fix(silent-eval): the harness matched ghci — and found a setlist track that does not compile (#93)

Four of thirteen setlist tracks could not be built, so their orbits had never been
verified cold. Two causes, both the same shape: THE HARNESS DIVERGED FROM THE BOOT
ENVIRONMENT, which is what #93 predicted would keep producing new errors.

1. Ambiguous `cutoff`. BootTidal defines `let cutoff = pF "cutoff"`. In ghci a let
   binding SHADOWS an import, quietly and legally. The harness dedents that block to
   module top level, where there is no shadowing — GHC reports "Ambiguous occurrence"
   and the track becomes unverifiable for a difference that does not exist on the rig.
   Fixed by hiding every name the generated module defines from the Context import,
   with the hide-list DERIVED from the generated text rather than listed, so it cannot
   drift when BootTidal gains a helper. 235 names on the current boot.

2. Overlapping IsString instances on a chord literal
   ("<gb3'maj db3'maj bb2'min bb2'maj>" could be ParseBP's `Pattern a` or Simple's
   `ControlPattern`). ExtendedDefaultRules and NoMonomorphismRestriction are ON BY
   DEFAULT IN GHCI, which is where these patterns actually run. Turning them on
   matched the environment; annotating each literal would have treated the symptom
   one track at a time forever.

=== And then the third failure turned out not to be a harness limit at all

`live/collab/raph/desire.tidal` line 46 reads `# pan 0.42plz /se`. It is committed,
not a stray working-copy edit. `plz` is not a Tidal function and the file has NO
blank lines, so it is one single do-block: that one typo means every orbit of desire
is dead on ctrl+enter. It is on the OPAL setlist, six days out.

The tool had been reporting it as "BUILD FAILED — harness limit, NOT a track verdict".
That message was written to protect the music from the tool, and it was right twice
and catastrophically wrong the third time: it told the reader, in bold, to ignore a
broken setlist track. A parser miss must never masquerade as a data conflict, and the
inverse is worse.

So the verdict is now computed, not asserted. Errors whose line numbers fall inside
the track's own generated lines get a distinct BROKEN verdict — "THE TRACK DOES NOT
COMPILE — it will fail live too" — and count as a gig blocker. Errors in the boot
helpers or the scaffolding stay a harness limit. Line numbers are also mapped back to
the real .tidal by matching the offending line's TEXT, because a generated-module line
number is useless to whoever has to fix the file and looks authoritative while being
so; unmatched or ambiguous lines are left alone rather than guessed at.

Result: 12/13 build and every declared orbit emits events cold. The 13th is a real
bug in the music, reported as one, with a file:line you can jump to.

NOT FIXED HERE deliberately: desire.tidal is in PLN's dirty working tree and Pulsar
saves the BUFFER, so a disk edit made behind his back can be silently reverted by his
next ctrl+S. It is item 1 of MORNING.md instead, with the one-line fix, to be applied
in the editor where it will actually stick.
parent f9b2c83a
...@@ -175,6 +175,19 @@ def orbit_bindings(track: Track) -> list[tuple[str, int, int, str]]: ...@@ -175,6 +175,19 @@ def orbit_bindings(track: Track) -> list[tuple[str, int, int, str]]:
return out return out
# Haskell keywords that can open a line and would otherwise be scraped as names.
_HS_KEYWORDS = {"import", "module", "where", "let", "in", "do", "if", "then",
"else", "case", "of", "data", "type", "class", "instance",
"newtype", "deriving", "infix", "infixl", "infixr"}
def top_level_names(src: str) -> set[str]:
"""Lowercase identifiers bound at column 0 — i.e. what this module defines."""
got = set(re.findall(r"(?m)^([a-z][A-Za-z0-9_']*)(?=[ \t]*=|[ \t]+[a-z_(\[\"])",
src))
return got - _HS_KEYWORDS
def build_module(track: Track, seeded: bool) -> str: def build_module(track: Track, seeded: bool) -> str:
helpers = helper_block() helpers = helper_block()
# A track that redefines a boot helper must WIN. Rename the boot definition # A track that redefines a boot helper must WIN. Rename the boot definition
...@@ -197,10 +210,50 @@ def build_module(track: Track, seeded: bool) -> str: ...@@ -197,10 +210,50 @@ def build_module(track: Track, seeded: bool) -> str:
f' audible "d{n} (line {ln})" {name} failed' for name, n, ln, _ in binds) f' audible "d{n} (line {ln})" {name} failed' for name, n, ln, _ in binds)
body = "\n\n".join(src for _, _, _, src in binds) body = "\n\n".join(src for _, _, _, src in binds)
# Everything the module defines at top level must be HIDDEN from the Context
# import, or the two collide. This is the harness diverging from the boot
# environment, which #93 named as the root cause of both its build failures:
# in ghci a `let cutoff = pF "cutoff"` SHADOWS the imported `cutoff`, quietly
# and legally. At module top level there is no shadowing — GHC reports
# "Ambiguous occurrence" and the track is unverifiable, for a difference that
# does not exist live.
#
# Derived from the generated text rather than listed, so it cannot drift when
# BootTidal gains a helper. Same discipline as the rest of the toolbox:
# generate the guard from the source of truth, do not copy it.
hidden = sorted(top_level_names("\n".join([helpers, *locals_, body])))
hide = f" hiding ({', '.join(hidden)})" if hidden else ""
src = _module_text(hide, helpers, locals_, body, seed, checks)
# Which generated lines came from the TRACK, so a build error can be blamed
# correctly. Found by substring rather than arithmetic: the template is one
# f-string and any edit to it would silently shift a hand-counted offset.
ranges = []
for chunk in ([body] + locals_ if body else locals_):
if not chunk.strip():
continue
i = src.find(chunk)
if i < 0:
continue
a = src.count("\n", 0, i) + 1
ranges.append((a, a + chunk.count("\n")))
return src, ranges
def _module_text(hide, helpers, locals_, body, seed, checks) -> str:
return f"""{{-# LANGUAGE OverloadedStrings #-}} return f"""{{-# LANGUAGE OverloadedStrings #-}}
-- ExtendedDefaultRules and NoMonomorphismRestriction are ON BY DEFAULT IN GHCI,
-- which is where these patterns actually run. Without them a chord literal like
-- "<gb3'maj db3'maj bb2'min bb2'maj>" cannot pick an IsString instance
-- (ParseBP's `Pattern a` vs Simple's `ControlPattern`) and the track is reported
-- unverifiable for a reason that never exists on the rig. Matching the boot
-- environment is the fix; annotating each literal would have been treating the
-- symptom one track at a time.
{{-# LANGUAGE ExtendedDefaultRules #-}}
{{-# LANGUAGE NoMonomorphismRestriction #-}}
{{-# OPTIONS_GHC -Wno-missing-signatures -Wno-name-shadowing -Wno-type-defaults #-}} {{-# OPTIONS_GHC -Wno-missing-signatures -Wno-name-shadowing -Wno-type-defaults #-}}
module Main where module Main where
import Sound.Tidal.Context import Sound.Tidal.Context{hide}
import qualified Data.Map.Strict as M import qualified Data.Map.Strict as M
import System.Exit (exitFailure) import System.Exit (exitFailure)
import Data.IORef import Data.IORef
...@@ -253,12 +306,63 @@ main = do ...@@ -253,12 +306,63 @@ main = do
""" """
ERR_LINE_RE = re.compile(r"(?m)^<track>:(\d+):\d+: error:")
def blame(err: str, ranges: list[tuple[int, int]]) -> str:
"""`TRACK` if any GHC error points INTO the track's own lines, else `HARNESS`.
The distinction is the whole point of this verdict and it used to be a
constant. Every build failure was printed as "harness limit, NOT a track
verdict", which was true for the two real harness bugs and catastrophically
wrong for the third: desire.tidal has `# pan 0.42plz /se` committed on line 46
and does not compile at all. The reassuring label hid a broken setlist track
behind a message that told the reader to ignore it.
A parser miss must never masquerade as a data conflict — and the inverse,
which is what happened here, is worse.
"""
hits = [int(m.group(1)) for m in ERR_LINE_RE.finditer(err)]
return "TRACK" if any(a <= h <= b for h in hits for a, b in ranges) else "HARNESS"
def relocate(err: str, src: str, path: Path) -> str:
"""Rewrite `<track>:N` to the line in the REAL .tidal file, where possible.
A generated-module line number is useless to the person who has to fix the
file — and worse, it looks authoritative. Mapped by matching the offending
line's TEXT back into the source, which survives the harness's rewrites
(comment stripping, blank-line removal, `dN` -> `orbN_M = idcp`) as long as
the code itself is intact. Ambiguous or unfound lines are left as-is rather
than guessed at.
"""
gen = src.split("\n")
try:
want = [l.rstrip() for l in path.read_text().split("\n")]
except OSError:
return err
def sub(m: re.Match) -> str:
n = int(m.group(1))
if not (1 <= n <= len(gen)):
return m.group(0)
needle = gen[n - 1].strip()
if len(needle) < 4:
return m.group(0)
hits = [i + 1 for i, l in enumerate(want) if l.strip() == needle]
if len(hits) != 1:
return m.group(0)
return f"{path.name}:{hits[0]}:{m.group(2)}"
return re.sub(r"<track>:(\d+):(\d+)", sub, err)
def check_track(path: Path, seeded: bool, keep: bool) -> tuple[str, str]: def check_track(path: Path, seeded: bool, keep: bool) -> tuple[str, str]:
"""Returns (verdict, detail). verdict in ok / SILENT / BUILD.""" """Returns (verdict, detail). verdict in ok / SILENT / BUILD / BROKEN."""
track = load(str(path)) track = load(str(path))
if not track.orbits(): if not track.orbits():
return "ok", " (no dN declarations)" return "ok", " (no dN declarations)"
src = build_module(track, seeded) src, ranges = build_module(track, seeded)
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
work = Path(td) work = Path(td)
hs = work / "Main.hs" hs = work / "Main.hs"
...@@ -273,7 +377,8 @@ def check_track(path: Path, seeded: bool, keep: bool) -> tuple[str, str]: ...@@ -273,7 +377,8 @@ def check_track(path: Path, seeded: bool, keep: bool) -> tuple[str, str]:
capture_output=True, text=True) capture_output=True, text=True)
if b.returncode != 0: if b.returncode != 0:
err = b.stderr.replace(str(hs), "<track>") err = b.stderr.replace(str(hs), "<track>")
return "BUILD", err.strip() return ("BROKEN" if blame(err, ranges) == "TRACK" else "BUILD"), \
relocate(err.strip(), src, path)
r = subprocess.run([str(work / "run")], capture_output=True, text=True) r = subprocess.run([str(work / "run")], capture_output=True, text=True)
return ("SILENT" if r.returncode else "ok"), r.stdout.rstrip() return ("SILENT" if r.returncode else "ok"), r.stdout.rstrip()
...@@ -304,13 +409,23 @@ def main(argv: list[str] | None = None) -> int: ...@@ -304,13 +409,23 @@ def main(argv: list[str] | None = None) -> int:
mode = "WITH the #55 boot seed" if a.seeded else "with an EMPTY control map" mode = "WITH the #55 boot seed" if a.seeded else "with an EMPTY control map"
print(f"silent-eval: {len(tracks)} track(s), {mode}\n") print(f"silent-eval: {len(tracks)} track(s), {mode}\n")
bad = built = 0 bad = built = broken = 0
for t in tracks: for t in tracks:
if not t.exists(): if not t.exists():
print(f" {t.name}: no such file") print(f" {t.name}: no such file")
bad += 1 bad += 1
continue continue
verdict, detail = check_track(t, a.seeded, a.keep) verdict, detail = check_track(t, a.seeded, a.keep)
if verdict == "BROKEN":
# The error points INTO the track's own lines. This is not a harness
# limitation and must never be worded like one — the file does not
# compile, so every orbit in its block is dead on ctrl+enter.
print(f" {t.stem}: ✖ THE TRACK DOES NOT COMPILE — it will fail live "
f"too. Every dN in its block is dead.")
print("\n".join(" " + l for l in detail.splitlines()[:8]))
broken += 1
bad += 1
continue
if verdict == "BUILD": if verdict == "BUILD":
print(f" {t.stem}: BUILD FAILED — harness limit, NOT a track verdict") print(f" {t.stem}: BUILD FAILED — harness limit, NOT a track verdict")
print("\n".join(" " + l for l in detail.splitlines()[:6])) print("\n".join(" " + l for l in detail.splitlines()[:6]))
...@@ -326,6 +441,9 @@ def main(argv: list[str] | None = None) -> int: ...@@ -326,6 +441,9 @@ def main(argv: list[str] | None = None) -> int:
bad += 1 bad += 1
print() print()
if broken:
print(f"silent-eval: ✖ {broken} track(s) DO NOT COMPILE. This is a gig "
f"blocker, not a tooling note — fix the .tidal file.")
if built: if built:
print(f"silent-eval: {built} track(s) could not be built by the harness " print(f"silent-eval: {built} track(s) could not be built by the harness "
f"— fix the harness, do not read these as passes") f"— fix the harness, do not read these as passes")
......
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