feat(analyzer): walk CLASS method bodies for undeclared-var warnings

Phase 2 of the analyzer originally only called analyzeFunc on
*ast.FuncDecl. Class methods parse as *ast.MethodDecl and were
silently skipped — meaning anything inside `METHOD Foo() CLASS TBar`
got zero static checking, including the undeclared-variable scan.

This is what let FindExclusive's DBI_FULLPATH / DBI_SHARED references
ship: the gengo fallback (now PushMemvar, previously PushLocal(0))
turned them into runtime NIL / crash, but the analyzer never flagged
them at build time because it never descended into the method body.

Fix: add analyzeMethod — same scope setup as analyzeFunc (module
statics, parameters, LOCAL/STATIC decls) — and route MethodDecl to
it from the Phase 2 dispatch.

Also register PCCOMPILE / PCEVAL / SQLSCAN in the RTL allow-list so
FiveSql2's new pcode hot-path RTL doesn't trip the warning.

Expected side effect: the FiveSql2 build now emits 17 real warnings
from TSqlIndex.prg — undefined DBOI_* order-info constants and
unregistered RTL functions (FieldType, FieldLen, ordCreate,
dbCreateIndex, dbClearIndex). These are real tech debt hiding behind
PushMemvar's silent NIL fallback; left as-is to surface them rather
than suppress.

Validation:
  - FiveSql2 43/43
  - Harbour compat 51/51
  - go test ./compiler/analyzer/... PASS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 09:46:33 +09:00
parent 08ad6f4761
commit d89797c4e3

View File

@@ -111,11 +111,13 @@ func Analyze(file *ast.File, externalFuncs ...map[string]bool) []Diagnostic {
}
}
// Phase 2: Analyze each function
// Phase 2: Analyze each function and class method body
for _, d := range file.Decls {
switch decl := d.(type) {
case *ast.FuncDecl:
a.analyzeFunc(decl)
case *ast.MethodDecl:
a.analyzeMethod(decl)
}
}
@@ -175,6 +177,63 @@ func (a *Analyzer) analyzeFunc(fn *ast.FuncDecl) {
}
}
// analyzeMethod walks a class-method body (`METHOD Foo() CLASS TBar`)
// applying the same undeclared-variable and unused-variable checks
// that analyzeFunc performs on standalone functions. Without this,
// unresolved identifiers inside CLASS methods silently fell through
// to gengo's memvar fallback (NIL at runtime) — e.g. a missing
// `#include "dbinfo.ch"` leaving DBI_FULLPATH undefined in a method.
func (a *Analyzer) analyzeMethod(m *ast.MethodDecl) {
a.scope = &Scope{
Name: m.Name,
Declared: make(map[string]VarInfo),
Used: make(map[string]bool),
}
// Module-level STATICs are visible to class methods too
for name, info := range a.moduleStatics {
a.scope.Declared[name] = info
}
// Parameters
for _, p := range m.Params {
a.scope.Declared[strings.ToUpper(p.Name)] = VarInfo{
Name: p.Name,
Pos: p.NamePos,
IsParam: true,
}
}
// LOCAL / STATIC declarations inside the method
for _, d := range m.Decls {
if vd, ok := d.(*ast.VarDecl); ok {
for _, v := range vd.Vars {
a.scope.Declared[strings.ToUpper(v.Name)] = VarInfo{
Name: v.Name,
Pos: v.NamePos,
Kind: vd.Scope,
}
}
}
}
for _, stmt := range m.Body {
a.analyzeStmt(stmt)
}
// Unused-variable hints (same exclusions as analyzeFunc)
for name, info := range a.scope.Declared {
if !a.scope.Used[name] && !info.IsParam {
lower := strings.ToLower(info.Name)
if lower == "i" || lower == "j" || lower == "k" || lower == "n" ||
lower == "err" || lower == "_" {
continue
}
a.hint(info.Pos, "unused variable '%s'", info.Name)
}
}
}
func (a *Analyzer) analyzeStmt(stmt ast.Stmt) {
if stmt == nil {
return
@@ -487,6 +546,8 @@ var rtlFunctions = map[string]bool{
"DBRECALL": true, "DBCOMMIT": true, "DBRLOCK": true, "DBRUNLOCK": true,
"DBSEEK": true, "DBSELECTAREA": true, "DBPACK": true, "DBZAP": true,
"DBCREATE": true, "DBINFO": true, "DBORDERINFO": true, "DBSETINDEX": true,
// FiveSql2 hybrid hot-path RTL (pcode + Go-native scan)
"PCCOMPILE": true, "PCEVAL": true, "SQLSCAN": true,
"RECALL": true, "PACK": true, "ZAP": true,
"FLOCK": true, "DBUNLOCK": true,
"__DBPACK": true, "__DBZAP": true,