fix(repl): preserve try/else and if/else across newlines in protocol mode#635
Conversation
…mode (#635) After #628 normalized bare \r/\n to semicolons in script/eval, multi-line try { ... }\nelse { ... } and if { ... }\nelse { ... } broke. The newline between '}' and 'else' got a ';' inserted, splitting the composite statement into a standalone try/if (executed silently) and an orphan 'else' keyword. YACC error recovery dropped the rest of the script and the with-wrapper returned its truthy default. Tests that depended on try/else semantics returned 'true' instead of the expected value. Fix: add a '}' guard with lookahead. When the previous non-whitespace byte is '}' and the next non-whitespace token is 'else' (or ';', '}', or EOF), don't insert a synthetic ';'. Other followers (return, local, identifier, ...) ARE new statements and still need an explicit ';' between them. Restores 21 previously-failing tests in control_flow.yaml, try_error.yaml, and error_handling_extended.yaml. Adds 3 new regression tests covering: - try/else with no error in body (passes through to return) - try/else with real error in body (caught by else) - if/else across newlines Also documents the canonical UserTalk brace placement style (closing '}' inlined at end of last statement of a block) in the primer and usertalk-engineer agent — discovered during the investigation. Integration count: 146 failed -> 122 failed (-24, of which 3 are new tests). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lse, docs P1 (bar-raiser): a `//` line comment or `«...»` curly comment on its own line between `}` and `else` broke the lookahead — peek landed on `/` (or `«`) instead of `else`, so `;` got inserted and split try/else. Same bug class as the original #635 P0. Extend the peek loop to skip line comments and curly comments in addition to whitespace. Both are inter-token noise that hides the real next token. Also: - P2: update stale header comment (claimed "no '}' guard is needed"). - P2: renumber the duplicate `3.` list item in usertalk-engineer.md (the new brace rule plus the existing test-isolation rule were both `3.`). - P2: 3 new regression tests — comment between `}` and `else`, canonical inline `};\nelse`, nested try/else across newlines. - P2: comment explaining why `prev_nonws = ';'` after the suppressed `;` is intentional (the newline acts as the boundary signal even when no byte was emitted). Tests: integration 1956 passed / 122 failed / 207 skipped (was 1953/122/ 207 — three new tests added, all pass; no regressions). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
) Round-2 /gate follow-ups: - Add case/else-across-newlines integration test. case shares the grammar caseheader casebody elsetoken bracketedstatementlist with try/else and if/else, so the }-else lookahead applies the same way. Lock it in so a future refactor can't silently regress the third construct. - Reword the comment that incorrectly implied 'continue' itself skipped the \r/\n. It actually stops AT the terminator; the outer loop's whitespace pass consumes it on the next iteration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Local /gate round 2 (post-round-1 fixes)Verdict: PASS — zero P0/P1 findings. Reviewers (HEAD =
|
| Round | P0 | P1 | P2 | Tests |
|---|---|---|---|---|
Round 1 (commit 9fa6f60a4) |
0 | 1 (comment-between-}-and-else) |
4 | 24 fixed |
Round 2 (commit a54efc14b) |
0 | 0 | 1 nit (comment wording) | +3 new tests |
Round 3 (commit ff3f213df) |
0 | 0 | 0 | +1 case/else test |
Pre-existing items NOT introduced by this PR (filed-or-fileable, not blockers)
- No quote-state tracking in
normalize_newlines_to_semicolons— newlines inside"..."literals still get;heuristically inserted (correctness only, not security) src_len * 2 + 1malloc not overflow-checked (theoretical only, 64-bit only, unreachable in practice over loopback JSON)
Test status
- Unit: 489/489 pass
- Integration: 1957 passed / 122 failed / 207 skipped / 2286 total (was 1932/146/207/2282 on develop pre-PR — net 24 newly-passing existing tests + 7 new behavioral tests added by this PR)
Ready to merge.
/auto SummaryWhat went well: The local /gate review caught a real P1 (comment between What could improve: /auto ran inside a sub-agent with Recommendations: Document that /auto should not be entered from inside an 🤖 Generated with Claude Code /auto |
…ol mode (#637) The kernel scanner only recognizes single-byte MacRoman 0xC7/0xC8 for «», not UTF-8 0xC2 0xAB/0xC2 0xBB. Protocol-mode scripts (yaml integration tests, REPL paste, programmatic eval) get UTF-8 input and silently fail to recognize curly comments — leading to compile errors or worse, code that scans into the comment body. The normalization layer (#635) handles the lookahead correctly; the gap is at the scanner. Tracked in #637 (kernel-side scanner extension). Until that lands, // is the only portable comment form.
… burndown) (#636) * test(repl): regression sweep for newline normalization after #635 (#620 burndown) Audit every composite/block grammar production in langparser.y for cases where protocol script/eval's bare-CR/LF -> ';' rewrite (normalize_newlines_to_semicolons) could break the parse or silently change semantics. Add 14 regression-guard tests covering: - All non-else block constructs: on f () {...}, loop {...}, while x {...}, for i = 1 to 3 {...}, with table {...}, bundle {...}, try {...} with no else, case x {...} with multiple clauses but no else. These ALL require ';' insertion after the closing '}' before the next statement --- the default normalizer behavior is correct, this pins it down. - Edge cases for the }-else lookahead: blank lines between '}' and 'else', leading newlines at start of script, '//' comment at very end (no trailing newline), whitespace-only script (no crash). - String literal containing '}\nelse {' --- DOCUMENTS the known pre-existing limitation (normalizer does not track string-literal state). Tracked separately; not in scope for this PR. Test pins down current observed output so any future fix shows up as a deliberate test update. Sweep result: no normalizer bugs found. The current '}' lookahead in repl_variables.c correctly handles all composite forms because only if/case/try have an attached elsetoken production in the grammar. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(repl): address /gate review for #636 — fileloop coverage, with-test hygiene P1: add regression test for fileloopheader-bracketedstatementlist (langparser.y:652). Sweep missed this construct. Same shape as loop / for / while / with / bundle — no else continuation; closing '}' is terminal; '}\\nreturn' must gain ';'. Test wraps the fileloop in 'if false { ... }' so the parser exercises the production without touching the filesystem. P2: rewrite the 'with table { body }' test to use a non-mutating witness. The original wrote 'r = 7' from inside 'with system.temp' while 'local (r)' was in scope, propagating a state-mutation pattern future tests shouldn't copy. New version assigns to a local 'q' inside the with block and reads back through r. P2: soften the string-literal documentation test's claim about scanner LF behavior. Original stated 'parsepopchar swallows bare LF' as definitive; we haven't verified end-to-end. Reworded as best-current-hypothesis with a follow-up note. The behavioral assertion (observed output = ']else {') stays — that's the contract. Tests: integration 30/30 in protocol_eval_compile_errors.yaml (was 29). Total 2300 / 1971 passed / 207 skipped / 122 failed (pre-existing TCP cluster). Unit 489/489. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…620 burndown) (#640) * fix(repl): suppress spurious ';' on trailing newline / trailing ';' (#620 burndown) After #635 tightened newline normalization in script/eval, scripts ending with a trailing newline (which is every YAML | block scalar) got a ';' inserted before that final '\n'. The build_wrapped_script suffix '\n}' then made the with-block end in an empty statement, and evaltree returned the wrapper's truthy default ('true') instead of the user's last-expression value. A symmetric issue affected user-typed trailing ';' on the fallback langrunhandle_value path. Three coordinated changes in normalize_newlines_to_semicolons: 1. Factor the whitespace+comment skip into peek_next_real_byte(). The existing '}'-guard lookahead and the new EOF guard share the same skip rules (ASCII WS, '//' line comments, UTF-8 '«...»' comments). 2. EOF guard: when need_sep is true, peek past inter-token noise. If nothing real follows before '\0', suppress the ';' insertion. The '}' check is now subordinate to this -- it only runs when something real does follow. 3. Trailing strip: after the scan, drop trailing ';' / whitespace / '\r' / '\n' so a user-typed trailing ';' doesn't manifest as an empty top-level statement. Tested with five new regression cases in protocol_eval_compile_errors.yaml covering: callback-style multi-line script, simple expr with trailing newline, trailing semicolon-newline, trailing whitespace/tabs, trailing line comment. Integration: 1936 passed / 157 failed -> 2027 passed / 71 failed (net +91 passing, +5 new tests added). All 49 callback_tcp + callback_database tests pass. Zero "got 'true'" failures remain -- the cluster is fully fixed. Unit tests: 489 / 489 pass (unchanged). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(repl): address /gate P2s for #640 — clarify trailing-strip semantics Three comment-only polish items from the local /gate round 1 review: - P2 #1: docblock for normalize_newlines_to_semicolons now explicitly calls out that the trailing-';' strip changes user-visible semantics for explicitly-terminated scripts ("foo();" now returns foo()'s value, not the wrapper's truthy default 'true'). This is the REPL "always return the value of the last expression" convention. Previously the docblock framed it as symmetric closure with the EOF guard, which understated the behavioral change. - P2 #3: add an inline comment at the EOF-guard / '}'-guard branch noting that EOF is checked first and subsumes the '}' guard's EOF case. Order matters and was previously implicit in the if/else-if ordering. - P2 #4: add an inline comment at the trailing-strip loop explaining the `dst > out` guard — it prevents the strip from walking into the prefix on empty/fully-strippable input, since `dst == out` is its initial value before any byte was emitted. No behavioral change. Integration 2028/70/207/2305; unit 489/489. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ollow-up) (#641) * refactor(repl): table-driven normalize_newlines_to_semicolons (#640 follow-up) After three rounds of patches (#628 -> #635 -> #640), the precedence between the start, ';', '{', '}', and EOF/'else' guards in normalize_newlines_to_semicolons had become inline-conditional logic that was easy to misread and hard to extend. This refactor makes the specification explicit: a 5x5 (prev_kind, next_kind) table maps every transition to emit-or-suppress, and the loop becomes a single lookup. Behavioral contract is unchanged. The cells of kEmitSep map 1:1 to the previous code's branch outcomes: - START / SEMI / LBRACE rows: all suppress (matches start-of-script, ';' guard, '{' guard). - RBRACE row: suppress for EOF / ELSE / SEMI / RBRACE (the '}' guard from #635), emit for OTHER. - OTHER row: emit for ELSE / SEMI / RBRACE / OTHER, suppress for EOF (the trailing-newline guard from #620). Structural changes: - classify_prev() reduces the last non-whitespace byte to one of five kinds. classify_next() does the same for the peeked next byte, including the 'else'-keyword check with a trailing-delimiter test. - kEmitSep[5][5] is the new control surface. The cells are the spec; the loop just looks up and acts. - strip_trailing_terminators() factors out the post-pass that walks back over trailing ';' and whitespace (#620 trailing-';' case). - peek_next_real_byte() is unchanged from #635/#640. Tests added (transition-matrix cells previously implicit): - ';' x '}' - explicit ';' before newline-close-brace - '{' x '}' - empty block written across newlines - other x ';' - bare newline followed by explicit ';' - '}' x ';' - closing brace followed by newline then explicit ';' All four pass against the pre-refactor code as well, confirming the refactor preserves the existing contract for the previously-untested cells. Other cells of the matrix are already exercised by the 35 tests added across #624 / #635 / #620. External API is unchanged: normalize_newlines_to_semicolons(const char *) still returns a freshly-malloc'd C string or NULL on allocation failure. No new dependencies, no quote-state tracking (#638), no overflow-check addition (#639) - those are tracked separately. Test results: - Unit: 489/489 pass (unchanged) - Integration: 35 protocol_eval_compile_errors tests + 4 new pass; overall failure count unchanged from develop baseline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(repl): address /gate review for #641 (P1 #1, P2 #3, P2 #5) P1 #1: drop the orphaned 58-line prose docstring at lines 338-395. The refactor moved the function down ~190 lines (to make room for enums, the transition table, and helpers), leaving its docstring stranded above an unrelated typedef. Replace with a tight 17-line summary at the function itself. The "table IS the spec" framing means we no longer need a duplicate prose enumeration of the rules — the kEmitSep comment carries them. P2 #3: enumerate the 5 moot-by-construction cells explicitly near kEmitSep (START x ELSE, START x RBRACE, SEMI x ELSE, LBRACE x ELSE, LBRACE x EOF — inputs that would exercise them are parse errors before normalization matters). Previously the moot set was implicit and the agent's reported count (6) was off by one. P2 #5: rename strip_trailing_terminators -> strip_trailing_whitespace_and_semicolons. The "terminators" name read narrower than the actual stripped set (' ', '\t', '\r', '\n', ';'). No behavioral change. Tests: unit 489/489, integration 2034/68/207/2309 (target file 39/39 — even one flake-recovery vs the prior r1 run). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#620 burndown) (#643) * fix(repl): PREV_BINOP normalizer class for binary-operator continuations (#620 burndown) 5th hole in normalize_newlines_to_semicolons after #628/#635/#640. When a UserTalk script ends a line with a binary-operator token ('and', 'or', '==', '>=', '<=', '!=', '>', '<', '+', '-', '*', '/', ',', '('), the next line is a continuation of the same expression. Previously the (PREV_OTHER, NEXT_OTHER) cell emitted ';' between them, producing source like 'x and; y' that the parser rejected. The 5 failing sys.* integration tests with multi-line expressions (typeof(sys.os()) == stringType) and ...) were the visible symptom. Add PREV_BINOP class to the transition table. The classifier (prev_is_binop) walks back through the output buffer to detect multi-byte operator tokens ('and' / 'or' word-bounded, '==' / '>=' / '<=' / '!=' via byte-before check) so single '=' (assignment) is correctly distinguished from '=='. New BINOP row suppresses ';' for every next-kind. Adds 8 integration tests in protocol_eval_compile_errors.yaml covering the new transition cell and the disambiguation paths (single '=' assignment, identifier ending in 'and' / 'or'). Integration: -5 failures (sys cluster) + 8 new tests, all green. Unit: 489/489. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(repl): add 4 BINOP regression tests for /gate P1 findings on #643 Bar-raiser flagged P1 test gaps: - Docblock claims BINOP coverage for >=, <=, !=, >, <, *, /, - but tests only exercised and, or, ==, +, ',', '('. - 'and' had a band/kand word-boundary regression but 'or' did not. Add 4 new tests: - '>=' pins the '>' arm of the byte-before-'=' disambiguation - '!=' pins the '!' arm - '*' pins the BINOP single-char arithmetic byte-class - 'door' as variable pins the 'or' word-boundary check (symmetric to 'band') Integration: 2321 total / 2050 passed / 64 failed / 207 skipped — +4 total, +5 passed (the 4 new tests + a flake recovery), -1 failed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
}\nelseregression introduced by fix(repl): normalize bare CR/LF to semicolons in protocol script/eval (#624) #628's newline normalization: try/else and if/else across newlines were being split by an inserted;, causing 24 integration tests to return'true'(the with-wrapper's truthy default) instead of expected values.}to the guard with lookahead: when the previous non-whitespace byte is}and the next non-whitespace token iselse/;/}/EOF, don't insert a synthetic;. Other followers (return, local, identifier, …) still get a;because they're new statements.usertalk-engineeragent (closing}and};inlined at end of last statement of a block).Why
#628 fixed
local (x = 5)\rreturn x→5by normalizing bare\r/\nto;. That worked for top-level multi-statement scripts but broke composite statements liketry {...}\nelse {...}where the}shouldn't be followed by;(it's not a statement terminator — theelseclause attaches to the try). The fix is selective: only suppress;when the next real token attaches to or closes the}.Impact
protocol_eval_compile_errors.yaml:Test plan
./tools/run_headless_tests.sh— required, passingcd tests && make test-integration— required, 122 failed → was 146 failed pre-fix; the 24 newly-passing are all in the try/else/control-flow family\rseparator, CRLF, semicolon-already-present, if-block all still passRefs: #628, #624.