← board

A later wall at key_analysis.py:82 blocks convertrawtext.py and SongFormatter.py

What happens

pascal26:82: error: unexpected token
  near: label  split     >>>
Expected: ), but got:  (Kind: 2, Line: 82)

The line is in key_analysis.py, not in the file being compiled:

def _key_label_to_parts(label: str) -> tuple[str, str]:
    if label.endswith("m") and " " not in label:
        return (label[:-1], "minor")
    if " " in label:
        tonic, mode = label.split(" ", 1)      # <- line 82
        return tonic, mode
    return (label, "major")

Note the reported line number belongs to an imported module, so it is not on the same scale as the line numbers of the file on the command line. Reading it as "the failure moved earlier in convertrawtext.py" is wrong, and is what it looks like at first glance.

Attribution — pre-existing, measured

Compilers built from the same base, one with and one without the keyword-call fix that unblocked the earlier wall:

compiler convertrawtext.py (tuple pads neutralised in a copy of the app)
f8f879988222without the fix 82: error: unexpected token
bcb428ba25ac — with the fix 82: error: unexpected token

The copy has every padx=(a, b) / pady=(a, b) rewritten to its first element, which removes the earlier refusal so the baseline can reach the same depth. Both compilers then fail identically. Without that step the baseline stops at the grid call and never reaches this, which is the whole reason the wall looks new.

What does NOT reproduce it

All of these compile clean, on both compilers:

So it needs more of convertrawtext.py's context than any of these carry, and it is not minimised. That is the first job on this ticket; the shape above is where to start, not a diagnosis.

A second, probably separate observation from the same file

convertrawtext.py:1387 rebinds the imported module's own name as a variable (key_analysis = analyze_song_key(...) alongside from key_analysis import analyze_key). Reduced to:

from key_analysis import analyze_key
def f(song):
    key_analysis = analyze_key(song)
    return key_analysis

no overload of analyze_key matches these arguments, identically on both compilers, so also pre-existing. It is probably NOT about the name reuse: the same shape against a throwaway module (from mymod import analyze with a local named mymod) compiles and matches CPython. Something about analyze_key's own signature is what the overload probe rejects. Recorded here rather than filed separately until one of the two is minimised, in case they turn out to be one defect.

Impact

settings.py and key_analysis.py compile. convertrawtext.py and SongFormatter.py do not, so this is the current wall for [[feature-demo-songformatter-pxx-target]]. render_backend.py is blocked by a different animal — [[bug-nilpy-render-backend-py-compile-does-not-terminate]] — which is no answer rather than a wrong one.


MINIMISED, 2026-08-30 (frankwasm)

Correction first: the analyze_key lead in this ticket was wrong, and it was mine

The section above records key_analysis = analyze_key(song) failing with no overload of analyze_key matches these arguments on both compilers, offered as "a stronger lead". It is not a defect. analyze_key's parameters after chords are keyword-only, and chord_to_notes is required:

def analyze_key(
    chords: list[str],
    *,
    chord_to_notes: Callable[[str], list[str]],
    ...

CPython rejects the same call: analyze_key() missing 1 required keyword-only argument: 'chord_to_notes'. So pxx refusing it is correct behaviour, and I wrote an invalid call and recorded it as evidence. Corrected in place rather than deleted, because the wrong lead is the reason the next section took the route it did. (Keyword-only parameters themselves work: def f(a, *, b), *, b=5 and mixed forms all match CPython.)

The real defect, in two lines plus a unit

# mod.py
def zzzz(label: str):
    tonic, mode = label.split(" ", 1)
    return tonic, mode
# main.npy
import 'usesutil.pas' as u      # any unit whose `uses` clause names sysutils
from mod import zzzz
print(zzzz("C minor"))

pascal26:2: error: unexpected token, near: label split >>>.

CPython prints ('C', 'minor').

Mechanism — confirmed by prediction, not by story

sysutils declares a string helper taking one argument:

    function Split(const Separators: array of Char): TStringArray;

Python's str.split(sep, maxsplit) takes two. When the receiver is statically a str, the two-argument Python call is matched against the Pascal helper, and the parse dies on the second argument.

The prediction that confirms it: rsplit has the identical Python shape and no sysutils helper, so it must work where split does not.

call sysutils declares it? result
label.split(" ", 1) yes, 1-arg ERR
label.rsplit(" ", 1) no ok
label.split(" ") yes, 1-arg ok
label.partition(" ") no ok

The four conditions, each necessary

condition drop it and
a unit that pulls sysutils is imported first ok
receiver is statically str (: str on a param or local) ok
.split called with two arguments ok
the module is pulled as a unit, not compiled directly ok

pathlib and json trigger it because their uses clauses name sysutils; configparser, re, io, collections, typing, dataclasses do not and are clean. It is not a capacity effect — pathlib is 250 lines against configparser's 401.

This fully explains the original report: key_analysis.py:82 is tonic, mode = label.split(" ", 1) inside def _key_label_to_parts(label: str) -> tuple[str, str], key_analysis is pulled as a unit, and convertrawtext.py imports pathlib at line 49.

One observable I could NOT explain, recorded rather than guessed

The function's own name changes the outcome, deterministically (three runs each), with everything else byte-identical:

name result name result
f g q z (1 char) ok aa ab ff gg zz qq abc xyz ERR
word item value chunk part parts split ok aaaa zzzz xxxx ERR

It is not length (word ok, aaaa ERR) and not presence in the RTL (ff occurs in 6 RTL files and fails; tonic occurs in none). The names that pass are real words and single letters; the ones that fail are invented sequences. I do not know why, and I am not going to invent a rule for it — it is recorded so the next holder does not have to rediscover that the name is a variable at all. The mechanism above reproduces regardless, on any failing name.

Warning for anyone sampling names here: label, tonic and mode look like passes and are not — they compile and then return wrong values, because the function name collides with one of its own locals. That is a separate defect, filed as [[bug-n-a-local-named-after-its-own-def-aliases-the-function-result]].


ROOT-CAUSED, 2026-08-30 (frankwasm) — and it is bigger than split

The site

compiler/pasparser_lval.inc:275-308, the TYPE-HELPER dispatch:

  if (idx >= 0) and (CurTok.Kind = tkDot) and
     (Syms[idx].RecName < REC_UCLASS_BASE) and (not Syms[idx].IsArray) and
     not (Syms[idx].TypeKind in [tyRecord, tyClass, tyVariant, tyPointer]) and
     (TokPos < TokCount) and (Tokens[TokPos].Kind = tkIdent) then
  begin
    mci := FindHelperForType(Syms[idx].TypeKind, Syms[idx].RecName);
    if (mci >= 0) and ((FindUMeth(mci, GetTokenStr(TokPos)) >= 0) or
                       (FindUProp(mci, GetTokenStr(TokPos)) >= 0)) then
    begin
      ...
      Result := node;
      Exit;
    end;
  end;

There is no isNilPy guard. sysutils.pas:100 declares TStringHelper = type helper for AnsiString, so once any imported unit pulls sysutils, a NilPy receiver statically typed str has a helper in scope. This block Exits with the Pascal helper before the NilPy str-method loop in pasparser_expr.inc:8559 (PyIsStrBaseTkPyParseStrMethod) is ever reached. The Pascal helper wins over the Python method, silently.

The collision set is four names, not one

Pascal member lookup is case-insensitive, so TStringHelper's surface collides with Python's str on:

Python TStringHelper how it fails
split(sep, maxsplit) Split(array of Char) arityunexpected token, the reported wall
startswith(tuple) StartsWith(AnsiString) argument typeno overload of startswith matches these arguments
startswith(str) StartsWith(AnsiString) compiles — silently the Pascal method
endswith(str) EndsWith(AnsiString) compiles — silently the Pascal method
replace(old, new) Replace(Old, New) compiles — silently the Pascal method

Measured, same file, only the def's name changed (see below):

label.startswith(("C", "D"))   helper reached  -> no overload of startswith matches these arguments
label.startswith(("C", "D"))   helper missed   -> ok

The last three rows are the reason this matters beyond one wall: they are the silent-wrong-behavior class, so by CLAUDE.md's compat escape rule they are a bug-, not a compat item. str.replace takes an optional third count; TStringHelper.Replace's third parameter is TReplaceFlags. Any NilPy program that reaches the helper and passes three arguments gets a different function under the same spelling.

What the def-name effect actually is

The unexplained name table above is now localised, though not explained: the name decides whether FindHelperForType / FindUMeth at line 288 finds the helper. Failing names are the ones where it succeeds. Re-measured tonight on 9ea7174aa:

ok :  a  b  f  a1  a2  word  split
ERR:  aa ab zz ff qq zzz aaa abc foo bar baz parse tonic x1 zzzz wzzz zzzw zzzz2

return label.split(" ", 1), x = label.split(" ", 1) and tonic, mode = label.split(" ", 1) all behave identically, so the tuple-unpack in the original report was not a condition — only the name is. I do not know why the name moves the lookup, and I am not guessing; but the fix below makes it moot for every str method, which is why chasing it further is not on the critical path.

Proposed fix (NOT applied — needs a Track A grant)

One condition at pasparser_lval.inc:283: in NilPy mode, when the receiver is a str base and the member names a known Python str method (PyStrMethodInfo), skip the type-helper dispatch and let PyParseStrMethod have it. Python's str surface owns its own spellings; the Pascal helper is for Pascal receivers.

File ownership: pasparser_lval.inc is Pascal-frontend ground shared with Track A — same situation as the pasparser_call.inc change in [[bug-n-a-methods-keyword-call-drops-a-tuple-argument-when-an-earlier-default-is-skipped]], which needed a grant and A's gate (gate.sh quick), not just the fixedpoint. Not applied under Track N.


FIXED, 2026-08-30 (frankwasm)

Correction: the site is in pyparser.inc, NOT pasparser_lval.inc

The section above names pasparser_lval.inc:275 as the site and I requested — and received — a Track A grant for that file ([[grant-pasparser-lval-to-the-wasm-lane-for-the-nilpy-str-helper-collision]]). The grant was not needed and the file is returned untouched. Patching it changed nothing, and a PXXDBG probe on the guard showed why: it is never asked during a NilPy parse. The TYPE-HELPER dispatch is duplicated — the same block, comment and all, also lives at compiler/pyparser.inc:39038, and that is the copy NilPy runs.

That duplication is deliberate (devdocs/dev/the-substrate-is-ast-and-ir-not- the-parser.md: share the AST and IR, duplicate the parser per language), so the fix belongs in the NilPy copy, which is Track N's own ground. What I got wrong was reading one copy and assuming it was the only one — the sibling rule in CLAUDE.md, applied one step too late. Grepping FindHelperForType is what found it, and would have found it before the grant request.

The fix

compiler/pyparser.inc — a new PyStrMethodOwnsMember (forwarded in pyforwards.inc), and one condition on the dispatch:

     (TokPos < TokCount) and (Tokens[TokPos].Kind = tkIdent) and
     not PyStrMethodOwnsMember(Syms[idx].TypeKind, GetTokenStr(TokPos)) then

Gated on the method table (PyStrMethodInfo — the same table the lowering consults to pick the pylib entry), not on the receiver alone, so it answers True only where the two surfaces actually collide.

Verified — the positive AND the negative arm

test/test_nilpy_str_method_vs_pascal_string_helper.npy (+ .expected, + test/strhelperprobe.pas, the minimal unit whose uses sysutils IS the precondition):

row before after / CPython
split(" ", 1) unexpected token ['C', 'minor']
startswith(("C","D")) no overload ... matches True
endswith(("r","x")) no overload ... matches True
replace("a","b",2) compiled, flags not count bbaa
Trim() helper helper — still
IsEmpty() helper helper — still

The last two rows are the point, not padding. PyStrMethodInfo gating makes the guard's selectivity invisible to every oracle: a guard that is too broad silently stops the type-helper dispatch from ever being reachable and everything still compiles, as Python. Trim and IsEmpty are spellings Python's str does not have, and PyParseStrMethod REJECTS an unknown method outright — so if the guard fired on them they would be a compile error rather than a wrong value. The guard is observed rejecting, not only accepting.

The def-name effect is gone with it: the test carries the original zzzz body under a name from the failing set.

A regression of my OWN, found and fixed in the same pass

Adding the class-return rows to test/test_nilpy_str_method_return_type_on_a_variable.npy turned up that def a(label: str): return label.rsplit(" ", 1) printed a bare handle (124150846851152) instead of ['C', 'minor']. Mine, in 7ddcb9650, and already on master. That arm pairs cur := smRetTk with PyInferLastCi := -1, which is right for a scalar and destroys a class result. Confirmed against an honest control — HEAD without the uncommitted patch, same base, 74afbc8f0a22 — not against pinned, and reproduced with rsplit, which has no Pascal helper and so cannot be the collision above.

Fixed by a third gate on that arm (smRetTk <> tyClass, <> tyRecord), with the class rows now asserted in that test so the arm cannot silently reclaim them. Only the DEF-RETURN path went through it — print(s.rsplit(...)) and x = s.rsplit(...) were correct throughout, which is why nothing caught it.

Filed, not hidden

partition belongs to that class-return family and prints raw memory when returned from a def — on pinned too, so pre-existing and neither of the above. It is deliberately absent from the test, with the reason written at the row, and filed as [[bug-n-a-tuple-returning-str-method-prints-raw-memory-when-returned-from-a-def]]. The list arm works and the tuple arm does not, which is a one-concept-two- mechanisms smell rather than a partition bug.

Gate

make compiler/pascal26 — self-host fixedpoint e632e0d82ef9. Plus gate.sh quick. Track N ground only (pyparser.inc, pyforwards.inc, test/**); pasparser_lval.inc untouched.

Log