chore(infra): audit_uses_functions detecta mejor simbolos Go con abreviaturas (issue 0057)

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>
This commit is contained in:
Egutierrez
2026-06-30 13:14:27 +02:00
parent 88eabb0457
commit 8917105184
2 changed files with 396 additions and 16 deletions
@@ -148,6 +148,273 @@ func main() { fmt.Println("hello") }
})
}
// 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) {