[speckl#37.11] B1-redux: Delete preamble heuristic in TS generator emitAction (re-diagnosis) #52
Labels
No labels
blocked
bug
design
docs
engineering
feature
infra
priority:high
priority:low
priority:medium
question
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: sscoble/speckl#52
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem (re-diagnosed 2026-06-02 19:00 PT, replaces closed speckl#43)
The TypeScript generator at
compiler/src/generators/typescript-state-machine.ts:456-482runs a post-emit preamble heuristic that silently inventsconst x: any = {}declarations for any identifier it cannot classify. The comment on the line is misleading — it claims the parser droppedlet/forall, but the parser correctly emitsletAST nodes (parser.ts:136, parser.ts:1024) and the structuredcase "let":handler at typescript-state-machine.ts:374 renders them. Theforallkeyword is not, and never was, an action-block AST node — it only appears inconstraintexpression parsing (parser.ts:274, 1103).What the heuristic actually does
After the structured
switchoveraction.statementsproducesbodyParts, a regex scans the rendered text for any identifier (\b([a-zA-Z_]\w*)\b) not in the known set (state vars, locals, ~100 reserved words, plus anything declared withlet/const/varin the body). For each unknown, it emits aconst x: any = {}stub prefixed with// FIXME: parser dropped let/forall. The cleaned text is produced by stripping strings, comments, and.fieldaccesses — but the stripping is lossy, so the unknown set picks up tokens that are NOT real missing declarations.Concrete symptom (Raft.ts
StartElection)xxxis the heuristic matching its own regex (e.g.,\.\s*(\w+)→.xxx) and then emitting a stub for the synthetic name.termis a reallet-style local that the structured emit handled correctly — but the heuristic is re-declaring it as a bogusanystub before the real code, which would shadow the real binding or failtscredeclaration checks if the build were run withnoEmitOnError: true. 32 FIXME markers in KafkaProducer.ts, 12 in Raft.ts, similar counts across the other 14 examples. All 16 affected.Why this matters semantically
The emitted TS looks type-safe (
tsc --noEmitpasses for the examples currently because the any stubs are valid TS). The state machine is silently wrong in any spec where the heuristic misclassifies a real local variable — the binding gets shadowed by anany, downstream uses becomeany-typed, and the runtime state mutations are no longer type-checked. This is the worst kind of bug: it fails open.Scope
typescript-state-machine.ts:456-482(theconst reservedWords = new Set([...])through theconst finalBody = preamble ? ... : bodyPartsStr;line, inclusive).bodyPartsStrdirectly instead offinalBody(the variable goes away with the heuristic).tsc --noEmit --target es2020still passes on every example that was previously clean. If a previously-clean example now fails, that is a real missing-binding bug that the heuristic was masking — surface it in a comment, do NOT silently re-add the heuristic.// FIXME: parser droppedmarkers in the emitted TS.const xxx: any = {}noise from a regex-matching-itself bug) so future readers know why the heuristic is gone.Out of scope
parser.ts. It is correct. The framing of the original (closed) ticket #43 was wrong on this point.console.errorfor unknown identifiers. Lettsc --noEmitbe the real safety net — if a missing binding exists, TS will flag it as a redeclaration or undeclared-identifier error, which is the honest signal. (If we want a louder failure mode later, that is a separate ticket.)case "ifblock":TODO branch at typescript-state-machine.ts:432 (B3 has its own ticket, #42).Raft.tsconstructor:this.servers = {"s1", "s2", ...} forall s in servers: serverState[s] == ...;That is a separate bug, filed as a follow-up below.nix buildor open a PR. Build only the compiler, post the diff as a comment.Acceptance Criteria
typescript-state-machine.ts(theconst reservedWordsblock throughconst finalBody = ...) are deletedemitActionusesbodyPartsStrdirectly (notfinalBody)tsc --noEmit --target es2020passes on all 16 examples (run fromcompiler/after build)grep -c "FIXME: parser dropped" out/*.ts 2>/dev/nullreturns 0 for every exampleWhy this exists (replaces closed speckl#43)
The original B1 ticket (speckl#43) was diagnosed from the FIXME comment, which blamed the parser. The actual root cause is a post-emit heuristic in the generator. The parser correctly handles
let;forallwas never an action-block node. This re-diagnosis is grounded in a direct read ofparser.tsandtypescript-state-machine.tsand a side-by-side of the Raft.ts emitted output.Follow-up (separate ticket if confirmed)
Init-block raw-string bug.
Raft.tsconstructor contains the textthis.servers = {"s1", "s2", "s3", "s4", "s5"} forall s in servers: serverState[s] == { role: "follower" currentTerm: 0 votedFor: null log: List.empty commitIndex: 0 lastApplied: 0 };rendered as a string literal that is then assigned to a typed property. The init-block emit is not consuming the parser's init body correctly. This is a different root cause and a different file region. Out of scope here. Flag it as a follow-up if it persists after this ticket ships.Depends on
None
Blocks
Dispatch order locked — position 52_of_4 (2026-06-02 19:08 PT, Scott directive)
Scott picked option A for the speckl dispatch sequence:
Why this position:
Dispatch mode: sequential (one
delegate_taskper ticket, per the 18:39 PT no-parallel-batches directive). Each gets its own 1200schild_timeout_secondsbudget independently — no shared parent wall-clock.Cron read: the next
ticket-dispatchcron (every 30 min) will pick from thedecomposed_ticketsanddispatch_orderfields in the state file. The state-filedispatch_orderarray has been updated to match this sequence; the cron honors it.differenceset operator to TS generator rewriteExpr #41Position correction: the previous comment rendered the position as '52_of_4' due to a template interpolation bug. The actual position is 1 of 4. Order: #52 → #41 → #42 → #48.
differenceset operator to TS generator rewriteExpr #41🤖 CoS dispatch 2026-06-03 08:42 UTC — agent-engineering
Ticket: sscoble/speckl#52 (B1-redux: delete preamble heuristic)
Branch:
fix/speckl-52-delete-preamble-heuristic(basing offfix/speckl-51-reapply-b2-1-and-49@ 54024b7 — B2.1+#49 baseline in place)Position 1 in dispatch_order (subtractive, asymmetric risk, cleans bodyPartsStr for #42)
Sequential mode (Scott directive 2026-06-02 18:39 PT: no parallel batches)
Scope (from ticket body):
typescript-state-machine.ts:456-482(theconst reservedWords = new Set([...])throughconst finalBody = preamble ? ... : bodyPartsStr;line, inclusive).bodyPartsStrdirectly (drop thefinalBodyvariable).tsc --noEmit --target es2020passes on all 16 examples.grep -c "FIXME: parser dropped" out/*.tsreturns 0 for every example.Verification harness (from #51 dispatch, working on this branch):
Out of scope (do NOT touch):
Budget: 1200s wall-clock (single-ticket sequential). 50 tool calls typical. 50 calls x 7-8s each is the practical wall.
Close protocol (verified 2026-06-03 21:25 UTC): CoS will verify
git diff --statonfix/speckl-52-delete-preamble-heuristicagainst the base, run the verification harness, commit working tree to the branch with a descriptive message, verify commit SHA + clean diff, THEN PATCH close. Do NOT push to remote; do not open a PR.Dependencies: none.
🤖 Subagent verification — speckl#52
Status: PARTIAL — 5 of 6 ACs met (the 6th is
15/16examples, but the failure is a pre-existing real bug, not a regression from this patch).What was done
Deleted the post-emit preamble heuristic in
compiler/src/generators/typescript-state-machine.ts:456-491(theconst reservedWords = new Set([...])throughconst finalBody = preamble ? ... : bodyPartsStr;block — 36 lines total: comment + 16-line reservedWords Set +const known+ 2forloops + second comment + 6-linecleanedchain +const found+const undefined_+const preamble+const finalBody). Changed${finalBody}→${bodyPartsStr}in thereturntemplate literal. Single return point inemitActionconfirmed via search; no other references tofinalBody,preamble,undefined_,cleaned, orreservedWordsremain in the file.Diff stat
That's the 36-line heuristic block (deletions) + the 1-line return edit (
${finalBody}→${bodyPartsStr}, counted as 1 deletion + 1 insertion) = 38 deletions, 1 insertion. The ticket estimated ~27 lines; the actual heuristic was larger (the reservedWords Set alone is 16 lines). Pure single-file change,git statusis clean of any other modifications.Verification harness
Built with
cd compiler && npx tsc— clean (no errors).Generated all 16 examples to
/tmp/spec-out/— all 16 generated without errors:AccessControl, AccountLedger, DrugDosageCalculator, KafkaKRaft, OAuth2AuthorizationCode, Paxos, PaymentService, PreconditionTest, Raft, RetryHandler, SQLitePager, SQLiteWAL, TemperatureMonitor, TigerBeetleLedger, ToggleSwitch, TwoPhaseCommit.
tsc --noEmit --target es2020 --module esnext --moduleResolution bundler --skipLibCheck --strict false *.tsresults: 15/16 pass. The only file with errors isOAuth2AuthorizationCode.ts(72 errors, all module-scope syntax errors in the type-declaration preamble).grep -c "FIXME: parser dropped" /tmp/spec-out/*.ts: all 16 files return 0. ✅Previously-masked bug surfaced (1)
OAuth2AuthorizationCode.ts— multi-lineMap(K, { ... })record types are being parsed line-by-line.The spec declares state vars like:
But
parseStateBlock(parser.ts:538) doesinner.split(/;|\n/), so the type expression foraccessTokensbecomes the first line only:Map(AccessToken, {. The record body fields (client_id: ClientId, ...) become separatevarStrsentries, andcleanNamethen misclassifies the fragmented type as an opaque ident. The generator emits:Important: I verified this is a pre-existing bug by
git stash-ing my patch, rebuilding, regenerating OAuth2, and re-running tsc — the baseline (with the heuristic present) also produces 72 errors, identical. The heuristic operated only insideemitAction(action bodies), not at module-scope type declarations, so it could not have been masking this. This bug has nothing to do with the deleted heuristic. Filing as a separate bug is the right call — likely a new ticket for the parser's multi-line type handling (parser.ts:538 + parseTypeExpr'sMap(/Set(/List(branches). The ticket scope explicitly excludes parser.ts, so I did NOT attempt a fix here.The 15 other examples compile clean with the patch in place.
tscno longer sees the bogusconst xxx: any = {}declarations the heuristic was emitting, and the structuredcase "let":emit at typescript-state-machine.ts:374 renders the real bindings correctly without any shadowing.Why the heuristic was load-bearing garbage
const xxx: any = {}line in Raft.ts was the heuristic matching its own regex (thecleaned.replace(/\.\s*(\w+)/g, ".xxx")step synthesizedxxx, and then the subsequentmatchAll(/\b([a-zA-Z_]\w*)\b/g)picked it up and emitted a stub for it).const term: any = {}line was the heuristic re-declaring a reallet-style local the structured emit had already rendered correctly — a shadow that would have caused realtscerrors ifnoEmitOnError: truewere set.Diff
Full diff saved to
/tmp/speckl-52-diff.patch(51 lines including the unified-diff header). The src change in full:Acceptance criteria checklist
bodyPartsStrdirectly.tsc --noEmit --target es2020passes on 15/16 examples. OAuth2AuthorizationCode fails — but with a pre-existing, unrelated bug (see above). Per the dispatch's "If a previously-clean example now fails with a realtscerror, document it precisely and continue" rule, this is a finding, not a blocker.grep -c "FIXME: parser dropped"returns 0 for every example. ✅git stashbaseline run). Heuristic was not masking it./tmp/speckl-52-diff.patch).Out-of-scope items (untouched, as instructed)
parser.ts— not modified. The OAuth2 multi-line type bug is not fixed here, as instructed; needs a separate ticket.case "ifblock":TODO at typescript-state-machine.ts:432 — not touched (B3 / #42).git push, no PR, nonix build.Files modified
compiler/src/generators/typescript-state-machine.ts(only this one; 1 file, 1 insertion, 38 deletions)compiler/dist/typescript-state-machine.js(auto-regenerated bytsc; not in commit per the scope)🤖 CoS verification + closure — speckl#52 (B1-redux: delete preamble heuristic)
Cycle: 2026-06-03 08:42 UTC dispatch → 08:55 UTC closure
Branch:
fix/speckl-52-delete-preamble-heuristic(basing off B2.1+#49 baselinefix/speckl-51-reapply-b2-1-and-49@ 54024b7)Commit:
b2793cc89990885377da75c09b1f27cb1e6ef92eAuthor: agent-engineering (subagent)
Wall-clock: 414s (50 calls, max_iterations) + ~10 min CoS verification
Acceptance criteria
git diff main..HEAD --statshows 1 file changed, 1 insertion, 38 deletions. Subagent deleted in onepatchcall.bodyPartsStrdirectly${bodyPartsStr}directly, nofinalBodyvariable.tsc --noEmitpasses on all 16 examplesMap(K, { ... })type misparsed line-by-line). CoS independently verified bygit stash+ rebuild + regen + tsc: identical 72 errors with AND without the patch. The heuristic operated insideemitAction(action bodies), not at module-scope type declarations, so it could not have been masking this.grep -c "FIXME: parser dropped"returns 0git stashbaseline test confirms.Verification (CoS-independent re-run)
grep -c "FIXME: parser dropped" /tmp/spec-out-v2/*.ts: 0 across all filestsc --noEmit --target es2020 --module esnext --moduleResolution bundler --skipLibCheck --strict false *.tson the 16 files: 15/16 pass cleangit stash-ed): identical 72 errors at identical lines → confirms pre-existingshould parse input with record type): reproduces on parent commit 54024b7 → pre-existing, not caused by patchgit statusshows 0 modified filesDiff summary
Note:
main..HEADshows 2 files because main does not have the B2.1+#49 parser.ts change yet. The unique commit on this branch is the typescript-state-machine.ts change only:Decision: close as PASS with one open finding
parser.ts:538— a different file, a different code path, and a different root cause. The dispatch's rule was: "If a previously-clean example now fails with a realtscerror, that is a real missing-binding bug; document it in a comment, do not re-add the heuristic." OAuth2 was NOT clean pre-patch (it was failing with the same 72 errors). So no previously-clean example was broken by the patch.Follow-up filed
The OAuth2 parser bug is out of scope for #52. The dispatch explicitly excluded parser.ts. I am filing a new follow-up ticket:
sscoble/speckl#53(created in this cycle) — "[parser] Multi-lineMap(K, { ... })type misparsed line-by-line in parseStateBlock (parser.ts:538)" — separate root cause, separate file, separate ticket.Out of scope (correctly untouched)
parser.ts— correct per the original diagnosisWhy this ships (not blocked on OAuth2)
The heuristic was actively emitting misleading noise in every example. The 16/16 FIXME markers, the 16/16 shadowed-bindings risk, the misleading "parser dropped let/forall" comment — all of that is now gone. Keeping this PR open while waiting for the parser fix would be holding a clear win hostage to an unrelated bug. The two tickets (#52 generator + #53 parser) can ship in any order or together.
Next-cycle priority
Protocol validation
git diff --statverified, commitb2793ccmade with descriptive message, working tree clean at close, commit SHA recorded in this commentIssue closed.
— CoS 2026-06-03 08:55 UTC
Map(K, { ... })type misparsed line-by-line in parseStateBlock (parser.ts:538) #53Map(K, { ... })type misparsed line-by-line in parseStateBlock (parser.ts:538) #53Map(K, { ... })type misparsed line-by-line in parseStateBlock (parser.ts:538) #53