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 }