Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

261 lines
7.9 KiB
Go

package analysis
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
)
// SourceReader provides access to symbol source code for scaffolding.
// ResolveFilePath maps a graph file path (repo-prefixed in multi-repo mode,
// repo-relative in single-repo mode) to an absolute filesystem path. It
// returns "" when the path can't be anchored to any indexed repo, which
// callers must surface as an error rather than silently opening a file
// relative to the daemon's process CWD.
//
// This interface avoids a circular dependency with the indexer package.
type SourceReader interface {
Graph() graph.Store
ResolveFilePath(graphPath string) string
}
// ScaffoldEdit describes a single code insertion for scaffolding.
type ScaffoldEdit struct {
FilePath string `json:"file_path"`
InsertionLine int `json:"insertion_line"`
Code string `json:"code"`
Reason string `json:"reason"` // "new_symbol" | "registration" | "test_stub"
}
// ScaffoldResult is the output of code scaffolding generation.
type ScaffoldResult struct {
Edits []ScaffoldEdit `json:"edits"`
Notes []string `json:"notes,omitempty"`
}
// GenerateScaffold generates code scaffolding based on an existing symbol pattern.
// It reads the example's source, substitutes the new name, generates registration
// code from depth-1 callers, and creates a test stub if a test pattern exists.
func GenerateScaffold(engine *query.Engine, reader SourceReader, exampleID, newName string) (*ScaffoldResult, error) {
g := reader.Graph()
// Look up the example symbol
node := g.GetNode(exampleID)
if node == nil {
return nil, fmt.Errorf("symbol not found: %s", exampleID)
}
if node.StartLine <= 0 || node.EndLine <= 0 {
return nil, fmt.Errorf("symbol has no line range: %s", exampleID)
}
// Step 2: Read the example's source code from disk. Resolve via the
// SourceReader so multi-repo paths anchor to the correct repo root
// rather than the daemon process CWD.
absPath := reader.ResolveFilePath(node.FilePath)
if absPath == "" {
return nil, fmt.Errorf("could not resolve abs path for %s", node.FilePath)
}
source, err := readSourceLines(absPath, node.StartLine, node.EndLine)
if err != nil {
return nil, fmt.Errorf("failed to read source for %s: %w", exampleID, err)
}
result := &ScaffoldResult{}
// Perform name substitution
substituted := substituteSymbolName(source, node.Name, newName)
// Add the new symbol edit — insert after the example symbol
result.Edits = append(result.Edits, ScaffoldEdit{
FilePath: node.FilePath,
InsertionLine: node.EndLine + 1,
Code: substituted,
Reason: "new_symbol",
})
// Find callers at depth 1 for registration code
callerSG := engine.GetCallers(exampleID, query.QueryOptions{Depth: 1, Limit: 50})
callerNodes := filterCallerNodes(callerSG, exampleID)
if len(callerNodes) > 0 {
regEdit := generateRegistrationCode(g, callerNodes, node, newName)
if regEdit != nil {
result.Edits = append(result.Edits, *regEdit)
}
} else {
result.Notes = append(result.Notes, "no callers found at depth 1; registration/wiring code omitted")
}
// Look for test pattern
testEdit := generateTestStub(g, reader, node, newName)
if testEdit != nil {
result.Edits = append(result.Edits, *testEdit)
} else {
result.Notes = append(result.Notes, "no test pattern found for example symbol; test stub omitted")
}
return result, nil
}
// readSourceLines reads lines [startLine, endLine] (1-indexed) from an
// absolute file path.
func readSourceLines(absPath string, startLine, endLine int) (string, error) {
data, err := os.ReadFile(absPath)
if err != nil {
return "", err
}
lines := strings.Split(string(data), "\n")
if startLine < 1 {
startLine = 1
}
if endLine > len(lines) {
endLine = len(lines)
}
if startLine > len(lines) {
return "", fmt.Errorf("start line %d exceeds file length %d", startLine, len(lines))
}
selected := lines[startLine-1 : endLine]
return strings.Join(selected, "\n"), nil
}
// substituteSymbolName replaces whole-word occurrences of oldName with newName in source code.
// Uses word-boundary matching to avoid corrupting identifiers that contain oldName
// as a substring (e.g., replacing "Extract" should not affect "GoExtractor").
func substituteSymbolName(source, oldName, newName string) string {
pattern := `\b` + regexp.QuoteMeta(oldName) + `\b`
re := regexp.MustCompile(pattern)
return re.ReplaceAllString(source, newName)
}
// filterCallerNodes returns caller nodes from a SubGraph, excluding the example itself.
func filterCallerNodes(sg *query.SubGraph, exampleID string) []*graph.Node {
var callers []*graph.Node
for _, n := range sg.Nodes {
if n.ID == exampleID {
continue
}
if n.Kind == graph.KindFile || n.Kind == graph.KindImport {
continue
}
callers = append(callers, n)
}
return callers
}
// generateRegistrationCode creates a registration/wiring edit by analyzing how
// the example symbol is called by its depth-1 callers.
func generateRegistrationCode(g graph.Store, callers []*graph.Node, example *graph.Node, newName string) *ScaffoldEdit {
if len(callers) == 0 {
return nil
}
// Use the first caller as the registration pattern.
// Read the caller's call edges to find how it references the example.
caller := callers[0]
// Find the specific call edge from caller to example
var callLine int
outEdges := g.GetOutEdges(caller.ID)
for _, e := range outEdges {
if e.To == example.ID && e.Kind == graph.EdgeCalls {
callLine = e.Line
break
}
}
// Generate a registration line that mirrors the call pattern
regCode := fmt.Sprintf("// TODO: Register %s following the pattern of %s\n// See %s at line %d for the registration pattern",
newName, example.Name, caller.FilePath, callLine)
insertionLine := caller.EndLine
if callLine > 0 {
insertionLine = callLine + 1
}
return &ScaffoldEdit{
FilePath: caller.FilePath,
InsertionLine: insertionLine,
Code: regCode,
Reason: "registration",
}
}
// generateTestStub creates a test stub edit by finding the test file and test
// functions associated with the example symbol.
func generateTestStub(g graph.Store, reader SourceReader, example *graph.Node, newName string) *ScaffoldEdit {
testFilePath := deriveTestFilePath(example.FilePath)
// Check if the test file exists on disk. Resolve abs path through
// the reader so multi-repo paths anchor to the correct root.
absTestPath := reader.ResolveFilePath(testFilePath)
if absTestPath == "" {
return nil
}
if _, err := os.Stat(absTestPath); err != nil {
return nil
}
// Look for test functions in the test file that reference the example
testNodes := g.GetFileNodes(testFilePath)
var testExample *graph.Node
for _, n := range testNodes {
if n.Kind != graph.KindFunction {
continue
}
// Match test functions that contain the example's name
if strings.Contains(n.Name, example.Name) {
testExample = n
break
}
}
if testExample == nil {
return nil
}
// Read the test function source and substitute the name
testSource, err := readSourceLines(absTestPath, testExample.StartLine, testExample.EndLine)
if err != nil {
return nil
}
substituted := substituteSymbolName(testSource, example.Name, newName)
return &ScaffoldEdit{
FilePath: testFilePath,
InsertionLine: testExample.EndLine + 1,
Code: substituted,
Reason: "test_stub",
}
}
// deriveTestFilePath converts a source file path to its corresponding test file path.
// e.g., "internal/analysis/foo.go" → "internal/analysis/foo_test.go"
func deriveTestFilePath(filePath string) string {
ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext)
switch ext {
case ".go":
return base + "_test.go"
case ".ts":
return base + ".test.ts"
case ".js":
return base + ".test.js"
case ".py":
dir := filepath.Dir(filePath)
name := filepath.Base(filePath)
return filepath.Join(dir, "test_"+name)
default:
return base + "_test" + ext
}
}