fix(genpc,parser,pcinterp): pcode wider regression sweep (Tier 1 #3)

Six more silent miscompiles in the pcode path, all uncovered by a
new pcode regression sweep that exercises the full PRG surface a
dynamic FrbCompile body could legitimately use.

  * **xBase-keyword shadowing of variable names.** parseIdentStmt
    and parseExprStmt's fallback switches consumed an entire line
    when the leading IDENT matched LABEL / REPORT / ACCEPT / INPUT
    / NOTE / etc. Those words are also extremely common LOCAL /
    PRIVATE names — `LOCAL label ; label := "x"` had the
    assignment swallowed because the switch didn't peek at the
    next token. Both switches now look at peek(1): an assignment
    operator, [], (, -, ++, --, or `.` means it's a variable /
    call / member access, not the xBase command, and we fall
    through to expression parsing. Real silent bug — bit
    test_frb_pcode_sweep's `LOCAL label` declaration.

  * **`arr[i]` indexing not implemented in genpc.** ast.IndexExpr
    fell through to the default PushNil path, so any indexed read
    in a pcode-mode body returned NIL. New case emits the array,
    the index, and PcOpArrayPush (the get-op; PcOpArrayPop is the
    set-op — naming follows Harbour convention). Hashes go
    through the same opcode, which already special-cases
    IsHash() in ops_collection.go.

  * **Hash literals not implemented in genpc + dispatch missing
    in pcinterp.** `{ "k" => v, ... }` fell to PushNil. Added
    HashLitExpr emit (Push key, Push value pairs, then PcOpHashGen
    with count). Also wired up the PcOpHashGen dispatch in
    execPcodeBody — it had been declared in pcode.go since the
    initial design but the case statement was never added, so
    even hand-written modules couldn't use hashes.

  * **`x++` / `x--` postfix were silent no-ops.** PostfixExpr fell
    to PushNil and the surrounding ExprStmt then popped the NIL.
    DO WHILE loops with `n--` couldn't terminate; FOR loops with
    `i++` in the body were broken too. New case: PushLocal +
    LocalAddInt(±1).

  * **BlockExpr (`{|p| body }`) wasn't compiled.** Eval(b, n)
    inside a pcode body returned NIL. Added: build the body in a
    sub-codebuffer with the block's params occupying its locals,
    emit PcOpRetValue at the end, then PushBlock with the
    serialized bytes. Format extended with a uint16 nParams field
    so the runtime's PcOpPushBlock dispatch can set
    PcodeFunc.Params correctly — without it, ExecPcode's
    Frame(0, 0) pulled none of Eval's args and the block saw
    every parameter as NIL.

  * **All g.locals accesses were case-sensitive.** PRG is case-
    insensitive, but the pcode generator stored block params via
    strings.ToUpper while every other lookup site (function decl,
    mid-decl, ForStmt, IdentExpr read, AssignExpr write,
    PostfixExpr) used the raw .Name. So `{|x| x*x }` stored "X"
    but read "x" and missed. Normalized: all insertions and all
    lookups now go through strings.ToUpper.

  * **SeqExpr in pcode** — added the matching emit for comma-
    separated expression lists in code blocks (`{|| a, b, c }`).
    Same shape as the gengo SeqExpr case from Wave 1.

Test fixture: tests/frb/test_frb_pcode_sweep.prg covers 14 shapes
(string ops, arithmetic, comparison chains, array indexing, DO
WHILE with postfix, nested IF, IIf, hash literal + indexing,
block + Eval, character iteration). All 14 pass. Wired into the
FRB runner — suite now stands at 7/7.

Other gates green:
  go test ./...      : PASS
  FiveSql2 SQL:1999  : 43/43
  Harbour compat     : 56/56
  std.ch suite       : 15/15
  FRB suite          : 7/7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 11:32:38 +09:00
parent dca7bb22e5
commit 29ca02e1bc
5 changed files with 415 additions and 20 deletions

View File

@@ -123,16 +123,17 @@ func (g *generator) emitFunc(fn *ast.FuncDecl) {
g.code = nil
g.locals = make(map[string]int)
// Build local map
// Build local map. PRG is case-insensitive so all keys are
// uppercased here; every lookup site below must mirror this.
idx := 1
for _, p := range fn.Params {
g.locals[p.Name] = idx
g.locals[strings.ToUpper(p.Name)] = idx
idx++
}
for _, d := range fn.Decls {
if vd, ok := d.(*ast.VarDecl); ok && vd.Scope == ast.ScopeLocal {
for _, v := range vd.Vars {
g.locals[v.Name] = idx
g.locals[strings.ToUpper(v.Name)] = idx
idx++
}
}
@@ -140,7 +141,7 @@ func (g *generator) emitFunc(fn *ast.FuncDecl) {
for _, s := range fn.Body {
if vd, ok := s.(*ast.VarDecl); ok && vd.Scope == ast.ScopeLocal {
for _, v := range vd.Vars {
g.locals[v.Name] = idx
g.locals[strings.ToUpper(v.Name)] = idx
idx++
}
}
@@ -228,7 +229,7 @@ func (g *generator) emitStmt(stmt ast.Stmt) {
for _, v := range s.Vars {
if v.Init != nil {
g.emitExpr(v.Init)
if idx, ok := g.locals[v.Name]; ok {
if idx, ok := g.locals[strings.ToUpper(v.Name)]; ok {
g.emit(hbrt.PcOpPopLocal)
g.emitU16(uint16(idx))
} else {
@@ -287,7 +288,7 @@ func (g *generator) emitDoWhile(s *ast.DoWhileStmt) {
}
func (g *generator) emitFor(s *ast.ForStmt) {
idx, ok := g.locals[s.Var]
idx, ok := g.locals[strings.ToUpper(s.Var)]
if !ok {
return
}
@@ -400,7 +401,12 @@ func (g *generator) emitExpr(expr ast.Expr) {
g.emit(hbrt.PcOpPushSelf)
return
}
if idx, ok := g.locals[e.Name]; ok {
// Locals are keyed case-insensitively. Look up via uppercase
// (also covers blocks: their params are stored ToUpper). The
// previous raw `e.Name` lookup missed any caller that wrote
// the identifier in different case from the declaration —
// `{|x| x * x }` invoked via Eval(b, 7) silently saw x=NIL.
if idx, ok := g.locals[upper]; ok {
g.emit(hbrt.PcOpPushLocal)
g.emitU16(uint16(idx))
} else {
@@ -463,6 +469,117 @@ func (g *generator) emitExpr(expr ast.Expr) {
g.emit(hbrt.PcOpArrayGen)
g.emitU16(uint16(len(e.Items)))
case *ast.BlockExpr:
// `{|p| body }` — compile body to its own pcode buffer with
// the block's params occupying locals 1..len(Params), then
// emit PcOpPushBlock + length + body bytes + nDetached (zero
// — closure capture isn't wired up in pcode mode yet, so
// blocks see their declared params and any module-local
// symbol but no caller locals).
// Without this case, BlockExpr fell through to the generic
// PushNil and Eval(NIL, ...) returned NIL — silently
// breaking every higher-order function (Eval / AEval /
// SqlScan predicate compile / etc.) inside a pcode body.
savedCode := g.code
savedLocals := g.locals
g.code = nil
g.locals = make(map[string]int, len(e.Params))
for i, p := range e.Params {
g.locals[strings.ToUpper(p)] = i + 1
}
g.emitExpr(e.Body)
g.emit(hbrt.PcOpRetValue)
body := g.code
g.code = savedCode
g.locals = savedLocals
g.emit(hbrt.PcOpPushBlock)
g.emitI32(int32(len(body)))
g.code = append(g.code, body...)
g.emitU16(uint16(len(e.Params))) // nParams
g.emitU16(0) // nDetached — no closure capture yet
case *ast.SeqExpr:
// Comma-separated expression list inside a code block:
// `{|| e1, e2, e3 }`. Evaluate each in order, pop intermediate
// results so only the last value remains. Same semantics as
// gengo's SeqExpr handler.
for i, item := range e.Items {
g.emitExpr(item)
if i < len(e.Items)-1 {
g.emit(hbrt.PcOpPop)
}
}
case *ast.HashLitExpr:
// `{ "k" => 1, ... }` — push each key+value pair, HashGen
// builds the hash from the top-N stack pairs. Without this
// case, the hash literal silently fell through to PushNil
// and any subsequent `h[key]` panicked at ArrayPush with
// "argument error (op: [])".
for i, k := range e.Keys {
g.emitExpr(k)
g.emitExpr(e.Values[i])
}
g.emit(hbrt.PcOpHashGen)
g.emitU16(uint16(len(e.Keys)))
case *ast.IndexExpr:
// arr[idx] — push array, push index, ArrayPush reads element.
// (ArrayPush is the "get" op; ArrayPop is the "set" op — names
// kept to match the Harbour stack-machine convention.)
// Without this case, indexed reads in pcode silently emitted
// PushNil via the default fallback, so `arr[i]` always
// returned NIL and `n + arr[i]` panicked at the +.
g.emitExpr(e.X)
g.emitExpr(e.Index)
g.emit(hbrt.PcOpArrayPush)
case *ast.PostfixExpr:
// `x++` / `x--` — read current value (becomes the expression
// result), apply Inc/Dec to the LOCAL slot, leave the
// pre-modification value on the stack so it round-trips
// correctly when used as an expression. As a statement the
// caller does Pop afterward.
// Without this case, postfix on pcode-mode silently emitted
// PushNil → `n++` was a no-op, breaking DO WHILE / FOR
// patterns that mutate the loop counter.
if id, isIdent := e.X.(*ast.IdentExpr); isIdent {
if idx, found := g.locals[strings.ToUpper(id.Name)]; found {
g.emit(hbrt.PcOpPushLocal)
g.emitU16(uint16(idx))
delta := int64(1)
if e.Op == token.DEC {
delta = -1
}
g.emit(hbrt.PcOpLocalAddInt)
g.emitU16(uint16(idx))
g.emitI32(int32(delta))
return
}
}
// Anything else (memvar, alias->field, arr[i]) — emit the
// expression as a no-op for now and document the gap.
g.emitExpr(e.X)
case *ast.AliasExpr:
// Pcode mode: only the M-> / MEMVAR-> namespace (memvar
// access) is wired up. The general workarea-alias form
// (`FOO->bar`, `(expr)->(body)`) needs new opcodes for
// alias dispatch + workarea context save/restore — until
// then it falls through to the generic NIL fallback so
// callers see "missing data" rather than crash.
if aliasIdent, ok1 := e.Alias.(*ast.IdentExpr); ok1 {
if fieldIdent, ok2 := e.Field.(*ast.IdentExpr); ok2 {
upper := strings.ToUpper(aliasIdent.Name)
if upper == "M" || upper == "MEMVAR" {
g.emitString(hbrt.PcOpPushMemvar, fieldIdent.Name)
return
}
}
}
g.emit(hbrt.PcOpPushNil)
default:
g.emit(hbrt.PcOpPushNil) // fallback
}
@@ -584,7 +701,7 @@ func (g *generator) emitAssign(a *ast.AssignExpr) {
op, ok := compoundBinOp(a.Op)
if ok {
if ident, isIdent := a.Left.(*ast.IdentExpr); isIdent {
if idx, found := g.locals[ident.Name]; found {
if idx, found := g.locals[strings.ToUpper(ident.Name)]; found {
g.emit(hbrt.PcOpPushLocal)
g.emitU16(uint16(idx))
g.emitExpr(a.Right)
@@ -597,7 +714,7 @@ func (g *generator) emitAssign(a *ast.AssignExpr) {
}
}
if ident, ok := a.Left.(*ast.IdentExpr); ok {
if idx, found := g.locals[ident.Name]; found {
if idx, found := g.locals[strings.ToUpper(ident.Name)]; found {
g.emitExpr(a.Right)
g.emit(hbrt.PcOpPopLocal)
g.emitU16(uint16(idx))

View File

@@ -1154,17 +1154,36 @@ func (p *Parser) parseIdentStmt() ast.Stmt {
// CLOSE / REINDEX / PACK / ZAP / UNLOCK / KEYBOARD / RUN are now
// rewritten by compiler/pp/std.ch into function calls before the
// parser sees them.
//
// Guard against shadowing variables — the keywords here (LABEL,
// REPORT, INPUT, NOTE, ...) are also extremely common LOCAL/PRIVATE
// names. If the very next token is an assignment / index / paren /
// alias-arrow operator, the user is doing a variable assignment or
// function call, not invoking the xBase command — fall through to
// expression parsing. This was a real silent bug: `LOCAL label` +
// `label := "x"` had the assignment swallowed by the LABEL case
// because the no-op consumed-to-EOL path doesn't care about :=.
switch upper {
case "LABEL", "REPORT", "ACCEPT", "INPUT",
"RELEASE", "SAVE", "RESTORE",
"DIR", "STORE", "NOTE", "TEXT", "ENDTEXT",
"WITH", "CLEAR":
p.advance()
for p.current.Kind != token.NEWLINE && p.current.Kind != token.EOF {
switch p.peekAt(1) {
case token.ASSIGN, token.PLUSEQ, token.MINUSEQ, token.STAREQ,
token.SLASHEQ, token.PERCENTEQ, token.POWEREQ,
token.LBRACKET, token.LPAREN, token.ARROW,
token.INC, token.DEC,
token.DOT:
// Looks like a variable / call / member-access — not
// the xBase command. Fall through.
default:
p.advance()
for p.current.Kind != token.NEWLINE && p.current.Kind != token.EOF {
p.advance()
}
p.expectEndOfStmt()
return &ast.ExprStmt{X: &ast.LiteralExpr{Kind: token.NIL_LIT, Value: "NIL"}}
}
p.expectEndOfStmt()
return &ast.ExprStmt{X: &ast.LiteralExpr{Kind: token.NIL_LIT, Value: "NIL"}}
case "FIVE_GODUMP__":
// GoDump is a Decl, wrap as ExprStmt for statement context
@@ -1229,13 +1248,25 @@ func (p *Parser) parseExprStmt() ast.Stmt {
"RELEASE", "SAVE", "RESTORE",
"DIR", "STORE", "NOTE", "TEXT", "ENDTEXT",
"WITH", "CLEAR":
// Consume entire line — these are complex multi-word commands
p.advance()
for p.current.Kind != token.NEWLINE && p.current.Kind != token.EOF {
// Same shadowing-guard as parseIdentStmt — see comment
// there. Without this, `LOCAL label ; label := "x"` had
// the assignment swallowed.
switch p.peekAt(1) {
case token.ASSIGN, token.PLUSEQ, token.MINUSEQ, token.STAREQ,
token.SLASHEQ, token.PERCENTEQ, token.POWEREQ,
token.LBRACKET, token.LPAREN, token.ARROW,
token.INC, token.DEC,
token.DOT:
// Variable / call / member-access — fall through.
default:
// Consume entire line — these are complex multi-word commands
p.advance()
for p.current.Kind != token.NEWLINE && p.current.Kind != token.EOF {
p.advance()
}
p.expectEndOfStmt()
return &ast.ExprStmt{X: &ast.LiteralExpr{Kind: token.NIL_LIT, Value: "NIL"}}
}
p.expectEndOfStmt()
return &ast.ExprStmt{X: &ast.LiteralExpr{Kind: token.NIL_LIT, Value: "NIL"}}
}
}