Implement deferred core utility parity APIs/tests and refresh tracking artifacts
This commit is contained in:
@@ -256,6 +256,10 @@ func (a *Analyzer) parseTestFile(filePath string) ([]TestFunc, []ImportInfo, int
|
||||
}
|
||||
|
||||
test.FeatureName = a.inferFeatureName(name)
|
||||
test.BestFeatureIdx = -1
|
||||
if fn.Body != nil {
|
||||
test.Calls = a.extractCalls(fn.Body)
|
||||
}
|
||||
tests = append(tests, test)
|
||||
}
|
||||
|
||||
@@ -331,6 +335,210 @@ func (a *Analyzer) inferFeatureName(testName string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// extractCalls walks an AST block statement and extracts all function/method calls.
|
||||
func (a *Analyzer) extractCalls(body *ast.BlockStmt) []CallInfo {
|
||||
seen := make(map[string]bool)
|
||||
var calls []CallInfo
|
||||
|
||||
ast.Inspect(body, func(n ast.Node) bool {
|
||||
callExpr, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
var ci CallInfo
|
||||
switch fun := callExpr.Fun.(type) {
|
||||
case *ast.Ident:
|
||||
ci = CallInfo{FuncName: fun.Name}
|
||||
case *ast.SelectorExpr:
|
||||
ci = CallInfo{
|
||||
RecvOrPkg: extractIdent(fun.X),
|
||||
MethodName: fun.Sel.Name,
|
||||
IsSelector: true,
|
||||
}
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
||||
key := ci.callKey()
|
||||
if !seen[key] && !isFilteredCall(ci) {
|
||||
seen[key] = true
|
||||
calls = append(calls, ci)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return calls
|
||||
}
|
||||
|
||||
// extractIdent extracts an identifier name from an expression (handles X in X.Y).
|
||||
func extractIdent(expr ast.Expr) string {
|
||||
switch e := expr.(type) {
|
||||
case *ast.Ident:
|
||||
return e.Name
|
||||
case *ast.SelectorExpr:
|
||||
return extractIdent(e.X) + "." + e.Sel.Name
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// isFilteredCall returns true if a call should be excluded from feature matching.
|
||||
func isFilteredCall(c CallInfo) bool {
|
||||
if c.IsSelector {
|
||||
recv := c.RecvOrPkg
|
||||
// testing.T/B methods
|
||||
if recv == "t" || recv == "b" || recv == "tb" {
|
||||
return true
|
||||
}
|
||||
// stdlib packages
|
||||
if stdlibPkgs[recv] {
|
||||
return true
|
||||
}
|
||||
// NATS client libs
|
||||
if recv == "nats" || recv == "nuid" || recv == "nkeys" || recv == "jwt" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Go builtins
|
||||
name := c.FuncName
|
||||
if builtinFuncs[name] {
|
||||
return true
|
||||
}
|
||||
|
||||
// Test assertion helpers
|
||||
lower := strings.ToLower(name)
|
||||
if strings.HasPrefix(name, "require_") {
|
||||
return true
|
||||
}
|
||||
for _, prefix := range []string{"check", "verify", "assert", "expect"} {
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// featureRef identifies a feature within the analysis result.
|
||||
type featureRef struct {
|
||||
moduleIdx int
|
||||
featureIdx int
|
||||
goFile string
|
||||
goClass string
|
||||
}
|
||||
|
||||
// resolveCallGraph matches test calls against known features across all modules.
|
||||
func resolveCallGraph(result *AnalysisResult) {
|
||||
// Build method index: go_method name → list of feature refs
|
||||
methodIndex := make(map[string][]featureRef)
|
||||
for mi, mod := range result.Modules {
|
||||
for fi, feat := range mod.Features {
|
||||
ref := featureRef{
|
||||
moduleIdx: mi,
|
||||
featureIdx: fi,
|
||||
goFile: feat.GoFile,
|
||||
goClass: feat.GoClass,
|
||||
}
|
||||
methodIndex[feat.GoMethod] = append(methodIndex[feat.GoMethod], ref)
|
||||
}
|
||||
}
|
||||
|
||||
// For each test, resolve calls to features
|
||||
for mi := range result.Modules {
|
||||
mod := &result.Modules[mi]
|
||||
for ti := range mod.Tests {
|
||||
test := &mod.Tests[ti]
|
||||
seen := make(map[int]bool) // feature indices already linked
|
||||
var linked []int
|
||||
|
||||
testFileBase := sourceFileBase(test.GoFile)
|
||||
|
||||
for _, call := range test.Calls {
|
||||
// Look up the method name
|
||||
name := call.MethodName
|
||||
if !call.IsSelector {
|
||||
name = call.FuncName
|
||||
}
|
||||
|
||||
candidates := methodIndex[name]
|
||||
if len(candidates) == 0 {
|
||||
continue
|
||||
}
|
||||
// Ambiguity threshold: skip very common method names
|
||||
if len(candidates) > 10 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter to same module
|
||||
var sameModule []featureRef
|
||||
for _, ref := range candidates {
|
||||
if ref.moduleIdx == mi {
|
||||
sameModule = append(sameModule, ref)
|
||||
}
|
||||
}
|
||||
if len(sameModule) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ref := range sameModule {
|
||||
if !seen[ref.featureIdx] {
|
||||
seen[ref.featureIdx] = true
|
||||
linked = append(linked, ref.featureIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.LinkedFeatures = linked
|
||||
|
||||
// Set BestFeatureIdx using priority:
|
||||
// (a) existing inferFeatureName match
|
||||
// (b) same-file-base match
|
||||
// (c) first remaining candidate
|
||||
if test.BestFeatureIdx < 0 && len(linked) > 0 {
|
||||
// Try same-file-base match first
|
||||
for _, fi := range linked {
|
||||
featFileBase := sourceFileBase(mod.Features[fi].GoFile)
|
||||
if featFileBase == testFileBase {
|
||||
test.BestFeatureIdx = fi
|
||||
break
|
||||
}
|
||||
}
|
||||
// Fall back to first candidate
|
||||
if test.BestFeatureIdx < 0 {
|
||||
test.BestFeatureIdx = linked[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sourceFileBase strips _test.go suffix and path to get the base file name.
|
||||
func sourceFileBase(goFile string) string {
|
||||
base := filepath.Base(goFile)
|
||||
base = strings.TrimSuffix(base, "_test.go")
|
||||
base = strings.TrimSuffix(base, ".go")
|
||||
return base
|
||||
}
|
||||
|
||||
var stdlibPkgs = map[string]bool{
|
||||
"fmt": true, "time": true, "strings": true, "bytes": true, "errors": true,
|
||||
"os": true, "math": true, "sort": true, "reflect": true, "sync": true,
|
||||
"context": true, "io": true, "filepath": true, "strconv": true,
|
||||
"encoding": true, "json": true, "binary": true, "hex": true, "rand": true,
|
||||
"runtime": true, "atomic": true, "slices": true, "testing": true,
|
||||
"net": true, "bufio": true, "crypto": true, "log": true, "regexp": true,
|
||||
"unicode": true, "http": true, "url": true,
|
||||
}
|
||||
|
||||
var builtinFuncs = map[string]bool{
|
||||
"make": true, "append": true, "len": true, "cap": true, "close": true,
|
||||
"delete": true, "panic": true, "recover": true, "print": true,
|
||||
"println": true, "copy": true, "new": true,
|
||||
}
|
||||
|
||||
// isStdlib checks if an import path is a Go standard library package.
|
||||
func isStdlib(importPath string) bool {
|
||||
firstSlash := strings.Index(importPath, "/")
|
||||
|
||||
@@ -11,28 +11,47 @@ func main() {
|
||||
sourceDir := flag.String("source", "", "Path to Go source root (e.g., ../../golang/nats-server)")
|
||||
dbPath := flag.String("db", "", "Path to SQLite database file (e.g., ../../porting.db)")
|
||||
schemaPath := flag.String("schema", "", "Path to SQL schema file (e.g., ../../porting-schema.sql)")
|
||||
mode := flag.String("mode", "full", "Analysis mode: 'full' (default) or 'call-graph' (incremental)")
|
||||
flag.Parse()
|
||||
|
||||
if *sourceDir == "" || *dbPath == "" || *schemaPath == "" {
|
||||
fmt.Fprintf(os.Stderr, "Usage: go-analyzer --source <path> --db <path> --schema <path>\n")
|
||||
if *sourceDir == "" || *dbPath == "" {
|
||||
fmt.Fprintf(os.Stderr, "Usage: go-analyzer --source <path> --db <path> [--schema <path>] [--mode full|call-graph]\n")
|
||||
flag.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch *mode {
|
||||
case "full":
|
||||
runFull(*sourceDir, *dbPath, *schemaPath)
|
||||
case "call-graph":
|
||||
runCallGraph(*sourceDir, *dbPath)
|
||||
default:
|
||||
log.Fatalf("Unknown mode %q: must be 'full' or 'call-graph'", *mode)
|
||||
}
|
||||
}
|
||||
|
||||
func runFull(sourceDir, dbPath, schemaPath string) {
|
||||
if schemaPath == "" {
|
||||
log.Fatal("--schema is required for full mode")
|
||||
}
|
||||
|
||||
// Open DB and apply schema
|
||||
db, err := OpenDB(*dbPath, *schemaPath)
|
||||
db, err := OpenDB(dbPath, schemaPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Run analysis
|
||||
analyzer := NewAnalyzer(*sourceDir)
|
||||
analyzer := NewAnalyzer(sourceDir)
|
||||
result, err := analyzer.Analyze()
|
||||
if err != nil {
|
||||
log.Fatalf("Analysis failed: %v", err)
|
||||
}
|
||||
|
||||
// Resolve call graph before writing
|
||||
resolveCallGraph(result)
|
||||
|
||||
// Write to DB
|
||||
writer := NewDBWriter(db)
|
||||
if err := writer.WriteAll(result); err != nil {
|
||||
@@ -46,3 +65,35 @@ func main() {
|
||||
fmt.Printf(" Dependencies: %d\n", len(result.Dependencies))
|
||||
fmt.Printf(" Imports: %d\n", len(result.Imports))
|
||||
}
|
||||
|
||||
func runCallGraph(sourceDir, dbPath string) {
|
||||
// Open existing DB without schema
|
||||
db, err := OpenDBNoSchema(dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Run analysis (parse Go source)
|
||||
analyzer := NewAnalyzer(sourceDir)
|
||||
result, err := analyzer.Analyze()
|
||||
if err != nil {
|
||||
log.Fatalf("Analysis failed: %v", err)
|
||||
}
|
||||
|
||||
// Resolve call graph
|
||||
resolveCallGraph(result)
|
||||
|
||||
// Update DB incrementally
|
||||
writer := NewDBWriter(db)
|
||||
stats, err := writer.UpdateCallGraph(result)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to update call graph: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Call graph analysis complete:\n")
|
||||
fmt.Printf(" Tests analyzed: %d\n", stats.TestsAnalyzed)
|
||||
fmt.Printf(" Tests linked: %d\n", stats.TestsLinked)
|
||||
fmt.Printf(" Dependency rows: %d\n", stats.DependencyRows)
|
||||
fmt.Printf(" Feature IDs set: %d\n", stats.FeatureIDsSet)
|
||||
}
|
||||
|
||||
@@ -152,3 +152,176 @@ func (w *DBWriter) insertLibrary(tx *sql.Tx, imp *ImportInfo) error {
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// OpenDBNoSchema opens an existing SQLite database without applying schema.
|
||||
// It verifies that the required tables exist.
|
||||
func OpenDBNoSchema(dbPath string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=ON")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening database: %w", err)
|
||||
}
|
||||
|
||||
// Verify required tables exist
|
||||
for _, table := range []string{"modules", "features", "unit_tests", "dependencies"} {
|
||||
var name string
|
||||
err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&name)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("required table %q not found: %w", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// CallGraphStats holds summary statistics from a call-graph update.
|
||||
type CallGraphStats struct {
|
||||
TestsAnalyzed int
|
||||
TestsLinked int
|
||||
DependencyRows int
|
||||
FeatureIDsSet int
|
||||
}
|
||||
|
||||
// UpdateCallGraph writes call-graph analysis results to the database incrementally.
|
||||
func (w *DBWriter) UpdateCallGraph(result *AnalysisResult) (*CallGraphStats, error) {
|
||||
stats := &CallGraphStats{}
|
||||
|
||||
// Load module name→ID mapping
|
||||
moduleIDs := make(map[string]int64)
|
||||
rows, err := w.db.Query("SELECT id, name FROM modules")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying modules: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var name string
|
||||
if err := rows.Scan(&id, &name); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
moduleIDs[name] = id
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Load feature DB IDs: "module_name:go_method:go_class" → id
|
||||
type featureKey struct {
|
||||
moduleName string
|
||||
goMethod string
|
||||
goClass string
|
||||
}
|
||||
featureDBIDs := make(map[featureKey]int64)
|
||||
rows, err = w.db.Query(`
|
||||
SELECT f.id, m.name, f.go_method, COALESCE(f.go_class, '')
|
||||
FROM features f
|
||||
JOIN modules m ON f.module_id = m.id
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying features: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var modName, goMethod, goClass string
|
||||
if err := rows.Scan(&id, &modName, &goMethod, &goClass); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
featureDBIDs[featureKey{modName, goMethod, goClass}] = id
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Load test DB IDs: "module_name:go_method" → id
|
||||
testDBIDs := make(map[string]int64)
|
||||
rows, err = w.db.Query(`
|
||||
SELECT ut.id, m.name, ut.go_method
|
||||
FROM unit_tests ut
|
||||
JOIN modules m ON ut.module_id = m.id
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying unit_tests: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var modName, goMethod string
|
||||
if err := rows.Scan(&id, &modName, &goMethod); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
testDBIDs[modName+":"+goMethod] = id
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Begin transaction
|
||||
tx, err := w.db.Begin()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("beginning transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Clear old call-graph data
|
||||
if _, err := tx.Exec("DELETE FROM dependencies WHERE source_type='unit_test' AND dependency_kind='calls'"); err != nil {
|
||||
return nil, fmt.Errorf("clearing old dependencies: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec("UPDATE unit_tests SET feature_id = NULL"); err != nil {
|
||||
return nil, fmt.Errorf("clearing old feature_ids: %w", err)
|
||||
}
|
||||
|
||||
// Prepare statements
|
||||
insertDep, err := tx.Prepare("INSERT OR IGNORE INTO dependencies (source_type, source_id, target_type, target_id, dependency_kind) VALUES ('unit_test', ?, 'feature', ?, 'calls')")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("preparing insert dependency: %w", err)
|
||||
}
|
||||
defer insertDep.Close()
|
||||
|
||||
updateFeatureID, err := tx.Prepare("UPDATE unit_tests SET feature_id = ? WHERE id = ?")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("preparing update feature_id: %w", err)
|
||||
}
|
||||
defer updateFeatureID.Close()
|
||||
|
||||
// Process each module's tests
|
||||
for _, mod := range result.Modules {
|
||||
for _, test := range mod.Tests {
|
||||
stats.TestsAnalyzed++
|
||||
|
||||
testDBID, ok := testDBIDs[mod.Name+":"+test.GoMethod]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Insert dependency rows for linked features
|
||||
if len(test.LinkedFeatures) > 0 {
|
||||
stats.TestsLinked++
|
||||
}
|
||||
for _, fi := range test.LinkedFeatures {
|
||||
feat := mod.Features[fi]
|
||||
featDBID, ok := featureDBIDs[featureKey{mod.Name, feat.GoMethod, feat.GoClass}]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, err := insertDep.Exec(testDBID, featDBID); err != nil {
|
||||
return nil, fmt.Errorf("inserting dependency for test %s: %w", test.GoMethod, err)
|
||||
}
|
||||
stats.DependencyRows++
|
||||
}
|
||||
|
||||
// Set feature_id for best match
|
||||
if test.BestFeatureIdx >= 0 {
|
||||
feat := mod.Features[test.BestFeatureIdx]
|
||||
featDBID, ok := featureDBIDs[featureKey{mod.Name, feat.GoMethod, feat.GoClass}]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, err := updateFeatureID.Exec(featDBID, testDBID); err != nil {
|
||||
return nil, fmt.Errorf("updating feature_id for test %s: %w", test.GoMethod, err)
|
||||
}
|
||||
stats.FeatureIDsSet++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("committing transaction: %w", err)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
@@ -58,6 +58,28 @@ type TestFunc struct {
|
||||
GoLineCount int
|
||||
// FeatureName links this test to a feature by naming convention
|
||||
FeatureName string
|
||||
// Calls holds raw function/method calls extracted from the test body AST
|
||||
Calls []CallInfo
|
||||
// LinkedFeatures holds indices into the parent module's Features slice
|
||||
LinkedFeatures []int
|
||||
// BestFeatureIdx is the primary feature match index (-1 = none)
|
||||
BestFeatureIdx int
|
||||
}
|
||||
|
||||
// CallInfo represents a function or method call extracted from a test body.
|
||||
type CallInfo struct {
|
||||
FuncName string // direct call name: "newMemStore"
|
||||
RecvOrPkg string // selector receiver/pkg: "ms", "fmt", "t"
|
||||
MethodName string // selector method: "StoreMsg", "Fatalf"
|
||||
IsSelector bool // true for X.Y() form
|
||||
}
|
||||
|
||||
// callKey returns a deduplication key for this call.
|
||||
func (c CallInfo) callKey() string {
|
||||
if c.IsSelector {
|
||||
return c.RecvOrPkg + "." + c.MethodName
|
||||
}
|
||||
return c.FuncName
|
||||
}
|
||||
|
||||
// Dependency represents a call relationship between two items.
|
||||
|
||||
Reference in New Issue
Block a user