Skip to content

Commit 3d65fad

Browse files
shivasuryaclaude
andauthored
cpf/enhancement: Implement call graph builder - Pass 3 (#327)
* feat: Add core data structures for call graph (PR #1) Add foundational data structures for Python call graph construction: New Types: - CallSite: Represents function call locations with arguments and resolution status - CallGraph: Maps functions to callees with forward/reverse edges - ModuleRegistry: Maps Python file paths to module paths - ImportMap: Tracks imports per file for name resolution - Location: Source code position tracking - Argument: Function call argument metadata Features: - 100% test coverage with comprehensive unit tests - Bidirectional call graph edges (forward and reverse) - Support for ambiguous short names in module registry - Helper functions for module path manipulation This establishes the foundation for 3-pass call graph algorithm: - Pass 1 (next PR): Module registry builder - Pass 2 (next PR): Import extraction and resolution - Pass 3 (next PR): Call graph construction Related: Phase 1 - Call Graph Construction & 3-Pass Algorithm 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: Implement module registry - Pass 1 of 3-pass algorithm (PR #2) Implement the first pass of the call graph construction algorithm: building a complete registry of Python modules by walking the directory tree. New Features: - BuildModuleRegistry: Walks directory tree and maps file paths to module paths - convertToModulePath: Converts file system paths to Python import paths - shouldSkipDirectory: Filters out venv, __pycache__, build dirs, etc. Module Path Conversion: - Handles regular files: myapp/views.py → myapp.views - Handles packages: myapp/utils/__init__.py → myapp.utils - Supports deep nesting: myapp/api/v1/endpoints/users.py → myapp.api.v1.endpoints.users - Cross-platform: Normalizes Windows/Unix path separators Performance Optimizations: - Skips 15+ common non-source directories (venv, __pycache__, .git, dist, build, etc.) - Avoids scanning thousands of dependency files - Indexes both full module paths and short names for ambiguity detection Test Coverage: 93% - Comprehensive unit tests for all conversion scenarios - Integration tests with real Python project structure - Edge case handling: empty dirs, non-Python files, deep nesting, permissions - Error path testing: walk errors, invalid paths, system errors - Test fixtures: test-src/python/simple_project/ with realistic structure - Documented: Remaining 7% are untestable OS-level errors (filepath.Abs failures) This establishes Pass 1 of 3: - ✅ Pass 1: Module registry (this PR) - Next: Pass 2 - Import extraction and resolution - Next: Pass 3 - Call graph construction Related: Phase 1 - Call Graph Construction & 3-Pass Algorithm Base Branch: shiva/callgraph-infra-1 (PR #1) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: Implement import extraction with tree-sitter - Pass 2 Part A This PR implements comprehensive import extraction for Python code using tree-sitter AST parsing. It handles all three main import styles: 1. Simple imports: `import module` 2. From imports: `from module import name` 3. Aliased imports: `import module as alias` and `from module import name as alias` The implementation uses direct AST traversal instead of tree-sitter queries for better compatibility and control. It properly handles: - Multiple imports per line (`from json import dumps, loads`) - Nested module paths (`import xml.etree.ElementTree`) - Whitespace variations - Invalid/malformed syntax (fault-tolerant parsing) Key functions: - ExtractImports(): Main entry point that parses code and builds ImportMap - traverseForImports(): Recursively traverses AST to find import statements - processImportStatement(): Handles simple and aliased imports - processImportFromStatement(): Handles from-import statements with proper module name skipping to avoid duplicate entries Test coverage: 92.8% overall, 90-95% for import extraction functions Test fixtures include: - simple_imports.py: Basic import statements - from_imports.py: From import statements with multiple names - aliased_imports.py: Aliased imports (both simple and from) - mixed_imports.py: Mixed import styles All tests passing, linting clean, builds successfully. This is Pass 2 Part A of the 3-pass call graph algorithm. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: Implement relative import resolution - Pass 2 Part B This PR implements comprehensive relative import resolution for Python using a 3-pass algorithm. It extends the import extraction system from PR #3 to handle Python's relative import syntax with dot notation. Key Changes: 1. **Added FileToModule reverse mapping to ModuleRegistry** - Enables O(1) lookup from file path to module path - Required for resolving relative imports - Updated AddModule() to maintain bidirectional mapping 2. **Implemented resolveRelativeImport() function** - Handles single dot (.) for current package - Handles multiple dots (.., ...) for parent/grandparent packages - Navigates package hierarchy using module path components - Clamps excessive dots to root package level - Falls back gracefully when file not in registry 3. **Enhanced processImportFromStatement() for relative imports** - Detects relative_import nodes in tree-sitter AST - Extracts import_prefix (dots) and optional module suffix - Resolves relative paths to absolute module paths before adding to ImportMap 4. **Comprehensive test coverage (94.5% overall)** - Unit tests for resolveRelativeImport with various dot counts - Integration tests with ExtractImports - Tests for deeply nested packages - Tests for mixed absolute and relative imports - Real fixture files with project structure Relative Import Examples: - `from . import utils` → "currentpackage.utils" - `from .. import config` → "parentpackage.config" - `from ..utils import helper` → "parentpackage.utils.helper" - `from ...db import query` → "grandparent.db.query" Test Fixtures: - Created myapp/submodule/handler.py with all relative import styles - Created supporting package structure with __init__.py files - Tests verify correct resolution across package hierarchy All tests passing, linting clean, builds successfully. This is Pass 2 Part B of the 3-pass call graph algorithm. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: Implement call site extraction from AST - Pass 2 Part C This PR implements call site extraction from Python source code using tree-sitter AST parsing. It builds on the import resolution work from PRs #3 and #4 to prepare for call graph construction in Pass 3. ## Changes ### Core Implementation (callsites.go) 1. **ExtractCallSites()**: Main entry point for extracting call sites - Parses Python source with tree-sitter - Traverses AST to find all call expressions - Returns slice of CallSite objects with location information 2. **traverseForCalls()**: Recursive AST traversal - Tracks function context while traversing - Updates context when entering function definitions - Finds and processes call expressions 3. **processCallExpression()**: Call site processing - Extracts callee name (function/method being called) - Parses arguments (positional and keyword) - Creates CallSite with source location - Parameters for importMap and caller reserved for Pass 3 4. **extractCalleeName()**: Callee name extraction - Handles simple identifiers: foo() - Handles attributes: obj.method(), obj.attr.method() - Recursively builds dotted names 5. **extractArguments()**: Argument parsing - Extracts all positional arguments - Preserves keyword arguments as "name=value" in Value field - Tracks argument position and variable status 6. **convertArgumentsToSlice()**: Helper for struct conversion - Converts []*Argument to []Argument for CallSite struct ### Comprehensive Tests (callsites_test.go) Created 17 test functions covering: - Simple function calls: foo(), bar() - Method calls: obj.method(), self.helper() - Arguments: positional, keyword, mixed - Nested calls: foo(bar(x)) - Multiple functions in one file - Class methods - Chained calls: obj.method1().method2() - Module-level calls (no function context) - Source location tracking - Empty files - Complex arguments: expressions, lists, dicts, lambdas - Nested method calls: obj.attr.method() - Real file fixture integration ### Test Fixture (simple_calls.py) Created realistic test file with: - Function definitions with various call patterns - Method calls on objects - Calls with arguments (positional and keyword) - Nested calls - Class methods with self references ## Test Coverage - Overall: 93.3% - ExtractCallSites: 90.0% - traverseForCalls: 93.3% - processCallExpression: 83.3% - extractCalleeName: 91.7% - extractArguments: 87.5% - convertArgumentsToSlice: 100.0% ## Design Decisions 1. **Keyword argument handling**: Store as "name=value" in Value field - Tree-sitter provides full keyword_argument node content - Preserves complete argument information for later analysis - Separating name/value would require additional parsing 2. **Caller context tracking**: Parameter reserved but not used yet - Will be populated in Pass 3 during call graph construction - Enables linking call sites to their containing functions 3. **Import map parameter**: Reserved for Pass 3 resolution - Will be used to resolve qualified names to FQNs - Enables cross-file call graph construction 4. **Location tracking**: Store exact position for each call site - File, line, column information - Enables precise error reporting and code navigation ## Testing Strategy - Unit tests for each extraction function - Integration tests with tree-sitter AST - Real file fixture for end-to-end validation - Edge cases: empty files, no context, nested structures ## Next Steps (PR #6) Pass 3 will use this call site data to: 1. Build the complete call graph structure 2. Resolve call targets to function definitions 3. Link caller and callee through edges 4. Handle disambiguation for overloaded names 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: Implement call graph builder - Pass 3 This PR completes the 3-pass algorithm for building Python call graphs by implementing the final pass that resolves call targets and constructs the complete graph structure with edges linking callers to callees. ## Changes ### Core Implementation (builder.go) 1. **BuildCallGraph()**: Main entry point for Pass 3 - Indexes all function definitions from code graph - Iterates through all Python files in the registry - Extracts imports and call sites for each file - Resolves each call site to its target function - Builds edges and stores call site details - Returns complete CallGraph with all relationships 2. **indexFunctions()**: Function indexing - Scans code graph for all function/method definitions - Maps each function to its FQN using module registry - Populates CallGraph.Functions map for quick lookup 3. **getFunctionsInFile()**: File-scoped function retrieval - Filters code graph nodes by file path - Returns only function/method definitions in that file - Used for finding containing functions of call sites 4. **findContainingFunction()**: Call site parent resolution - Determines which function contains a given call site - Uses line number comparison with nearest-match algorithm - Finds function with highest line number ≤ call line - Returns empty string for module-level calls 5. **resolveCallTarget()**: Core resolution logic - Handles simple names: sanitize() → myapp.utils.sanitize - Handles qualified names: utils.sanitize() → myapp.utils.sanitize - Resolves through import maps first - Falls back to same-module resolution - Validates FQNs against module registry - Returns (FQN, resolved bool) tuple 6. **validateFQN()**: FQN validation - Checks if a fully qualified name exists in registry - Handles both modules and functions within modules - Validates parent module for function FQNs 7. **readFileBytes()**: File reading helper - Reads source files for parsing - Handles absolute path conversion ### Comprehensive Tests (builder_test.go) Created 15 test functions covering: **Resolution Tests:** - Simple imported function resolution - Qualified import resolution (module.function) - Same-module function resolution - Unresolved method calls (obj.method) - Non-existent function handling **Validation Tests:** - Module existence validation - Function-in-module validation - Non-existent module handling **Helper Function Tests:** - Function indexing from code graph - Functions-in-file filtering - Containing function detection with edge cases **Integration Tests:** - Simple single-file call graph - Multi-file call graph with imports - Real test fixture integration ## Test Coverage - Overall: 91.8% - BuildCallGraph: 80.8% - indexFunctions: 87.5% - getFunctionsInFile: 100.0% - findContainingFunction: 100.0% - resolveCallTarget: 85.0% - validateFQN: 100.0% - readFileBytes: 75.0% ## Algorithm Overview Pass 3 ties together all previous work: ### Pass 1 (PR #2): BuildModuleRegistry - Maps file paths to module paths - Enables FQN generation ### Pass 2 (PRs #3-5): Import & Call Site Extraction - ExtractImports: Maps local names to FQNs - ExtractCallSites: Finds all function calls in AST ### Pass 3 (This PR): Call Graph Construction - Resolves call targets using import maps - Links callers to callees with edges - Validates resolutions against registry - Stores detailed call site information ## Resolution Strategy The resolver uses a multi-step approach: 1. **Simple names** (no dots): - Check import map first - Fall back to same-module lookup - Return unresolved if neither works 2. **Qualified names** (with dots): - Split into base + rest - Resolve base through imports - Append rest to get full FQN - Try current module if not imported 3. **Validation**: - Check if target exists in registry - For functions, validate parent module exists - Mark resolution success/failure ## Design Decisions 1. **Containing function detection**: - Uses nearest-match algorithm based on line numbers - Finds function with highest line number ≤ call line - Handles module-level calls by returning empty FQN 2. **Resolution priority**: - Import map takes precedence over same-module - Explicit imports always respected even if unresolved - Same-module only tried when not in imports 3. **Validation vs Resolution**: - Resolution finds FQN from imports/context - Validation checks if FQN exists in registry - Both pieces of information stored in CallSite 4. **Error handling**: - Continues processing even if some files fail - Marks individual call sites as unresolved - Returns partial graph instead of failing completely ## Next Steps The call graph infrastructure is now complete. Future PRs will: - PR #7: Add CFG data structures for control flow analysis - PR #8: Implement pattern matching for security rules - PR #9: Integrate into main initialization pipeline - PR #10: Add comprehensive documentation and examples - PR #11: Performance optimizations (caching, pooling) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5bc564e commit 3d65fad

File tree

2 files changed

+770
-0
lines changed

2 files changed

+770
-0
lines changed
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
package callgraph
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
8+
"github.com/shivasurya/code-pathfinder/sourcecode-parser/graph"
9+
)
10+
11+
// BuildCallGraph constructs the complete call graph for a Python project.
12+
// This is Pass 3 of the 3-pass algorithm:
13+
// - Pass 1: BuildModuleRegistry - map files to modules
14+
// - Pass 2: ExtractImports + ExtractCallSites - parse imports and calls
15+
// - Pass 3: BuildCallGraph - resolve calls and build graph
16+
//
17+
// Algorithm:
18+
// 1. For each Python file in the project:
19+
// a. Extract imports to build ImportMap
20+
// b. Extract call sites from AST
21+
// c. Extract function definitions from main graph
22+
// 2. For each call site:
23+
// a. Resolve target name using ImportMap
24+
// b. Find target function definition in registry
25+
// c. Add edge from caller to callee
26+
// d. Store detailed call site information
27+
//
28+
// Parameters:
29+
// - codeGraph: the existing code graph with parsed AST nodes
30+
// - registry: module registry mapping files to modules
31+
// - projectRoot: absolute path to project root
32+
//
33+
// Returns:
34+
// - CallGraph: complete call graph with edges and call sites
35+
// - error: if any step fails
36+
//
37+
// Example:
38+
// Given:
39+
// File: myapp/views.py
40+
// def get_user():
41+
// sanitize(data) # call to myapp.utils.sanitize
42+
//
43+
// Creates:
44+
// edges: {"myapp.views.get_user": ["myapp.utils.sanitize"]}
45+
// reverseEdges: {"myapp.utils.sanitize": ["myapp.views.get_user"]}
46+
// callSites: {"myapp.views.get_user": [CallSite{Target: "sanitize", ...}]}
47+
func BuildCallGraph(codeGraph *graph.CodeGraph, registry *ModuleRegistry, projectRoot string) (*CallGraph, error) {
48+
callGraph := NewCallGraph()
49+
50+
// First, index all function definitions from the code graph
51+
// This builds the Functions map for quick lookup
52+
indexFunctions(codeGraph, callGraph, registry)
53+
54+
// Process each Python file in the project
55+
for modulePath, filePath := range registry.Modules {
56+
// Skip non-Python files
57+
if !strings.HasSuffix(filePath, ".py") {
58+
continue
59+
}
60+
61+
// Read source code for parsing
62+
sourceCode, err := readFileBytes(filePath)
63+
if err != nil {
64+
// Skip files we can't read
65+
continue
66+
}
67+
68+
// Extract imports to build ImportMap for this file
69+
importMap, err := ExtractImports(filePath, sourceCode, registry)
70+
if err != nil {
71+
// Skip files with import errors
72+
continue
73+
}
74+
75+
// Extract all call sites from this file
76+
callSites, err := ExtractCallSites(filePath, sourceCode, importMap)
77+
if err != nil {
78+
// Skip files with call site extraction errors
79+
continue
80+
}
81+
82+
// Get all function definitions in this file
83+
fileFunctions := getFunctionsInFile(codeGraph, filePath)
84+
85+
// Process each call site to resolve targets and build edges
86+
for _, callSite := range callSites {
87+
// Find the caller function containing this call site
88+
callerFQN := findContainingFunction(callSite.Location, fileFunctions, modulePath)
89+
if callerFQN == "" {
90+
// Call at module level - use module name as caller
91+
callerFQN = modulePath
92+
}
93+
94+
// Resolve the call target to a fully qualified name
95+
targetFQN, resolved := resolveCallTarget(callSite.Target, importMap, registry, modulePath)
96+
97+
// Update call site with resolution information
98+
callSite.TargetFQN = targetFQN
99+
callSite.Resolved = resolved
100+
101+
// Add call site to graph (dereference pointer)
102+
callGraph.AddCallSite(callerFQN, *callSite)
103+
104+
// Add edge if we successfully resolved the target
105+
if resolved {
106+
callGraph.AddEdge(callerFQN, targetFQN)
107+
}
108+
}
109+
}
110+
111+
return callGraph, nil
112+
}
113+
114+
// indexFunctions builds the Functions map in the call graph.
115+
// Extracts all function definitions from the code graph and maps them by FQN.
116+
//
117+
// Parameters:
118+
// - codeGraph: the parsed code graph
119+
// - callGraph: the call graph being built
120+
// - registry: module registry for resolving file paths to modules
121+
func indexFunctions(codeGraph *graph.CodeGraph, callGraph *CallGraph, registry *ModuleRegistry) {
122+
for _, node := range codeGraph.Nodes {
123+
// Only index function/method definitions
124+
if node.Type != "method_declaration" && node.Type != "function_definition" {
125+
continue
126+
}
127+
128+
// Get the module path for this function's file
129+
modulePath, ok := registry.FileToModule[node.File]
130+
if !ok {
131+
continue
132+
}
133+
134+
// Build fully qualified name: module.function
135+
fqn := modulePath + "." + node.Name
136+
callGraph.Functions[fqn] = node
137+
}
138+
}
139+
140+
// getFunctionsInFile returns all function definitions in a specific file.
141+
//
142+
// Parameters:
143+
// - codeGraph: the parsed code graph
144+
// - filePath: absolute path to the file
145+
//
146+
// Returns:
147+
// - List of function/method nodes in the file, sorted by line number
148+
func getFunctionsInFile(codeGraph *graph.CodeGraph, filePath string) []*graph.Node {
149+
var functions []*graph.Node
150+
151+
for _, node := range codeGraph.Nodes {
152+
if node.File == filePath &&
153+
(node.Type == "method_declaration" || node.Type == "function_definition") {
154+
functions = append(functions, node)
155+
}
156+
}
157+
158+
return functions
159+
}
160+
161+
// findContainingFunction finds the function that contains a given call site location.
162+
// Uses line numbers to determine which function a call belongs to.
163+
//
164+
// Algorithm:
165+
// 1. Iterate through all functions in the file
166+
// 2. Find function with the highest line number that's still <= call line
167+
// 3. Return the FQN of that function
168+
//
169+
// Parameters:
170+
// - location: source location of the call site
171+
// - functions: all function definitions in the file
172+
// - modulePath: module path of the file
173+
//
174+
// Returns:
175+
// - Fully qualified name of the containing function, or empty if not found
176+
func findContainingFunction(location Location, functions []*graph.Node, modulePath string) string {
177+
var bestMatch *graph.Node
178+
var bestLine uint32
179+
180+
for _, fn := range functions {
181+
// Check if call site is after this function definition
182+
if uint32(location.Line) >= fn.LineNumber {
183+
// Keep track of the closest preceding function
184+
if bestMatch == nil || fn.LineNumber > bestLine {
185+
bestMatch = fn
186+
bestLine = fn.LineNumber
187+
}
188+
}
189+
}
190+
191+
if bestMatch != nil {
192+
return modulePath + "." + bestMatch.Name
193+
}
194+
195+
return ""
196+
}
197+
198+
// resolveCallTarget resolves a call target name to a fully qualified name.
199+
// This is the core resolution logic that handles:
200+
// - Direct function calls: sanitize() → myapp.utils.sanitize
201+
// - Method calls: obj.method() → (unresolved, needs type inference)
202+
// - Imported functions: from utils import sanitize; sanitize() → myapp.utils.sanitize
203+
// - Qualified calls: utils.sanitize() → myapp.utils.sanitize
204+
//
205+
// Algorithm:
206+
// 1. Check if target is a simple name (no dots)
207+
// a. Look up in import map
208+
// b. If found, return FQN from import
209+
// c. If not found, try to find in same module
210+
// 2. If target has dots (qualified name)
211+
// a. Split into base and rest
212+
// b. Resolve base using import map
213+
// c. Append rest to get full FQN
214+
// 3. If all else fails, check if it exists in the registry
215+
//
216+
// Parameters:
217+
// - target: the call target name (e.g., "sanitize", "utils.sanitize", "obj.method")
218+
// - importMap: import mappings for the current file
219+
// - registry: module registry for validation
220+
// - currentModule: the module containing this call
221+
//
222+
// Returns:
223+
// - Fully qualified name of the target
224+
// - Boolean indicating if resolution was successful
225+
//
226+
// Examples:
227+
// target="sanitize", imports={"sanitize": "myapp.utils.sanitize"}
228+
// → "myapp.utils.sanitize", true
229+
//
230+
// target="utils.sanitize", imports={"utils": "myapp.utils"}
231+
// → "myapp.utils.sanitize", true
232+
//
233+
// target="obj.method", imports={}
234+
// → "obj.method", false (needs type inference)
235+
func resolveCallTarget(target string, importMap *ImportMap, registry *ModuleRegistry, currentModule string) (string, bool) {
236+
// Handle simple names (no dots)
237+
if !strings.Contains(target, ".") {
238+
// Try to resolve through imports
239+
if fqn, ok := importMap.Resolve(target); ok {
240+
// Found in imports - return the FQN
241+
// Validate if it exists in registry
242+
resolved := validateFQN(fqn, registry)
243+
return fqn, resolved
244+
}
245+
246+
// Not in imports - might be in same module
247+
sameLevelFQN := currentModule + "." + target
248+
if validateFQN(sameLevelFQN, registry) {
249+
return sameLevelFQN, true
250+
}
251+
252+
// Can't resolve - return as-is
253+
return target, false
254+
}
255+
256+
// Handle qualified names (with dots)
257+
parts := strings.SplitN(target, ".", 2)
258+
base := parts[0]
259+
rest := parts[1]
260+
261+
// Try to resolve base through imports
262+
if baseFQN, ok := importMap.Resolve(base); ok {
263+
fullFQN := baseFQN + "." + rest
264+
if validateFQN(fullFQN, registry) {
265+
return fullFQN, true
266+
}
267+
return fullFQN, false
268+
}
269+
270+
// Base not in imports - might be module-level access
271+
// Try current module
272+
fullFQN := currentModule + "." + target
273+
if validateFQN(fullFQN, registry) {
274+
return fullFQN, true
275+
}
276+
277+
// Can't resolve - return as-is
278+
return target, false
279+
}
280+
281+
// validateFQN checks if a fully qualified name exists in the registry.
282+
// Handles both module names and function names within modules.
283+
//
284+
// Examples:
285+
// "myapp.utils" - checks if module exists
286+
// "myapp.utils.sanitize" - checks if module "myapp.utils" exists
287+
//
288+
// Parameters:
289+
// - fqn: fully qualified name to validate
290+
// - registry: module registry
291+
//
292+
// Returns:
293+
// - true if FQN is valid (module or function in existing module)
294+
func validateFQN(fqn string, registry *ModuleRegistry) bool {
295+
// Check if it's a module
296+
if _, ok := registry.Modules[fqn]; ok {
297+
return true
298+
}
299+
300+
// Check if parent module exists (for functions)
301+
// "myapp.utils.sanitize" → check if "myapp.utils" exists
302+
lastDot := strings.LastIndex(fqn, ".")
303+
if lastDot > 0 {
304+
parentModule := fqn[:lastDot]
305+
if _, ok := registry.Modules[parentModule]; ok {
306+
return true
307+
}
308+
}
309+
310+
return false
311+
}
312+
313+
// readFileBytes reads a file and returns its contents as a byte slice.
314+
// Helper function for reading source code.
315+
func readFileBytes(filePath string) ([]byte, error) {
316+
absPath, err := filepath.Abs(filePath)
317+
if err != nil {
318+
return nil, err
319+
}
320+
return os.ReadFile(absPath)
321+
}

0 commit comments

Comments
 (0)