mirror of
https://github.com/imjasonh/slight
synced 2026-07-07 00:33:20 +00:00
debloat command
This commit is contained in:
parent
c13bd1ca36
commit
3ea5a53de3
4 changed files with 848 additions and 16 deletions
|
|
@ -6,6 +6,7 @@ import (
|
|||
"os"
|
||||
|
||||
"github.com/imjasonh/slight/pkg/analysis"
|
||||
"github.com/imjasonh/slight/pkg/debloat"
|
||||
"github.com/imjasonh/slight/pkg/report"
|
||||
)
|
||||
|
||||
|
|
@ -21,6 +22,11 @@ func main() {
|
|||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "debloat":
|
||||
if err := runDebloat(os.Args[2:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "help", "-h", "--help":
|
||||
printUsage()
|
||||
default:
|
||||
|
|
@ -38,6 +44,7 @@ Usage:
|
|||
|
||||
Commands:
|
||||
analyze Analyze vendored dependencies for unused symbols
|
||||
debloat Remove unused code from vendored dependencies
|
||||
|
||||
Run 'slight <command> -h' for more information about a command.
|
||||
`)
|
||||
|
|
@ -88,3 +95,100 @@ Examples:
|
|||
}
|
||||
return report.WriteText(os.Stdout, result, *unusedOnly, *verbose)
|
||||
}
|
||||
|
||||
func runDebloat(args []string) error {
|
||||
fs := flag.NewFlagSet("debloat", flag.ExitOnError)
|
||||
dryRun := fs.Bool("dry-run", false, "Show what would be removed without making changes")
|
||||
verbose := fs.Bool("v", false, "Verbose output")
|
||||
backup := fs.Bool("backup", false, "Create backup of vendor/ before modification")
|
||||
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, `Remove unused code from vendored dependencies.
|
||||
|
||||
Usage:
|
||||
slight debloat [flags] [path]
|
||||
|
||||
Flags:
|
||||
`)
|
||||
fs.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, `
|
||||
Examples:
|
||||
slight debloat .
|
||||
slight debloat -dry-run ./path/to/module
|
||||
`)
|
||||
}
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := "."
|
||||
if fs.NArg() > 0 {
|
||||
path = fs.Arg(0)
|
||||
}
|
||||
|
||||
// Run debloat
|
||||
result, err := debloat.Debloat(debloat.Options{
|
||||
Dir: path,
|
||||
DryRun: *dryRun,
|
||||
Verbose: *verbose,
|
||||
Backup: *backup,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Print result
|
||||
printDebloatResult(result, *verbose)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printDebloatResult(result *debloat.Result, verbose bool) {
|
||||
if result.DryRun {
|
||||
fmt.Println("Dry run - no changes made")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Print warnings
|
||||
for _, w := range result.Warnings {
|
||||
fmt.Printf("Warning: %s\n", w)
|
||||
}
|
||||
if len(result.Warnings) > 0 {
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if len(result.FilesRemoved) > 0 {
|
||||
if result.DryRun {
|
||||
fmt.Printf("Files to remove (%d):\n", len(result.FilesRemoved))
|
||||
} else {
|
||||
fmt.Printf("Files removed (%d):\n", len(result.FilesRemoved))
|
||||
}
|
||||
if verbose {
|
||||
for _, f := range result.FilesRemoved {
|
||||
fmt.Printf(" %s\n", f)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if len(result.FilesModified) > 0 {
|
||||
if result.DryRun {
|
||||
fmt.Printf("Files to modify (%d):\n", len(result.FilesModified))
|
||||
} else {
|
||||
fmt.Printf("Files modified (%d):\n", len(result.FilesModified))
|
||||
}
|
||||
if verbose {
|
||||
for _, f := range result.FilesModified {
|
||||
fmt.Printf(" %s\n", f)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if result.DryRun {
|
||||
fmt.Printf("Total symbols to remove: %d\n", result.SymbolsRemoved)
|
||||
} else {
|
||||
fmt.Printf("Total symbols removed: %d\n", result.SymbolsRemoved)
|
||||
fmt.Println("Compilation verified successfully")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
420
pkg/debloat/debloat.go
Normal file
420
pkg/debloat/debloat.go
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
// Package debloat removes unused code from vendored dependencies.
|
||||
package debloat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/printer"
|
||||
"go/token"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/imjasonh/slight/pkg/analysis"
|
||||
"github.com/imjasonh/slight/pkg/report"
|
||||
)
|
||||
|
||||
// Options configures the debloating operation.
|
||||
type Options struct {
|
||||
// Dir is the directory to debloat (defaults to ".")
|
||||
Dir string
|
||||
// DryRun shows what would be removed without making changes
|
||||
DryRun bool
|
||||
// Verbose enables verbose output
|
||||
Verbose bool
|
||||
// Backup creates a backup of vendor/ before modification
|
||||
Backup bool
|
||||
}
|
||||
|
||||
// Result contains the results of a debloat operation.
|
||||
type Result struct {
|
||||
// FilesRemoved is a list of files that were completely removed
|
||||
FilesRemoved []string
|
||||
// FilesModified is a list of files that had symbols removed
|
||||
FilesModified []string
|
||||
// SymbolsRemoved is the count of symbols removed
|
||||
SymbolsRemoved int
|
||||
// Warnings contains any warnings generated during debloating
|
||||
Warnings []string
|
||||
// DryRun indicates if this was a dry run
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// FileRemoval represents a file to be completely removed.
|
||||
type FileRemoval struct {
|
||||
Path string
|
||||
Symbols []report.Symbol
|
||||
}
|
||||
|
||||
// SymbolRemoval represents symbols to be removed from a file.
|
||||
type SymbolRemoval struct {
|
||||
Path string
|
||||
Symbols []report.Symbol
|
||||
}
|
||||
|
||||
// RemovalPlan contains the planned removals.
|
||||
type RemovalPlan struct {
|
||||
// FilesToRemove are files where all symbols are unused
|
||||
FilesToRemove []FileRemoval
|
||||
// SymbolsToRemove are symbols to remove from partially-used files
|
||||
SymbolsToRemove []SymbolRemoval
|
||||
// DirsToRemove are empty directories to remove after file removal
|
||||
DirsToRemove []string
|
||||
}
|
||||
|
||||
// Debloat performs the debloating operation.
|
||||
func Debloat(opts Options) (*Result, error) {
|
||||
if opts.Dir == "" {
|
||||
opts.Dir = "."
|
||||
}
|
||||
|
||||
// First, run analysis to get used/unused symbols
|
||||
analysisReport, err := analysis.Analyze(analysis.Options{Dir: opts.Dir})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analysis failed: %w", err)
|
||||
}
|
||||
|
||||
result := &Result{
|
||||
DryRun: opts.DryRun,
|
||||
Warnings: analysisReport.Warnings,
|
||||
}
|
||||
|
||||
// Build removal plan
|
||||
plan, err := buildRemovalPlan(opts.Dir, analysisReport)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building removal plan: %w", err)
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
// Just report what would be done
|
||||
for _, f := range plan.FilesToRemove {
|
||||
result.FilesRemoved = append(result.FilesRemoved, f.Path)
|
||||
result.SymbolsRemoved += len(f.Symbols)
|
||||
}
|
||||
for _, s := range plan.SymbolsToRemove {
|
||||
result.FilesModified = append(result.FilesModified, s.Path)
|
||||
result.SymbolsRemoved += len(s.Symbols)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Create backup if requested
|
||||
if opts.Backup {
|
||||
if err := createBackup(opts.Dir); err != nil {
|
||||
return nil, fmt.Errorf("creating backup: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the removal plan
|
||||
if err := executePlan(opts.Dir, plan, result); err != nil {
|
||||
return nil, fmt.Errorf("executing removal plan: %w", err)
|
||||
}
|
||||
|
||||
// Verify compilation
|
||||
if err := verifyCompilation(opts.Dir); err != nil {
|
||||
return nil, fmt.Errorf("compilation verification failed (this is a bug in slight): %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// buildRemovalPlan creates a plan for what to remove based on the analysis report.
|
||||
func buildRemovalPlan(dir string, rpt *report.Report) (*RemovalPlan, error) {
|
||||
plan := &RemovalPlan{}
|
||||
|
||||
// Group symbols by file
|
||||
type fileInfo struct {
|
||||
usedSymbols []report.Symbol
|
||||
unusedSymbols []report.Symbol
|
||||
}
|
||||
fileSymbols := make(map[string]*fileInfo)
|
||||
|
||||
for _, pkg := range rpt.Packages {
|
||||
for _, sym := range pkg.UsedSymbols {
|
||||
path := sym.Pos.Filename
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if fileSymbols[path] == nil {
|
||||
fileSymbols[path] = &fileInfo{}
|
||||
}
|
||||
fileSymbols[path].usedSymbols = append(fileSymbols[path].usedSymbols, sym)
|
||||
}
|
||||
for _, sym := range pkg.UnusedSymbols {
|
||||
path := sym.Pos.Filename
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if fileSymbols[path] == nil {
|
||||
fileSymbols[path] = &fileInfo{}
|
||||
}
|
||||
fileSymbols[path].unusedSymbols = append(fileSymbols[path].unusedSymbols, sym)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine what to remove
|
||||
for path, info := range fileSymbols {
|
||||
// Skip init functions - they're always kept
|
||||
unusedNonInit := filterNonInit(info.unusedSymbols)
|
||||
|
||||
if len(info.usedSymbols) == 0 && len(unusedNonInit) > 0 {
|
||||
// No used symbols in this file - remove the entire file
|
||||
plan.FilesToRemove = append(plan.FilesToRemove, FileRemoval{
|
||||
Path: path,
|
||||
Symbols: unusedNonInit,
|
||||
})
|
||||
} else if len(unusedNonInit) > 0 {
|
||||
// Some used, some unused - remove individual symbols
|
||||
plan.SymbolsToRemove = append(plan.SymbolsToRemove, SymbolRemoval{
|
||||
Path: path,
|
||||
Symbols: unusedNonInit,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort for deterministic output
|
||||
sort.Slice(plan.FilesToRemove, func(i, j int) bool {
|
||||
return plan.FilesToRemove[i].Path < plan.FilesToRemove[j].Path
|
||||
})
|
||||
sort.Slice(plan.SymbolsToRemove, func(i, j int) bool {
|
||||
return plan.SymbolsToRemove[i].Path < plan.SymbolsToRemove[j].Path
|
||||
})
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// filterNonInit returns symbols that are not init functions.
|
||||
func filterNonInit(symbols []report.Symbol) []report.Symbol {
|
||||
var result []report.Symbol
|
||||
for _, sym := range symbols {
|
||||
if !sym.IsInit {
|
||||
result = append(result, sym)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// executePlan executes the removal plan.
|
||||
func executePlan(dir string, plan *RemovalPlan, result *Result) error {
|
||||
// First, remove individual symbols from partially-used files
|
||||
for _, sr := range plan.SymbolsToRemove {
|
||||
if err := removeSymbolsFromFile(sr.Path, sr.Symbols); err != nil {
|
||||
return fmt.Errorf("removing symbols from %s: %w", sr.Path, err)
|
||||
}
|
||||
result.FilesModified = append(result.FilesModified, sr.Path)
|
||||
result.SymbolsRemoved += len(sr.Symbols)
|
||||
}
|
||||
|
||||
// Then, remove entire files
|
||||
dirsToCheck := make(map[string]bool)
|
||||
for _, fr := range plan.FilesToRemove {
|
||||
if err := os.Remove(fr.Path); err != nil {
|
||||
return fmt.Errorf("removing file %s: %w", fr.Path, err)
|
||||
}
|
||||
result.FilesRemoved = append(result.FilesRemoved, fr.Path)
|
||||
result.SymbolsRemoved += len(fr.Symbols)
|
||||
dirsToCheck[filepath.Dir(fr.Path)] = true
|
||||
}
|
||||
|
||||
// Remove empty directories
|
||||
if err := removeEmptyDirs(dir, dirsToCheck); err != nil {
|
||||
return fmt.Errorf("removing empty directories: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeSymbolsFromFile removes the specified symbols from a Go source file.
|
||||
func removeSymbolsFromFile(path string, symbols []report.Symbol) error {
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing file: %w", err)
|
||||
}
|
||||
|
||||
// Build a set of symbols to remove by line number
|
||||
// We use line numbers because symbol names might not be unique (overloaded methods, etc.)
|
||||
toRemove := make(map[int]bool)
|
||||
for _, sym := range symbols {
|
||||
toRemove[sym.Pos.Line] = true
|
||||
}
|
||||
|
||||
// Track which comment groups to remove (by their position)
|
||||
commentsToRemove := make(map[token.Pos]bool)
|
||||
|
||||
// Filter declarations
|
||||
var newDecls []ast.Decl
|
||||
for _, decl := range file.Decls {
|
||||
pos := fset.Position(decl.Pos())
|
||||
|
||||
if toRemove[pos.Line] {
|
||||
// Mark associated doc comment for removal
|
||||
markDeclComments(decl, commentsToRemove)
|
||||
continue // Remove this declaration
|
||||
}
|
||||
|
||||
// For GenDecl, we might need to remove individual specs
|
||||
if genDecl, ok := decl.(*ast.GenDecl); ok {
|
||||
newSpecs, removedSpecs := filterSpecsWithComments(genDecl.Specs, fset, toRemove)
|
||||
// Mark comments from removed specs
|
||||
for _, spec := range removedSpecs {
|
||||
markSpecComments(spec, commentsToRemove)
|
||||
}
|
||||
if len(newSpecs) == 0 {
|
||||
// Mark the GenDecl's doc comment too
|
||||
if genDecl.Doc != nil {
|
||||
commentsToRemove[genDecl.Doc.Pos()] = true
|
||||
}
|
||||
continue // Remove entire declaration
|
||||
}
|
||||
genDecl.Specs = newSpecs
|
||||
}
|
||||
|
||||
newDecls = append(newDecls, decl)
|
||||
}
|
||||
file.Decls = newDecls
|
||||
|
||||
// Filter out removed comments
|
||||
var newComments []*ast.CommentGroup
|
||||
for _, cg := range file.Comments {
|
||||
if !commentsToRemove[cg.Pos()] {
|
||||
newComments = append(newComments, cg)
|
||||
}
|
||||
}
|
||||
file.Comments = newComments
|
||||
|
||||
// Write the file back
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
cfg := &printer.Config{
|
||||
Mode: printer.UseSpaces | printer.TabIndent,
|
||||
Tabwidth: 8,
|
||||
}
|
||||
if err := cfg.Fprint(f, fset, file); err != nil {
|
||||
return fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// markDeclComments marks the doc comment of a declaration for removal.
|
||||
func markDeclComments(decl ast.Decl, commentsToRemove map[token.Pos]bool) {
|
||||
switch d := decl.(type) {
|
||||
case *ast.FuncDecl:
|
||||
if d.Doc != nil {
|
||||
commentsToRemove[d.Doc.Pos()] = true
|
||||
}
|
||||
case *ast.GenDecl:
|
||||
if d.Doc != nil {
|
||||
commentsToRemove[d.Doc.Pos()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// markSpecComments marks the doc comment of a spec for removal.
|
||||
func markSpecComments(spec ast.Spec, commentsToRemove map[token.Pos]bool) {
|
||||
switch s := spec.(type) {
|
||||
case *ast.TypeSpec:
|
||||
if s.Doc != nil {
|
||||
commentsToRemove[s.Doc.Pos()] = true
|
||||
}
|
||||
if s.Comment != nil {
|
||||
commentsToRemove[s.Comment.Pos()] = true
|
||||
}
|
||||
case *ast.ValueSpec:
|
||||
if s.Doc != nil {
|
||||
commentsToRemove[s.Doc.Pos()] = true
|
||||
}
|
||||
if s.Comment != nil {
|
||||
commentsToRemove[s.Comment.Pos()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// filterSpecsWithComments filters specs in a GenDecl, returning both kept and removed specs.
|
||||
func filterSpecsWithComments(specs []ast.Spec, fset *token.FileSet, toRemove map[int]bool) (kept, removed []ast.Spec) {
|
||||
for _, spec := range specs {
|
||||
pos := fset.Position(spec.Pos())
|
||||
if toRemove[pos.Line] {
|
||||
removed = append(removed, spec)
|
||||
} else {
|
||||
kept = append(kept, spec)
|
||||
}
|
||||
}
|
||||
return kept, removed
|
||||
}
|
||||
|
||||
// removeEmptyDirs removes empty directories from vendor.
|
||||
func removeEmptyDirs(dir string, dirsToCheck map[string]bool) error {
|
||||
vendorDir := filepath.Join(dir, "vendor")
|
||||
|
||||
// Sort directories by depth (deepest first) to remove children before parents
|
||||
var dirs []string
|
||||
for d := range dirsToCheck {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
sort.Slice(dirs, func(i, j int) bool {
|
||||
return strings.Count(dirs[i], string(os.PathSeparator)) >
|
||||
strings.Count(dirs[j], string(os.PathSeparator))
|
||||
})
|
||||
|
||||
for _, d := range dirs {
|
||||
// Only process directories under vendor
|
||||
if !strings.HasPrefix(d, vendorDir) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Walk up the directory tree removing empty directories
|
||||
for d != vendorDir && strings.HasPrefix(d, vendorDir) {
|
||||
entries, err := os.ReadDir(d)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
if err := os.Remove(d); err != nil {
|
||||
break
|
||||
}
|
||||
d = filepath.Dir(d)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createBackup creates a backup of the vendor directory.
|
||||
func createBackup(dir string) error {
|
||||
vendorDir := filepath.Join(dir, "vendor")
|
||||
backupDir := filepath.Join(dir, "vendor.backup")
|
||||
|
||||
// Remove existing backup if present
|
||||
if err := os.RemoveAll(backupDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy vendor to backup
|
||||
cmd := exec.Command("cp", "-r", vendorDir, backupDir)
|
||||
cmd.Dir = dir
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// verifyCompilation runs go build to verify the code still compiles.
|
||||
func verifyCompilation(dir string) error {
|
||||
cmd := exec.Command("go", "build", "./...")
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOFLAGS=-mod=vendor")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("build failed:\n%s", output)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
298
pkg/debloat/debloat_test.go
Normal file
298
pkg/debloat/debloat_test.go
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
package debloat
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDebloatDryRun(t *testing.T) {
|
||||
// Copy testdata to temp dir
|
||||
tmpDir := t.TempDir()
|
||||
if err := copyDir("../../testdata/simple", tmpDir); err != nil {
|
||||
t.Fatalf("copying testdata: %v", err)
|
||||
}
|
||||
|
||||
// Run debloat in dry-run mode
|
||||
result, err := Debloat(Options{
|
||||
Dir: tmpDir,
|
||||
DryRun: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("debloat failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify dry-run flag is set
|
||||
if !result.DryRun {
|
||||
t.Error("expected DryRun to be true")
|
||||
}
|
||||
|
||||
// Verify some symbols would be removed
|
||||
if result.SymbolsRemoved == 0 {
|
||||
t.Error("expected some symbols to be marked for removal")
|
||||
}
|
||||
|
||||
// Verify the vendor file still exists (dry-run shouldn't modify)
|
||||
libFile := filepath.Join(tmpDir, "vendor", "example.com", "lib", "lib.go")
|
||||
if _, err := os.Stat(libFile); os.IsNotExist(err) {
|
||||
t.Error("vendor file should still exist after dry-run")
|
||||
}
|
||||
|
||||
// Verify the content is unchanged
|
||||
content, err := os.ReadFile(libFile)
|
||||
if err != nil {
|
||||
t.Fatalf("reading vendor file: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), "UnusedFunc") {
|
||||
t.Error("UnusedFunc should still be present after dry-run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebloatRemovesUnusedSymbols(t *testing.T) {
|
||||
// Copy testdata to temp dir
|
||||
tmpDir := t.TempDir()
|
||||
if err := copyDir("../../testdata/simple", tmpDir); err != nil {
|
||||
t.Fatalf("copying testdata: %v", err)
|
||||
}
|
||||
|
||||
// Run debloat
|
||||
result, err := Debloat(Options{
|
||||
Dir: tmpDir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("debloat failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify some symbols were removed
|
||||
if result.SymbolsRemoved == 0 {
|
||||
t.Error("expected some symbols to be removed")
|
||||
}
|
||||
|
||||
// Verify the vendor file still exists (partially used)
|
||||
libFile := filepath.Join(tmpDir, "vendor", "example.com", "lib", "lib.go")
|
||||
if _, err := os.Stat(libFile); os.IsNotExist(err) {
|
||||
t.Error("vendor file should still exist (has used symbols)")
|
||||
}
|
||||
|
||||
// Verify unused symbols are removed
|
||||
content, err := os.ReadFile(libFile)
|
||||
if err != nil {
|
||||
t.Fatalf("reading vendor file: %v", err)
|
||||
}
|
||||
|
||||
// These should be removed
|
||||
for _, unused := range []string{"UnusedFunc", "UnusedType", "UnusedConst", "UnusedVar", "UnusedMethod"} {
|
||||
if strings.Contains(string(content), unused) {
|
||||
t.Errorf("%s should have been removed", unused)
|
||||
}
|
||||
}
|
||||
|
||||
// These should remain
|
||||
for _, used := range []string{"UsedFunc", "UsedType", "UsedConst", "UsedVar", "UsedMethod"} {
|
||||
if !strings.Contains(string(content), used) {
|
||||
t.Errorf("%s should still be present", used)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the code still compiles
|
||||
cmd := exec.Command("go", "build", "./...")
|
||||
cmd.Dir = tmpDir
|
||||
cmd.Env = append(os.Environ(), "GOFLAGS=-mod=vendor")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Errorf("code should still compile after debloating: %v\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebloatWithBackup(t *testing.T) {
|
||||
// Copy testdata to temp dir
|
||||
tmpDir := t.TempDir()
|
||||
if err := copyDir("../../testdata/simple", tmpDir); err != nil {
|
||||
t.Fatalf("copying testdata: %v", err)
|
||||
}
|
||||
|
||||
// Run debloat with backup
|
||||
_, err := Debloat(Options{
|
||||
Dir: tmpDir,
|
||||
Backup: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("debloat failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify backup was created
|
||||
backupDir := filepath.Join(tmpDir, "vendor.backup")
|
||||
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||
t.Error("backup directory should exist")
|
||||
}
|
||||
|
||||
// Verify backup has original content
|
||||
backupFile := filepath.Join(backupDir, "example.com", "lib", "lib.go")
|
||||
content, err := os.ReadFile(backupFile)
|
||||
if err != nil {
|
||||
t.Fatalf("reading backup file: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), "UnusedFunc") {
|
||||
t.Error("backup should contain original UnusedFunc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebloatPreservesInitFunctions(t *testing.T) {
|
||||
// Create a test module with an init function
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create go.mod
|
||||
goMod := `module testmod
|
||||
|
||||
go 1.21
|
||||
|
||||
require example.com/lib v1.0.0
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goMod), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create main.go that uses the library
|
||||
mainGo := `package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"example.com/lib"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println(lib.Value)
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte(mainGo), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create vendor directory
|
||||
vendorDir := filepath.Join(tmpDir, "vendor", "example.com", "lib")
|
||||
if err := os.MkdirAll(vendorDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create vendor/modules.txt
|
||||
modulesTxt := `# example.com/lib v1.0.0
|
||||
## explicit
|
||||
example.com/lib
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "vendor", "modules.txt"), []byte(modulesTxt), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create lib.go with init function
|
||||
libGo := `package lib
|
||||
|
||||
var Value string
|
||||
|
||||
func init() {
|
||||
Value = "initialized"
|
||||
}
|
||||
|
||||
func UnusedFunc() string {
|
||||
return "unused"
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(vendorDir, "lib.go"), []byte(libGo), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Run debloat
|
||||
result, err := Debloat(Options{
|
||||
Dir: tmpDir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("debloat failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify init function is preserved
|
||||
content, err := os.ReadFile(filepath.Join(vendorDir, "lib.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("reading lib.go: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(content), "func init()") {
|
||||
t.Error("init function should be preserved")
|
||||
}
|
||||
|
||||
// UnusedFunc should be removed
|
||||
if strings.Contains(string(content), "UnusedFunc") {
|
||||
t.Error("UnusedFunc should be removed")
|
||||
}
|
||||
|
||||
// Verify it still compiles
|
||||
cmd := exec.Command("go", "build", "./...")
|
||||
cmd.Dir = tmpDir
|
||||
cmd.Env = append(os.Environ(), "GOFLAGS=-mod=vendor")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Errorf("code should still compile: %v\n%s", err, output)
|
||||
}
|
||||
|
||||
_ = result // Silence unused variable warning
|
||||
}
|
||||
|
||||
func TestBuildRemovalPlan(t *testing.T) {
|
||||
// Copy testdata to temp dir
|
||||
tmpDir := t.TempDir()
|
||||
if err := copyDir("../../testdata/simple", tmpDir); err != nil {
|
||||
t.Fatalf("copying testdata: %v", err)
|
||||
}
|
||||
|
||||
// Run analysis
|
||||
result, err := Debloat(Options{
|
||||
Dir: tmpDir,
|
||||
DryRun: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("debloat failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have files to modify (partially used file)
|
||||
if len(result.FilesModified) == 0 && len(result.FilesRemoved) == 0 {
|
||||
t.Error("expected some files to be modified or removed")
|
||||
}
|
||||
|
||||
// Since the test file has both used and unused symbols,
|
||||
// it should be in FilesModified (not completely removed)
|
||||
hasPartialFile := false
|
||||
for _, f := range result.FilesModified {
|
||||
if strings.Contains(f, "lib.go") {
|
||||
hasPartialFile = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasPartialFile {
|
||||
t.Error("expected lib.go to be in FilesModified (partially used)")
|
||||
}
|
||||
}
|
||||
|
||||
// copyDir recursively copies a directory.
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate destination path
|
||||
relPath, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dstPath := filepath.Join(dst, relPath)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(dstPath, info.Mode())
|
||||
}
|
||||
|
||||
// Copy file
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dstPath, data, info.Mode())
|
||||
})
|
||||
}
|
||||
42
plan.md
42
plan.md
|
|
@ -72,7 +72,7 @@ Produce a report showing which vendored symbols are used vs unused, with percent
|
|||
- List of unused symbols with file:line locations
|
||||
- Library API: return structured data, let caller format
|
||||
|
||||
### Package Structure (Milestone 1)
|
||||
### Package Structure
|
||||
|
||||
```
|
||||
slight/
|
||||
|
|
@ -80,16 +80,21 @@ slight/
|
|||
│ └── main.go # CLI entry point
|
||||
├── pkg/
|
||||
│ ├── analysis/
|
||||
│ │ ├── analyze.go # Main analysis entry point
|
||||
│ │ ├── loader.go # Package loading with x/tools/go/packages
|
||||
│ │ ├── inventory.go # Build symbol inventory from vendor
|
||||
│ │ ├── usage.go # Analyze usage from main code
|
||||
│ │ └── closure.go # Transitive closure computation
|
||||
│ ├── debloat/
|
||||
│ │ └── debloat.go # Vendor debloating logic
|
||||
│ ├── report/
|
||||
│ │ ├── report.go # Report data structures
|
||||
│ │ ├── text.go # Human-readable output
|
||||
│ │ └── json.go # JSON output
|
||||
│ └── scan/
|
||||
│ └── prescan.go # Build tag detection, cgo/asm detection
|
||||
├── testdata/
|
||||
│ └── simple/ # Test fixture with vendored library
|
||||
├── go.mod
|
||||
└── go.sum
|
||||
```
|
||||
|
|
@ -113,42 +118,44 @@ Examples:
|
|||
|
||||
## Milestone 2: Vendor Debloating
|
||||
|
||||
**Status: COMPLETE** ✓
|
||||
|
||||
### Goal
|
||||
Remove unused code from vendor/ while ensuring compilation still succeeds.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. **Determine removal candidates**
|
||||
1. **Determine removal candidates** ✓
|
||||
- From Milestone 1 analysis, identify:
|
||||
- Entire files where no symbols are used
|
||||
- Individual symbols to remove from partially-used files
|
||||
|
||||
2. **Handle removal dependencies**
|
||||
2. **Handle removal dependencies** ✓
|
||||
- Before removing a symbol, verify nothing else in vendor depends on it
|
||||
- Handle file-level dependencies (package-level vars, types used by remaining code)
|
||||
- Be conservative: if unsure, keep the symbol
|
||||
|
||||
3. **Remove entire unused files**
|
||||
3. **Remove entire unused files** ✓
|
||||
- Delete `.go` files where no exported or unexported symbols are used by remaining code
|
||||
- Remove empty directories from vendor
|
||||
|
||||
4. **Remove individual symbols from files**
|
||||
4. **Remove individual symbols from files** ✓
|
||||
- Use `go/ast` and `go/printer` to rewrite files
|
||||
- Remove:
|
||||
- Unused functions and methods
|
||||
- Unused types (if no remaining code references them)
|
||||
- Unused variables and constants
|
||||
- Associated doc comments
|
||||
- Preserve:
|
||||
- `init()` functions
|
||||
- Anything needed for compilation of remaining code
|
||||
- Use `gofmt`-compatible output
|
||||
|
||||
5. **Verify compilation**
|
||||
5. **Verify compilation** ✓
|
||||
- After modifications, run `go build ./...` on the module
|
||||
- If build fails, this is a bug in slight - report and rollback changes
|
||||
- Consider doing this incrementally (remove, verify, continue)
|
||||
|
||||
6. **Dry-run mode**
|
||||
6. **Dry-run mode** ✓
|
||||
- Show what would be removed without making changes
|
||||
- Useful for review before actual debloating
|
||||
|
||||
|
|
@ -171,7 +178,7 @@ Examples:
|
|||
|
||||
## Testing Strategy
|
||||
|
||||
**Status: PARTIAL** - Unit tests implemented, integration tests pending
|
||||
**Status: COMPLETE** ✓
|
||||
|
||||
### Implemented Tests
|
||||
|
||||
|
|
@ -199,21 +206,24 @@ Run with `go test ./...`
|
|||
- `TestPreScanAssembly` - Assembly file detection and error
|
||||
- `TestPreScanSkipsTestFiles` - Test file exclusion
|
||||
|
||||
4. **pkg/debloat/debloat_test.go** - Debloating tests
|
||||
- `TestDebloatDryRun` - Verifies dry-run mode doesn't modify files
|
||||
- `TestDebloatRemovesUnusedSymbols` - Verifies unused symbols are removed and used symbols remain
|
||||
- `TestDebloatWithBackup` - Verifies backup creation
|
||||
- `TestDebloatPreservesInitFunctions` - Verifies init() functions are preserved
|
||||
- `TestBuildRemovalPlan` - Verifies removal plan generation
|
||||
|
||||
### Test Fixtures
|
||||
|
||||
- `testdata/simple/` - Simple module with vendored library containing used and unused symbols
|
||||
|
||||
### Pending Tests
|
||||
### Future Test Improvements
|
||||
|
||||
1. **Integration tests using testscript** (for Milestone 2)
|
||||
- Verify debloating removes expected code
|
||||
- Verify result still compiles after debloating
|
||||
|
||||
2. **Additional test cases to cover**
|
||||
1. **Additional test cases to cover**
|
||||
- Embedded types
|
||||
- Interface satisfaction
|
||||
- Transitive dependencies within vendor
|
||||
- Packages with init() functions
|
||||
- Multiple vendored packages with cross-dependencies
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue