8917105184
Reduce falsos positivos en la deteccion de simbolos Go del auditor uses_functions, por dos vias complementarias. Fase 1 — commonAbbrevs ampliado: anade abreviaturas verificadas contra los nombres reales de las funciones Go del registry (OHLCV, DuckDB, ClickHouse, NordVPN, SHA256, MD5, ANSI, CIDR, AEAD, PTY, VPS, WG, VT, FFT, EMA, RSI, SMA, VWAP, AX, E2E, URLs). El analisis empirico mostro que reduce los mismatches PascalCase-vs-real de 76 a 40 sin romper ninguna funcion. Se documenta por que NO se mapean "cdp" (el registry usa Cdp: CdpGetHTML, CdpNavigate) ni "pdf" (inconsistente: CdpPrintPDF vs PdfSimpleReport) — anadirlos generaria mas falsos positivos de los que arregla. Fase 2 — fallback a lectura del .go: cuando ni la signature ni PascalCase(name) localizan el simbolo, se lee el .go de la funcion del registry y se extrae el primer func exportado top-level (cache por ejecucion para no reabrir archivos). El fallback esta GATEADO a signature vacia: cuando la signature ya aporta un `func <Name>` es la fuente de verdad y no se sobreescribe. Esto evita la mis-atribucion en archivos .go compartidos por varias funciones (patron "TU adicional", p.ej. cdp_new_tab vive en cdp_list_tabs.go): sin el gate, el primer func del archivo (CdpListTabs) se atribuiria a cada hermano y suprimiria hallazgos reales de "unused". Verificacion (DoD): - go build -tags fts5 + go vet limpios. - Tests nuevos: TestSnakeToPascal_HandlesAbbreviations (golden + non-mappings cdp/pdf), TestAuditUsesFunctions_GoFileFallback (golden + error sin archivo), TestAuditUsesFunctions_SharedGoFileNotMisattributed (regresion del archivo compartido), TestGoRealExportedName (top-level/generic/missing/empty). - A/B contra el registry real (fn doctor uses-functions): baseline 69 unused vs nuevo 69, cero regresion; cdp_get_html_go_browser sigue sin marcarse unused en script_navegador (Fase 3.1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
446 lines
15 KiB
Go
446 lines
15 KiB
Go
package infra
|
|
|
|
import (
|
|
"database/sql"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
// createTestRegistryDB creates a minimal registry.db with the given apps and
|
|
// a single function (random_hex_id_go_core in domain core, lang go).
|
|
func createTestRegistryDB(t *testing.T, root string, apps []struct {
|
|
id string
|
|
lang string
|
|
dirPath string
|
|
usesFunctions string
|
|
}) {
|
|
t.Helper()
|
|
dbPath := filepath.Join(root, "registry.db")
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
|
|
_, err = db.Exec(`
|
|
CREATE TABLE functions (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT,
|
|
domain TEXT,
|
|
lang TEXT,
|
|
signature TEXT,
|
|
file_path TEXT
|
|
);
|
|
CREATE TABLE apps (
|
|
id TEXT PRIMARY KEY,
|
|
lang TEXT,
|
|
dir_path TEXT,
|
|
uses_functions TEXT DEFAULT '[]'
|
|
);
|
|
INSERT INTO functions (id, name, domain, lang, file_path)
|
|
VALUES ('random_hex_id_go_core','random_hex_id','core','go','functions/core/random_hex_id.go');
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, a := range apps {
|
|
_, err = db.Exec(
|
|
`INSERT INTO apps (id, lang, dir_path, uses_functions) VALUES (?,?,?,?)`,
|
|
a.id, a.lang, a.dirPath, a.usesFunctions,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("insert app %s: %v", a.id, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAuditUsesFunctions_DetectsMissing verifies that a Go app that calls
|
|
// RandomHexID in its source but declares empty uses_functions gets
|
|
// random_hex_id_go_core reported as missing.
|
|
func TestAuditUsesFunctions_DetectsMissing(t *testing.T) {
|
|
t.Run("missing function detected for Go app", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
createTestRegistryDB(t, root, []struct {
|
|
id, lang, dirPath, usesFunctions string
|
|
}{
|
|
{"testapp_go_tools", "go", "apps/testapp", `[]`},
|
|
})
|
|
|
|
appDir := filepath.Join(root, "apps", "testapp")
|
|
if err := os.MkdirAll(appDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
goSrc := `package main
|
|
|
|
import (
|
|
"fmt"
|
|
"fn-registry/functions/core"
|
|
)
|
|
|
|
func main() {
|
|
id := core.RandomHexID(8)
|
|
fmt.Println(id)
|
|
}
|
|
`
|
|
if err := os.WriteFile(filepath.Join(appDir, "main.go"), []byte(goSrc), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
results, err := AuditUsesFunctions(root)
|
|
if err != nil {
|
|
t.Fatalf("AuditUsesFunctions: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 result, got %d", len(results))
|
|
}
|
|
got := results[0]
|
|
if len(got.Missing) != 1 || got.Missing[0] != "random_hex_id_go_core" {
|
|
t.Errorf("Missing = %v, want [random_hex_id_go_core]", got.Missing)
|
|
}
|
|
if len(got.Unused) != 0 {
|
|
t.Errorf("Unused = %v, want []", got.Unused)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestAuditUsesFunctions_DetectsUnused verifies that a function declared in
|
|
// uses_functions but not called in source is reported as unused.
|
|
func TestAuditUsesFunctions_DetectsUnused(t *testing.T) {
|
|
t.Run("unused function detected for Go app", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
createTestRegistryDB(t, root, []struct {
|
|
id, lang, dirPath, usesFunctions string
|
|
}{
|
|
{"testapp2_go_tools", "go", "apps/testapp2", `["random_hex_id_go_core"]`},
|
|
})
|
|
|
|
appDir := filepath.Join(root, "apps", "testapp2")
|
|
if err := os.MkdirAll(appDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
goSrc := `package main
|
|
|
|
import "fmt"
|
|
|
|
func main() { fmt.Println("hello") }
|
|
`
|
|
if err := os.WriteFile(filepath.Join(appDir, "main.go"), []byte(goSrc), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
results, err := AuditUsesFunctions(root)
|
|
if err != nil {
|
|
t.Fatalf("AuditUsesFunctions: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 result, got %d", len(results))
|
|
}
|
|
got := results[0]
|
|
if len(got.Unused) != 1 || got.Unused[0] != "random_hex_id_go_core" {
|
|
t.Errorf("Unused = %v, want [random_hex_id_go_core]", got.Unused)
|
|
}
|
|
if len(got.Missing) != 0 {
|
|
t.Errorf("Missing = %v, want []", got.Missing)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestSnakeToPascal_HandlesAbbreviations verifies the commonAbbrevs expansion
|
|
// (issue 0057, Fase 1). Each "want" is the exported Go symbol the registry
|
|
// actually uses for that snake_case name. It also pins the deliberate
|
|
// non-mappings (cdp, pdf): the registry's own convention is mixed-case there,
|
|
// so the abbreviation must NOT fire.
|
|
func TestSnakeToPascal_HandlesAbbreviations(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
// New abbreviations added by issue 0057 (verified against real func names).
|
|
{"fetch_ohlcv", "FetchOHLCV"},
|
|
{"normalize_ohlcv", "NormalizeOHLCV"},
|
|
{"duckdb_open", "DuckDBOpen"},
|
|
{"load_ohlcv_from_duckdb", "LoadOHLCVFromDuckDB"},
|
|
{"clickhouse_open", "ClickHouseOpen"},
|
|
{"nordvpn_container_run", "NordVPNContainerRun"},
|
|
{"parse_nordvpn_status", "ParseNordVPNStatus"},
|
|
{"hash_sha256", "HashSHA256"},
|
|
{"hash_md5", "HashMD5"},
|
|
{"strip_ansi", "StripANSI"},
|
|
{"parse_ip_cidr", "ParseIPCIDR"},
|
|
{"open_aead", "OpenAEAD"},
|
|
{"seal_aead", "SealAEAD"},
|
|
{"pty_capture_stream", "PTYCaptureStream"},
|
|
{"setup_vps_app", "SetupVPSApp"},
|
|
{"vps_setup_app", "VPSSetupApp"},
|
|
{"wg_keygen", "WGKeygen"},
|
|
{"wg_peer_add", "WGPeerAdd"},
|
|
{"vt_render", "VTRender"},
|
|
{"fft", "FFT"},
|
|
{"ema", "EMA"},
|
|
{"rsi", "RSI"},
|
|
{"sma", "SMA"},
|
|
{"vwap", "VWAP"},
|
|
{"cdp_get_ax_outline", "CdpGetAXOutline"},
|
|
{"audit_e2e_coverage", "AuditE2ECoverage"},
|
|
{"e2e_run_checks", "E2ERunChecks"},
|
|
{"extract_urls", "ExtractURLs"},
|
|
// Pre-existing abbreviations (regression guard — must keep working).
|
|
{"http_json_response", "HTTPJSONResponse"},
|
|
{"sqlite_open", "SQLiteOpen"},
|
|
{"random_hex_id", "RandomHexID"},
|
|
// Deliberate non-mappings: registry uses mixed-case (Cdp, Pdf) here, so
|
|
// the snake_case→Pascal conversion must leave them mixed-case. These are
|
|
// the cases the .go fallback (Fase 2) and the signature path cover.
|
|
{"cdp_get_html", "CdpGetHTML"},
|
|
{"cdp_navigate", "CdpNavigate"},
|
|
{"pdf_simple_report", "PdfSimpleReport"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := snakeToPascal(c.in); got != c.want {
|
|
t.Errorf("snakeToPascal(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// goFallbackEnv builds a minimal registry.db + app on disk for the .go fallback
|
|
// test. The registry function gen_wails_crud_go_infra mimics wails_bind_crud:
|
|
// its signature omits the `func` keyword (so the signature regex misses) and its
|
|
// PascalCase("gen_wails_crud")="GenWailsCRUD" differs from the real exported
|
|
// symbol "GenerateWailsCRUD". The app calls the real symbol. When writeFnFile is
|
|
// true, the registry .go file exists and the fallback can recover the symbol.
|
|
func goFallbackEnv(t *testing.T, fnFilePath string, writeFnFile bool) UsesFunctionsAudit {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
dbPath := filepath.Join(root, "registry.db")
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = db.Exec(`
|
|
CREATE TABLE functions (id TEXT PRIMARY KEY, name TEXT, domain TEXT, lang TEXT, signature TEXT, file_path TEXT);
|
|
CREATE TABLE apps (id TEXT PRIMARY KEY, lang TEXT, dir_path TEXT, uses_functions TEXT DEFAULT '[]');
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = db.Exec(
|
|
`INSERT INTO functions (id,name,domain,lang,signature,file_path) VALUES (?,?,?,?,?,?)`,
|
|
"gen_wails_crud_go_infra", "gen_wails_crud", "infra", "go",
|
|
"GenerateWailsCRUD(spec WailsCRUDSpec) string", fnFilePath,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = db.Exec(
|
|
`INSERT INTO apps (id,lang,dir_path,uses_functions) VALUES (?,?,?,?)`,
|
|
"myapp_go_infra", "go", "apps/myapp", `["gen_wails_crud_go_infra"]`,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.Close()
|
|
|
|
if writeFnFile {
|
|
fnAbsDir := filepath.Join(root, filepath.Dir(fnFilePath))
|
|
if err := os.MkdirAll(fnAbsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
src := "package infra\n\ntype WailsCRUDSpec struct{}\n\nfunc GenerateWailsCRUD(spec WailsCRUDSpec) string { return \"\" }\n"
|
|
if err := os.WriteFile(filepath.Join(root, fnFilePath), []byte(src), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
appDir := filepath.Join(root, "apps", "myapp")
|
|
if err := os.MkdirAll(appDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
appSrc := "package main\n\nimport (\n\t\"fmt\"\n\t\"fn-registry/functions/infra\"\n)\n\nfunc main() {\n\tfmt.Println(infra.GenerateWailsCRUD(infra.WailsCRUDSpec{}))\n}\n"
|
|
if err := os.WriteFile(filepath.Join(appDir, "main.go"), []byte(appSrc), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
results, err := AuditUsesFunctions(root)
|
|
if err != nil {
|
|
t.Fatalf("AuditUsesFunctions: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 result, got %d", len(results))
|
|
}
|
|
return results[0]
|
|
}
|
|
|
|
// TestAuditUsesFunctions_GoFileFallback verifies the .go fallback (issue 0057,
|
|
// Fase 2): when neither the registry signature nor PascalCase(name) yields the
|
|
// real exported symbol, the auditor reads the function's .go file to recover it,
|
|
// so a genuinely-used function is not a false "unused". The error sub-case (file
|
|
// absent) shows the fallback degrades gracefully and the function is then
|
|
// correctly reported unused — proving the fallback is load-bearing.
|
|
func TestAuditUsesFunctions_GoFileFallback(t *testing.T) {
|
|
t.Run("golden: .go fallback recovers real symbol -> not unused", func(t *testing.T) {
|
|
got := goFallbackEnv(t, "functions/infra/gen_wails_crud.go", true)
|
|
if len(got.Unused) != 0 {
|
|
t.Errorf("Unused = %v, want [] (fallback should find GenerateWailsCRUD)", got.Unused)
|
|
}
|
|
if len(got.Missing) != 0 {
|
|
t.Errorf("Missing = %v, want []", got.Missing)
|
|
}
|
|
})
|
|
|
|
t.Run("error: missing .go file -> flagged unused, no crash", func(t *testing.T) {
|
|
got := goFallbackEnv(t, "functions/infra/gen_wails_crud.go", false)
|
|
if len(got.Unused) != 1 || got.Unused[0] != "gen_wails_crud_go_infra" {
|
|
t.Errorf("Unused = %v, want [gen_wails_crud_go_infra] (no fallback file to read)", got.Unused)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestAuditUsesFunctions_SharedGoFileNotMisattributed pins the regression caught
|
|
// during issue 0057 verification: several registry functions can share one .go
|
|
// file (the "TU adicional" pattern, e.g. cdp_new_tab living in cdp_list_tabs.go).
|
|
// Because they have valid signatures, the .go fallback must stay GATED OFF for
|
|
// them — otherwise the file's first exported func (here ListTabs) would be
|
|
// mis-attributed to a sibling (NewTab) and suppress a genuine "unused" finding.
|
|
// The app below uses only ListTabs; NewTab must remain flagged unused.
|
|
func TestAuditUsesFunctions_SharedGoFileNotMisattributed(t *testing.T) {
|
|
root := t.TempDir()
|
|
dbPath := filepath.Join(root, "registry.db")
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = db.Exec(`
|
|
CREATE TABLE functions (id TEXT PRIMARY KEY, name TEXT, domain TEXT, lang TEXT, signature TEXT, file_path TEXT);
|
|
CREATE TABLE apps (id TEXT PRIMARY KEY, lang TEXT, dir_path TEXT, uses_functions TEXT DEFAULT '[]');
|
|
INSERT INTO functions (id,name,domain,lang,signature,file_path) VALUES
|
|
('list_tabs_go_browser','list_tabs','browser','go','func ListTabs() error','functions/browser/tabs.go'),
|
|
('new_tab_go_browser','new_tab','browser','go','func NewTab() error','functions/browser/tabs.go');
|
|
INSERT INTO apps (id,lang,dir_path,uses_functions) VALUES
|
|
('tabsapp_go_browser','go','apps/tabsapp','["list_tabs_go_browser","new_tab_go_browser"]');
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.Close()
|
|
|
|
// Shared registry .go file: ListTabs is the FIRST exported func.
|
|
fnDir := filepath.Join(root, "functions", "browser")
|
|
if err := os.MkdirAll(fnDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tabsSrc := "package browser\n\nfunc ListTabs() error { return nil }\n\nfunc NewTab() error { return nil }\n"
|
|
if err := os.WriteFile(filepath.Join(fnDir, "tabs.go"), []byte(tabsSrc), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// App calls only ListTabs, but declares both.
|
|
appDir := filepath.Join(root, "apps", "tabsapp")
|
|
if err := os.MkdirAll(appDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
appSrc := "package main\n\nimport (\n\t\"fmt\"\n\t\"fn-registry/functions/browser\"\n)\n\nfunc main() {\n\tfmt.Println(browser.ListTabs())\n}\n"
|
|
if err := os.WriteFile(filepath.Join(appDir, "main.go"), []byte(appSrc), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
results, err := AuditUsesFunctions(root)
|
|
if err != nil {
|
|
t.Fatalf("AuditUsesFunctions: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 result, got %d", len(results))
|
|
}
|
|
got := results[0]
|
|
if len(got.Unused) != 1 || got.Unused[0] != "new_tab_go_browser" {
|
|
t.Errorf("Unused = %v, want [new_tab_go_browser] (sibling must NOT rescue via shared file)", got.Unused)
|
|
}
|
|
}
|
|
|
|
// TestGoRealExportedName verifies the .go symbol extractor: top-level exported
|
|
// funcs (plain and generic) are recovered, method receivers are skipped, the
|
|
// result is cached, and unreadable/empty paths return "" without error.
|
|
func TestGoRealExportedName(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(root, "functions", "infra"), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// File whose first exported func is preceded by an unexported func + a method.
|
|
src := "package infra\n\n" +
|
|
"import \"fmt\"\n\n" +
|
|
"func helper() {}\n\n" +
|
|
"type T struct{}\n\n" +
|
|
"func (t *T) Save() {}\n\n" +
|
|
"func GenerateWailsCRUD(spec int) string { fmt.Println(spec); return \"\" }\n\n" +
|
|
"func WailsStreamData[X any](xs []X) {}\n"
|
|
rel := "functions/infra/sample.go"
|
|
if err := os.WriteFile(filepath.Join(root, rel), []byte(src), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cache := map[string]string{}
|
|
|
|
t.Run("golden: first top-level exported func (skips helper + method)", func(t *testing.T) {
|
|
if got := goRealExportedName(root, rel, cache); got != "GenerateWailsCRUD" {
|
|
t.Errorf("got %q, want GenerateWailsCRUD", got)
|
|
}
|
|
if cache[rel] != "GenerateWailsCRUD" {
|
|
t.Errorf("cache[%q] = %q, want GenerateWailsCRUD", rel, cache[rel])
|
|
}
|
|
})
|
|
|
|
t.Run("edge: generic func form func Name[T any](", func(t *testing.T) {
|
|
genRel := "functions/infra/gen.go"
|
|
genSrc := "package infra\n\nfunc WailsStreamData[X any](xs []X) {}\n"
|
|
if err := os.WriteFile(filepath.Join(root, genRel), []byte(genSrc), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := goRealExportedName(root, genRel, cache); got != "WailsStreamData" {
|
|
t.Errorf("got %q, want WailsStreamData", got)
|
|
}
|
|
})
|
|
|
|
t.Run("error: missing file -> empty string, cached", func(t *testing.T) {
|
|
missRel := "functions/infra/does_not_exist.go"
|
|
if got := goRealExportedName(root, missRel, cache); got != "" {
|
|
t.Errorf("got %q, want empty for missing file", got)
|
|
}
|
|
if v, ok := cache[missRel]; !ok || v != "" {
|
|
t.Errorf("missing file should be cached as empty, got ok=%v v=%q", ok, v)
|
|
}
|
|
})
|
|
|
|
t.Run("error: empty file_path -> empty string", func(t *testing.T) {
|
|
if got := goRealExportedName(root, "", cache); got != "" {
|
|
t.Errorf("got %q, want empty for empty path", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestAuditUsesFunctions_MissingDir verifies that apps whose dir_path does not
|
|
// exist on disk get an entry with nil Missing/Unused slices (cannot inspect).
|
|
func TestAuditUsesFunctions_MissingDir(t *testing.T) {
|
|
t.Run("missing dir returns entry with nil slices", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
createTestRegistryDB(t, root, []struct {
|
|
id, lang, dirPath, usesFunctions string
|
|
}{
|
|
{"testapp3_go_tools", "go", "apps/testapp3", `[]`},
|
|
})
|
|
// intentionally do NOT create apps/testapp3 on disk
|
|
|
|
results, err := AuditUsesFunctions(root)
|
|
if err != nil {
|
|
t.Fatalf("AuditUsesFunctions: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("expected 1 result, got %d", len(results))
|
|
}
|
|
got := results[0]
|
|
if got.Missing != nil {
|
|
t.Errorf("Missing should be nil for missing dir, got %v", got.Missing)
|
|
}
|
|
if got.Unused != nil {
|
|
t.Errorf("Unused should be nil for missing dir, got %v", got.Unused)
|
|
}
|
|
})
|
|
}
|