nub/internal/ctxasm/repomap.go
Tom a97013d876 initial commit
- v 0.1.0 siehe CHANGELOG.md
2026-07-25 11:08:01 +02:00

204 lines
4.6 KiB
Go

package ctxasm
import (
"bufio"
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
const repoMapCommitPrefix = "commit: "
// GenerateRepoMap baut Verzeichnisbaum + exportierte Symbole (5.4). Für
// .go-Dateien über go/ast, sonst der sprachagnostische Fallback: die erste
// nicht-leere Zeile der Datei. Der Header trägt den Git-Commit, gegen den
// gebaut wurde — Assemble() warnt später, wenn HEAD davon abweicht.
func GenerateRepoMap(repoRoot string) (string, error) {
commit, _ := gitHead(repoRoot) // best effort; leer, wenn kein Git-Repo
var files []string
err := filepath.WalkDir(repoRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
rel, relErr := filepath.Rel(repoRoot, path)
if relErr != nil || rel == "." {
return nil
}
base := filepath.Base(path)
if d.IsDir() {
if base == ".git" || base == ".nub" || base == "node_modules" || base == "vendor" {
return filepath.SkipDir
}
return nil
}
files = append(files, rel)
return nil
})
if err != nil {
return "", err
}
sort.Strings(files)
var b strings.Builder
fmt.Fprintf(&b, "# REPOMAP.md\n%s%s\ngenerated_by: nub map\n\n", repoMapCommitPrefix, commit)
b.WriteString("## Tree\n\n")
for _, f := range files {
fmt.Fprintf(&b, "%s\n", f)
}
b.WriteString("\n## Symbols\n\n")
for _, f := range files {
abs := filepath.Join(repoRoot, f)
if strings.HasSuffix(f, ".go") {
syms, err := goSymbols(abs)
if err != nil || len(syms) == 0 {
continue
}
fmt.Fprintf(&b, "### %s\n", f)
for _, s := range syms {
fmt.Fprintf(&b, "- %s\n", s)
}
b.WriteString("\n")
continue
}
if header := fileHeader(abs); header != "" {
fmt.Fprintf(&b, "### %s\n%s\n\n", f, header)
}
}
return b.String(), nil
}
func goSymbols(path string) ([]string, error) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil, err
}
var syms []string
for _, decl := range f.Decls {
switch d := decl.(type) {
case *ast.FuncDecl:
if d.Name.IsExported() {
recv := ""
if d.Recv != nil && len(d.Recv.List) == 1 {
recv = "(" + exprString(d.Recv.List[0].Type) + ") "
}
syms = append(syms, "func "+recv+d.Name.Name)
}
case *ast.GenDecl:
for _, spec := range d.Specs {
switch s := spec.(type) {
case *ast.TypeSpec:
if s.Name.IsExported() {
syms = append(syms, "type "+s.Name.Name)
}
case *ast.ValueSpec:
kind := "var"
if d.Tok == token.CONST {
kind = "const"
}
for _, name := range s.Names {
if name.IsExported() {
syms = append(syms, kind+" "+name.Name)
}
}
}
}
}
}
return syms, nil
}
func exprString(e ast.Expr) string {
switch t := e.(type) {
case *ast.Ident:
return t.Name
case *ast.StarExpr:
return "*" + exprString(t.X)
default:
return "?"
}
}
// fileHeader ist der sprachagnostische Fallback: die erste nicht-leere Zeile.
func fileHeader(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if len(line) > 120 {
line = line[:120]
}
return line
}
return ""
}
func gitHead(repoRoot string) (string, error) {
out, err := exec.Command("git", "-C", repoRoot, "rev-parse", "HEAD").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// warnStaleRepoMap vergleicht den im REPOMAP.md-Header vermerkten Commit mit
// dem aktuellen HEAD und hängt bei Abweichung eine Warnung an (nicht fatal —
// eine falsche Repomap ist schlechter als keine, aber sie soll den Start
// nicht verhindern).
func warnStaleRepoMap(repoRoot string, files []FileOrigin, warnings *[]string) {
for _, f := range files {
if filepath.Base(f.Path) != "REPOMAP.md" {
continue
}
data, err := os.ReadFile(f.Source)
if err != nil {
return
}
recorded := ""
for _, line := range strings.Split(string(data), "\n") {
if rest, ok := strings.CutPrefix(line, repoMapCommitPrefix); ok {
recorded = strings.TrimSpace(rest)
break
}
}
if recorded == "" {
return
}
current, err := gitHead(repoRoot)
if err != nil {
return
}
if current != recorded {
*warnings = append(*warnings, fmt.Sprintf(
"REPOMAP.md wurde gegen Commit %s erzeugt, HEAD ist jetzt %s — `nub map` erneut ausführen",
shortSHA(recorded), shortSHA(current)))
}
}
}
func shortSHA(sha string) string {
if len(sha) > 12 {
return sha[:12]
}
return sha
}