initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:03:50 +02:00
commit 23816d00f7
71 changed files with 14497 additions and 0 deletions

728
backend/docgen/docgen.go Normal file
View file

@ -0,0 +1,728 @@
// Package docgen analyses a Go project and renders audience-segmented
// documentation for go-doc.
//
// The pipeline is deliberately mechanical: Analyze walks a project tree and
// extracts facts that can be read off the filesystem (module metadata, HTTP
// routes, CLI flags, environment variables, Make targets, container services,
// Helm charts, existing Markdown, language breakdown), and Render turns those
// facts into the same three-audience layout go-doc uses natively:
//
// ## For Users what the thing does and how to use it
// ## For Operators running, configuring and deploying it
// ## For Developers contracts, layout and dependencies
//
// Import wraps the rendered page in generated markers so re-running the tool
// refreshes only the machine-written region and never clobbers prose a human
// added outside it.
package docgen
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
)
// maxReadBytes caps how much of any single file is read into memory.
const maxReadBytes = 2 << 20
// maxWalkFiles caps the walk so a mis-pointed root cannot spin forever.
const maxWalkFiles = 200000
// Route is one HTTP endpoint discovered in the source.
type Route struct {
Method string
Path string
Handler string
}
// String renders the route the way it is registered.
func (r Route) String() string {
if r.Method == "" {
return r.Path
}
return r.Method + " " + r.Path
}
// SortKey orders routes by path, then method, so output is stable.
func (r Route) SortKey() string { return r.Path + "\x00" + r.Method + "\x00" + r.Handler }
// Flag is a command-line flag declared with the flag package.
type Flag struct {
Name string
Type string
Default string
Usage string
}
// EnvVar is an environment variable the code reads, plus a note when it was
// discovered in a .env.example file rather than in source.
type EnvVar struct {
Name string
Example string
FromEnv bool
}
// Dependency is one requirement line from a go.mod.
type Dependency struct {
Path string
Version string
Indirect bool
}
// Module is one go.mod file found in the tree.
type Module struct {
Path string
File string
GoVersion string
Direct []Dependency
Indirect int
}
// ComposeService is a service declared in a docker-compose file.
type ComposeService struct {
Name string
Image string
Ports string
File string
}
// DockerInfo summarises the project's Dockerfile.
type DockerInfo struct {
File string
Stages []string
Bases []string
Exposed []string
}
// DocFile is an existing Markdown document that ships with the project.
type DocFile struct {
Path string
Title string
Bytes int
}
// Dir is a first-level source directory with a purpose inferred from its name.
type Dir struct {
Name string
Files int
Lines int
}
// Language is a per-extension line count.
type Language struct {
Name string
Files int
Lines int
}
// Stats are the headline counters shown in the generated page.
type Stats struct {
GoFiles int
TestFiles int
TestFuncs int
Components int
TotalLines int
OldestCommit string
}
// Project is the full analysis result for one repository.
type Project struct {
Name string
Path string
Summary string
Readme string
Modules []Module
Routes []Route
Flags []Flag
EnvVars []EnvVar
MakeTargets []string
Compose []ComposeService
Docker *DockerInfo
HelmCharts []string
Docs []DocFile
Dirs []Dir
Languages []Language
Ports []string
Stats Stats
// Signature changes whenever any analysed input changes. Watch mode uses it
// to decide whether a regeneration is needed.
Signature string
}
// Analyze walks root and extracts everything Render needs.
//
// A missing or partial project is not an error: a repository without a go.mod
// or without a README still yields a usable page describing whatever was found.
func Analyze(name, root string) (*Project, error) {
info, err := os.Stat(root)
if err != nil {
return nil, fmt.Errorf("docgen: stat %s: %w", root, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("docgen: %s is not a directory", root)
}
p := &Project{Name: name, Path: root}
files, err := collectFiles(root)
if err != nil {
return nil, err
}
p.Signature = signature(files)
for _, file := range files {
rel := file.rel
switch {
case strings.HasSuffix(rel, ".go"):
p.Stats.GoFiles++
p.Stats.TotalLines += file.lines
if strings.HasSuffix(rel, "_test.go") {
p.Stats.TestFiles++
p.Stats.TestFuncs += countTestFuncs(file.text())
}
if isComponentPath(rel) {
p.Stats.Components++
}
analyzeGoFile(p, file)
case filepath.Base(rel) == "go.mod":
p.Modules = append(p.Modules, parseGoMod(rel, file.text()))
case isMarkdown(rel):
p.Docs = append(p.Docs, DocFile{
Path: rel,
Title: markdownTitle(file.text()),
Bytes: int(file.size),
})
case filepath.Base(rel) == "Makefile" || filepath.Base(rel) == "makefile":
p.MakeTargets = append(p.MakeTargets, makeTargets(file.text())...)
case isDockerfile(rel):
if p.Docker == nil {
p.Docker = parseDockerfile(rel, file.text())
}
case isComposeFile(rel):
p.Compose = append(p.Compose, parseCompose(rel, file.text())...)
case strings.HasPrefix(filepath.Base(rel), ".env"):
p.EnvVars = append(p.EnvVars, envFromExample(file.text())...)
case isHelmFile(rel):
p.HelmCharts = append(p.HelmCharts, chartRoot(rel))
}
p.Languages = addLanguage(p.Languages, file)
if portBearingFiles(rel) {
p.Ports = append(p.Ports, portsIn(file.text())...)
}
}
finish(p)
return p, nil
}
// ---------------------------------------------------------------------------
// Normalisation
// ---------------------------------------------------------------------------
func finish(p *Project) {
p.Readme = findReadme(p)
p.Summary = summarize(p)
p.Modules = dedupeModules(p.Modules)
p.Routes = dedupeRoutes(p.Routes)
p.Flags = dedupeFlags(p.Flags)
p.EnvVars = dedupeEnv(p.EnvVars)
p.MakeTargets = dedupeStrings(p.MakeTargets)
p.HelmCharts = dedupeStrings(p.HelmCharts)
p.Ports = dedupeStrings(p.Ports)
p.Dirs = topDirs(p)
p.Languages = rankLanguages(p.Languages)
}
// ---------------------------------------------------------------------------
// Walking
// ---------------------------------------------------------------------------
type scannedFile struct {
path string
rel string
size int64
lines int
}
func (f scannedFile) text() string {
data, err := readCapped(f.path, maxReadBytes)
if err != nil {
return ""
}
return string(data)
}
// ignoredDirs never contain first-party source worth documenting.
var ignoredDirs = map[string]struct{}{
"node_modules": {}, ".git": {}, ".idea": {}, ".vscode": {}, "dist": {},
"build": {}, "bin": {}, "vendor": {}, "coverage": {}, "tmp": {},
".cache": {}, ".next": {}, "target": {}, "__pycache__": {}, ".venv": {},
}
func collectFiles(root string) ([]scannedFile, error) {
var files []scannedFile
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
// An unreadable subtree must not abort the whole analysis.
if entry != nil && entry.IsDir() {
return fs.SkipDir
}
return nil
}
if len(files) >= maxWalkFiles {
return fs.SkipAll
}
name := entry.Name()
if entry.IsDir() {
if _, skip := ignoredDirs[name]; skip && path != root {
return fs.SkipDir
}
return nil
}
if !entry.Type().IsRegular() {
return nil
}
if isNoise(name) {
return nil
}
info, err := entry.Info()
if err != nil {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
rel = name
}
files = append(files, scannedFile{
path: path,
rel: filepath.ToSlash(rel),
size: info.Size(),
lines: countLinesFast(path),
})
return nil
})
if err != nil {
return nil, fmt.Errorf("docgen: walk %s: %w", root, err)
}
return files, nil
}
// isNoise filters artefacts that would pollute the analysis.
func isNoise(name string) bool {
switch filepath.Ext(name) {
case ".log", ".lock", ".sum", ".pem", ".crt", ".key", ".png", ".jpg", ".jpeg",
".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".zip", ".tar",
".gz", ".mp4", ".sql.gz", ".db", ".sqlite", ".pyc":
return true
}
// Certificates and keys are frequently committed without extensions.
switch {
case strings.HasSuffix(name, ".crt"), strings.HasSuffix(name, ".key"):
return true
case strings.HasSuffix(name, "-chain.crt"), strings.HasSuffix(name, "-root-ca.crt"):
return true
}
return false
}
func isMarkdown(rel string) bool {
return strings.EqualFold(filepath.Ext(rel), ".md")
}
func isDockerfile(rel string) bool {
base := filepath.Base(rel)
return base == "Dockerfile" || strings.HasPrefix(base, "Dockerfile.")
}
func isComposeFile(rel string) bool {
base := strings.ToLower(filepath.Base(rel))
if !strings.HasPrefix(base, "docker-compose") && base != "compose.yaml" && base != "compose.yml" {
return false
}
return strings.HasSuffix(base, ".yml") || strings.HasSuffix(base, ".yaml")
}
func isHelmChart(rel string) bool {
parts := strings.Split(rel, "/")
for i, part := range parts {
if part == "helm" || part == "charts" {
// A chart is a directory that owns a values.yaml or Chart.yaml.
if i+1 < len(parts) {
return true
}
}
}
return false
}
// chartRoot collapses a chart file path to the directory that owns the chart,
// so the page lists one entry per chart rather than one per template.
func chartRoot(rel string) string {
dir := path.Dir(rel)
if dir == "." {
return "./ (repository root)"
}
return dir
}
func isComponentPath(rel string) bool {
parts := strings.Split(rel, "/")
if len(parts) < 2 {
return false
}
switch parts[len(parts)-2] {
case "internal", "pkg", "cmd", "api", "services", "web", "ui", "frontend", "backend":
return true
}
return false
}
func readCapped(path string, limit int64) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
return io.ReadAll(io.LimitReader(file, limit))
}
func countLinesFast(path string) int {
file, err := os.Open(path)
if err != nil {
return 0
}
defer file.Close()
// Skip .md / .json / .lock style files where line counts are meaningless.
switch strings.ToLower(filepath.Ext(path)) {
case ".md", ".json", ".yaml", ".yml", ".txt", ".lock", ".css", ".html":
return 0
}
buf := make([]byte, 32<<10)
lines := 0
for {
n, err := file.Read(buf)
if n > 0 {
for _, b := range buf[:n] {
if b == '\n' {
lines++
}
}
}
if err != nil {
break
}
}
return lines
}
// signature hashes the identity of every analysed file. It deliberately uses
// size and mtime rather than content so watch mode can poll large trees cheaply.
func signature(files []scannedFile) string {
hash := sha256.New()
for _, file := range files {
info, err := os.Stat(file.path)
if err != nil {
continue
}
fmt.Fprintf(hash, "%s|%d|%d\n", file.rel, file.size, info.ModTime().UnixNano())
}
return hex.EncodeToString(hash.Sum(nil))[:32]
}
func countTestFuncs(source string) int {
return strings.Count(source, "func Test") + strings.Count(source, "func Fuzz") +
strings.Count(source, "func Benchmark")
}
// noiseExtensions are file types that carry no language signal: build
// metadata, ignore lists and editor configuration all have extensions that would
// otherwise show up as their own "language".
var noiseExtensions = map[string]struct{}{
"mod": {}, "sum": {}, "lock": {}, "gitignore": {}, "dockerignore": {},
"helmignore": {}, "npmignore": {}, "eslintignore": {}, "gitattributes": {},
"env": {},
"editorconfig": {}, "prettierignore": {}, "example": {}, "template": {},
}
func addLanguage(langs []Language, file scannedFile) []Language {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(file.path), "."))
if ext == "" {
return langs
}
if _, skip := noiseExtensions[ext]; skip {
return langs
}
name := languageName(ext)
lines := file.lines
if lines == 0 {
lines = countAllLinesFast(file.path)
}
for i := range langs {
if langs[i].Name == name {
langs[i].Files++
langs[i].Lines += lines
return langs
}
}
return append(langs, Language{Name: name, Files: 1, Lines: lines})
}
func countAllLinesFast(path string) int {
data, err := readCapped(path, maxReadBytes)
if err != nil {
return 0
}
lines := strings.Count(string(data), "\n")
if len(data) > 0 && !strings.HasSuffix(string(data), "\n") {
lines++
}
return lines
}
func languageName(ext string) string {
switch ext {
case "go":
return "Go"
case "js", "mjs", "cjs":
return "JavaScript"
case "jsx":
return "JSX"
case "ts", "tsx":
return "TypeScript"
case "css":
return "CSS"
case "html", "htm":
return "HTML"
case "sh", "bash":
return "Shell"
case "sql":
return "SQL"
case "yml", "yaml":
return "YAML"
case "json":
return "JSON"
case "md":
return "Markdown"
case "tf":
return "Terraform"
case "proto":
return "Protobuf"
case "tpl", "gotmpl":
return "Templates"
case "py":
return "Python"
default:
return ext
}
}
func rankLanguages(langs []Language) []Language {
sort.Slice(langs, func(i, j int) bool {
if langs[i].Lines == langs[j].Lines {
return langs[i].Name < langs[j].Name
}
return langs[i].Lines > langs[j].Lines
})
// A page does not benefit from a tail of one-file languages.
if len(langs) > maxLanguages {
langs = langs[:maxLanguages]
}
return langs
}
// maxLanguages caps the language table on a generated page.
const maxLanguages = 10
func topDirs(p *Project) []Dir {
byName := map[string]*Dir{}
err := filepath.WalkDir(p.Path, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return nil
}
if entry.IsDir() {
if path != p.Path {
if _, skip := ignoredDirs[entry.Name()]; skip {
return fs.SkipDir
}
// Dot directories hold tooling, not first-party source.
if strings.HasPrefix(entry.Name(), ".") {
return fs.SkipDir
}
}
return nil
}
rel, err := filepath.Rel(p.Path, path)
if err != nil {
return nil
}
parts := strings.Split(filepath.ToSlash(rel), "/")
if len(parts) < 2 {
return nil
}
dir := byName[parts[0]]
if dir == nil {
dir = &Dir{Name: parts[0]}
byName[parts[0]] = dir
}
dir.Files++
dir.Lines += countLinesFast(path)
return nil
})
if err != nil {
return nil
}
dirs := make([]Dir, 0, len(byName))
for _, dir := range byName {
dirs = append(dirs, *dir)
}
sort.Slice(dirs, func(i, j int) bool {
if dirs[i].Files == dirs[j].Files {
return dirs[i].Name < dirs[j].Name
}
return dirs[i].Files > dirs[j].Files
})
if len(dirs) > 14 {
dirs = dirs[:14]
}
return dirs
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func dedupeStrings(in []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(in))
for _, value := range in {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, dup := seen[value]; dup {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
sort.Strings(out)
return out
}
// dedupeInOrder removes duplicates while preserving first-seen order, for lists
// whose order is meaningful (Docker build stages, published ports).
func dedupeInOrder(in []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(in))
for _, value := range in {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, dup := seen[value]; dup {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func dedupeModules(mods []Module) []Module {
sort.Slice(mods, func(i, j int) bool { return mods[i].File < mods[j].File })
return mods
}
// dedupeRoutes collapses registrations that describe the same endpoint. The
// key is method plus path, because one endpoint is often registered in several
// places (a group helper plus the router); when several carry a handler name,
// the one with a handler wins over the one without.
func dedupeRoutes(routes []Route) []Route {
best := make(map[string]Route, len(routes))
for _, route := range routes {
if route.Path == "" {
continue
}
key := route.Method + " " + route.Path
existing, seen := best[key]
switch {
case !seen:
best[key] = route
case existing.Handler == "" && route.Handler != "":
best[key] = route
}
}
out := make([]Route, 0, len(best))
for _, route := range best {
out = append(out, route)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Path == out[j].Path {
return out[i].Method < out[j].Method
}
return out[i].Path < out[j].Path
})
return out
}
func dedupeFlags(flags []Flag) []Flag {
seen := map[string]struct{}{}
out := make([]Flag, 0, len(flags))
for _, flag := range flags {
if _, dup := seen[flag.Name]; dup {
continue
}
seen[flag.Name] = struct{}{}
out = append(out, flag)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func dedupeEnv(vars []EnvVar) []EnvVar {
seen := map[string]struct{}{}
out := make([]EnvVar, 0, len(vars))
for _, v := range vars {
if _, dup := seen[v.Name]; dup {
continue
}
seen[v.Name] = struct{}{}
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Timestamp is used inside generated markers so a reader can tell how fresh a
// machine-written region is.
func Timestamp() string { return time.Now().UTC().Format("2006-01-02 15:04 UTC") }

View file

@ -0,0 +1,621 @@
package docgen
import (
"os"
"path/filepath"
"strings"
"testing"
)
// fixture writes a small but representative project tree: all three route
// registration styles, a module file, a Makefile, a Dockerfile, a compose file,
// an .env, a README and one unrelated Markdown document.
func fixture(t *testing.T) string {
t.Helper()
root := t.TempDir()
files := map[string]string{
"README.md": "# Demo Service\n\nDemo is a service that answers questions about things. " +
"It runs behind Go Loose and exposes a JSON API.\n\n" +
"## Install\n\nNot part of the summary.\n",
"go.mod": "module github.com/danyel/demo\n\ngo 1.27.1\n\nrequire (\n" +
"\tgithub.com/go-chi/chi/v5 v5.2.3\n\tgithub.com/spf13/cobra v1.9.1\n" +
"\tgolang.org/x/sync v0.14.0 // indirect\n)\n",
"Makefile": "build:\n\tgo build ./...\n\n.PHONY: build\n\ntest:\n\tgo test ./...\n",
"Dockerfile": "FROM golang:1.27-alpine AS builder\nRUN go build ./...\n\n" +
"FROM alpine:3.20\nCOPY --from=builder /app/demo /demo\nEXPOSE 8080\n",
"docker-compose.yml": "services:\n api:\n image: demo:latest\n ports:\n" +
" - \"8080:8080\"\n db:\n image: postgres:17\n environment:\n" +
" POSTGRES_PASSWORD: secret\n",
".env.example": "# comment\nDEMO_HTTP_PORT=8080\nDEMO_API_KEY=\n",
"internal/server/routes.go": "package server\n\n" +
"import (\n\t\"net/http\"\n\t\"os\"\n)\n\n" +
"func Routes(mux *http.ServeMux, r chi.Router) {\n" +
"\tmux.HandleFunc(\"GET /healthz\", health)\n" +
"\tmux.HandleFunc(\"/plain\", plainHandler)\n" +
"\tregister(r, http.MethodPost, \"/api/items\", createItem)\n" +
"\tr.Get(\"/api/items/{id}\", getItem)\n" +
"\t_ = os.Getenv(\"DEMO_RUNTIME_SECRET\")\n}\n",
"internal/server/routes_test.go": "package server\n\n" +
"func TestRoutes(t *testing.T) {\n" +
"\t_ = httptest.NewRequest(http.MethodGet, \"/api/jobs\", nil)\n}\n",
"cmd/demo/main.go": "package main\n\nimport \"flag\"\n\nfunc main() {\n" +
"\tport := flag.String(\"port\", \"8080\", \"listen port\")\n" +
"\tverbose := flag.Bool(\"verbose\", false, \"chatty output\")\n" +
"\t_ = port\n\t_ = verbose\n}\n",
"docs/OPERATIONS.md": "# Operations\n\nHow to run the thing.\n",
}
for name, content := range files {
path := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
// A directory that must never be walked.
if err := os.MkdirAll(filepath.Join(root, "node_modules", "left-pad"), 0o755); err != nil {
t.Fatalf("mkdir node_modules: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "node_modules", "index.js"), []byte("x\n"), 0o644); err != nil {
t.Fatalf("write node_modules file: %v", err)
}
return root
}
func analyzeFixture(t *testing.T) *Project {
t.Helper()
p, err := Analyze("demo", fixture(t))
if err != nil {
t.Fatalf("Analyze: %v", err)
}
return p
}
func TestAnalyzeExtractsModuleMetadata(t *testing.T) {
p := analyzeFixture(t)
if len(p.Modules) != 1 {
t.Fatalf("want 1 module, got %d: %+v", len(p.Modules), p.Modules)
}
module := p.Modules[0]
if module.Path != "github.com/danyel/demo" {
t.Errorf("module path = %q", module.Path)
}
if module.GoVersion != "1.27.1" {
t.Errorf("go version = %q", module.GoVersion)
}
if len(module.Direct) != 2 {
t.Errorf("want 2 direct deps, got %d: %+v", len(module.Direct), module.Direct)
}
if module.Direct[0].Path != "github.com/go-chi/chi/v5" || module.Direct[0].Version != "v5.2.3" {
t.Errorf("unexpected first dep: %+v", module.Direct[0])
}
if module.Indirect != 1 {
t.Errorf("want 1 indirect dep, got %d", module.Indirect)
}
}
func TestAnalyzeExtractsAllRouteStyles(t *testing.T) {
p := analyzeFixture(t)
want := map[string]string{
"GET /healthz": "health",
"/plain": "plainHandler",
"POST /api/items": "createItem",
"GET /api/items/{id}": "getItem",
}
for key, handler := range want {
var found *Route
for i := range p.Routes {
if p.Routes[i].String() == key {
found = &p.Routes[i]
}
}
if found == nil {
t.Errorf("route %q not found; got %v", key, routeStrings(p.Routes))
continue
}
if found.Handler != handler {
t.Errorf("route %q handler = %q, want %q", key, found.Handler, handler)
}
}
if len(p.Routes) != len(want) {
t.Errorf("want %d routes, got %d: %v", len(want), len(p.Routes), routeStrings(p.Routes))
}
}
func TestAnalyzeIgnoresRoutesInTestFiles(t *testing.T) {
p := analyzeFixture(t)
for _, route := range p.Routes {
if route.Path == "/api/jobs" {
t.Fatalf("route from a _test.go file leaked into the analysis: %v", route)
}
}
}
func TestAnalyzeExtractsFlagsAndEnvironment(t *testing.T) {
p := analyzeFixture(t)
flags := map[string]Flag{}
for _, flag := range p.Flags {
flags[flag.Name] = flag
}
if flags["port"].Default != "8080" || flags["port"].Usage != "listen port" {
t.Errorf("port flag = %+v", flags["port"])
}
if flags["verbose"].Type != "bool" {
t.Errorf("verbose flag = %+v", flags["verbose"])
}
env := map[string]EnvVar{}
for _, v := range p.EnvVars {
env[v.Name] = v
}
if env["DEMO_RUNTIME_SECRET"].FromEnv {
t.Error("os.Getenv should not be reported as coming from a .env file")
}
if !env["DEMO_HTTP_PORT"].FromEnv || env["DEMO_HTTP_PORT"].Example != "8080" {
t.Errorf("DEMO_HTTP_PORT = %+v", env["DEMO_HTTP_PORT"])
}
}
func TestAnalyzeExtractsOperationalFacts(t *testing.T) {
p := analyzeFixture(t)
if got := p.MakeTargets; len(got) != 2 || got[0] != "build" || got[1] != "test" {
t.Errorf("make targets = %v", got)
}
if p.Docker == nil {
t.Fatal("no Dockerfile analysed")
}
if len(p.Docker.Bases) != 2 || p.Docker.Bases[0] != "golang:1.27-alpine" {
t.Errorf("docker bases = %v", p.Docker.Bases)
}
if len(p.Docker.Exposed) != 1 || p.Docker.Exposed[0] != "8080" {
t.Errorf("docker exposed = %v", p.Docker.Exposed)
}
if len(p.Compose) != 2 {
t.Fatalf("compose services = %+v", p.Compose)
}
if p.Compose[0].Name != "api" || p.Compose[0].Image != "demo:latest" {
t.Errorf("compose[0] = %+v", p.Compose[0])
}
if len(p.Ports) == 0 {
t.Error("expected the compose file port to be picked up")
}
if p.Readme != "README.md" {
t.Errorf("readme = %q", p.Readme)
}
if len(p.Docs) != 2 {
t.Errorf("docs = %+v", p.Docs)
}
}
func TestAnalyzeIgnoresVendoredTrees(t *testing.T) {
p := analyzeFixture(t)
for _, lang := range p.Languages {
if lang.Name == "JavaScript" {
t.Errorf("node_modules leaked into the language table: %+v", p.Languages)
}
}
for _, dir := range p.Dirs {
if dir.Name == "node_modules" {
t.Errorf("node_modules leaked into the directory map: %+v", dir)
}
}
}
func TestSummaryComesFromReadmeProse(t *testing.T) {
p := analyzeFixture(t)
if !strings.HasPrefix(p.Summary, "Demo is a service") {
t.Errorf("summary = %q", p.Summary)
}
if strings.Contains(p.Summary, "Install") {
t.Errorf("summary leaked a later section: %q", p.Summary)
}
}
func TestAnalyzeWithoutReadmeFallsBackToFacts(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
p, err := Analyze("bare", root)
if err != nil {
t.Fatalf("Analyze: %v", err)
}
if p.Summary == "" {
t.Error("a project with no README still needs a summary")
}
if p.Stats.GoFiles != 1 {
t.Errorf("go files = %d", p.Stats.GoFiles)
}
}
func TestSignatureChangesOnlyWithTheTree(t *testing.T) {
root := fixture(t)
first, err := Analyze("demo", root)
if err != nil {
t.Fatal(err)
}
again, err := Analyze("demo", root)
if err != nil {
t.Fatal(err)
}
if first.Signature != again.Signature {
t.Errorf("signature is not stable: %q vs %q", first.Signature, again.Signature)
}
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
changed, err := Analyze("demo", root)
if err != nil {
t.Fatal(err)
}
if changed.Signature == first.Signature {
t.Error("signature did not change after a file was added")
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
func TestRenderProducesAudienceSections(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatalf("Render: %v", err)
}
for _, want := range []string{
"# demo",
"## For Users",
"## For Operators",
"## For Developers",
"### HTTP API",
"| Method | Path | Handler |",
"| `GET` | `/healthz` | `health` |",
"### Direct dependencies",
"`github.com/go-chi/chi/v5` v5.2.3",
"### Network surface",
"`make build`",
} {
if !strings.Contains(page, want) {
t.Errorf("rendered page is missing %q", want)
}
}
}
func TestRenderIsWrappedInMarkers(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.HasPrefix(page, beginPrefix) {
t.Errorf("page does not start with the opening marker:\n%s", firstLine(page))
}
if !strings.HasSuffix(page, EndMarker) {
t.Errorf("page does not end with the closing marker:\n%s", lastLine(page))
}
if !FindBlock(page).Found {
t.Error("FindBlock cannot locate the markers on a freshly rendered page")
}
}
func TestRenderKeepsMarkdownSpacingSane(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatalf("Render: %v", err)
}
// Trim the marker lines: they are not part of the Markdown body.
body := page
if block := FindBlock(page); block.Found {
body = page[block.Body : block.End-len(EndMarker)]
}
lines := strings.Split(body, "\n")
for i := 0; i < len(lines)-1; i++ {
if lines[i] == "" && lines[i+1] == "" {
t.Errorf("blank line %d: two blank lines in a row", i+1)
}
if strings.HasPrefix(lines[i], "#") && lines[i+1] != "" {
t.Errorf("heading on line %d is glued to %q", i+1, lines[i+1])
}
}
if strings.HasPrefix(strings.TrimSpace(lines[0]), "<!--") {
t.Errorf("body starts with a comment: %q", lines[0])
}
}
func TestRenderIsDeterministic(t *testing.T) {
root := fixture(t)
first, err := Analyze("demo", root)
if err != nil {
t.Fatal(err)
}
second, err := Analyze("demo", root)
if err != nil {
t.Fatal(err)
}
a, err := Render(first)
if err != nil {
t.Fatal(err)
}
b, err := Render(second)
if err != nil {
t.Fatal(err)
}
// The only permitted difference is the generation timestamp.
if stampPattern.ReplaceAllString(a, "") != stampPattern.ReplaceAllString(b, "") {
t.Error("Render is not deterministic for an unchanged tree")
}
}
// ---------------------------------------------------------------------------
// Merging
// ---------------------------------------------------------------------------
func TestMergeCreatesWhenNoPageExists(t *testing.T) {
merged, status, err := Merge("", "generated body\n", false)
if err != nil {
t.Fatal(err)
}
if status != StatusCreated {
t.Errorf("status = %q", status)
}
if merged != "generated body\n" {
t.Errorf("merged = %q", merged)
}
}
func TestMergeSkipsUnmarkedPages(t *testing.T) {
const handWritten = "# demo\n\nWritten by a person.\n"
merged, status, err := Merge(handWritten, "generated\n", false)
if err != nil {
t.Fatal(err)
}
if status != StatusSkipped {
t.Errorf("status = %q", status)
}
if merged != handWritten {
t.Errorf("a hand-written page was modified: %q", merged)
}
}
func TestMergeForcesOverUnmarkedPages(t *testing.T) {
merged, status, err := Merge("# demo\n\nWritten by a person.\n", "generated\n", true)
if err != nil {
t.Fatal(err)
}
if status != StatusUpdated {
t.Errorf("status = %q", status)
}
if merged != "generated\n" {
t.Errorf("merged = %q", merged)
}
}
func TestMergePreservesProseOutsideMarkers(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatal(err)
}
withFooter := page + "\n## House rules\n\nNever edit the generated part by hand.\n"
// A second render of the same tree with different content, as if the
// project's README had been rewritten.
block := FindBlock(withFooter)
if !block.Found {
t.Fatal("markers missing from the page under test")
}
updated := strings.Replace(page, "Demo is a service", "Demo is a service, renamed", 1)
if updated == page {
t.Fatal("test setup failed to change the generated content")
}
merged, status, err := Merge(withFooter, updated, false)
if err != nil {
t.Fatal(err)
}
if status != StatusUpdated {
t.Errorf("status = %q", status)
}
if !strings.Contains(merged, "Never edit the generated part by hand.") {
t.Error("hand-written footer was dropped")
}
if !strings.Contains(merged, "Demo is a service, renamed") {
t.Error("generated region was not replaced")
}
if got := strings.Count(merged, EndMarker); got != 1 {
t.Errorf("want exactly 1 closing marker, got %d", got)
}
if got := strings.Count(merged, beginPrefix); got != 1 {
t.Errorf("want exactly 1 opening marker, got %d", got)
}
}
func TestMergeReportsUnchangedAndKeepsTheFileStable(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatal(err)
}
// Storage normalises the trailing newline, so the on-disk page has one.
onDisk := page + "\n"
merged, status, err := Merge(onDisk, page, false)
if err != nil {
t.Fatal(err)
}
if status != StatusUnchanged {
t.Errorf("status = %q, want unchanged", status)
}
if merged != onDisk {
t.Error("an unchanged sync must return the file untouched, timestamp included")
}
}
func TestFindBlockIgnoresMalformedMarkers(t *testing.T) {
tests := map[string]string{
"no markers at all": "# demo\n\nBody.\n",
"only an opening marker": beginPrefix + " source=demo -->\n\nBody.\n",
"only a closing marker": "Body.\n" + EndMarker + "\n",
"marker not at line start": "text " + beginPrefix + " -->\nBody.\n" + EndMarker + "\n",
"markers out of order": EndMarker + "\n" + beginPrefix + " -->\n",
}
for name, content := range tests {
if block := FindBlock(content); block.Found {
t.Errorf("%s: expected no block, got %+v", name, block)
}
}
}
func TestFindBlockReportsBoundaries(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatal(err)
}
block := FindBlock(page)
if !block.Found {
t.Fatal("block not found")
}
if block.Marker != 0 {
t.Errorf("marker offset = %d, want 0", block.Marker)
}
if !strings.HasPrefix(page[block.Body:], "# demo") {
t.Errorf("body does not start after the opening marker line: %q", page[block.Body:block.Body+8])
}
if got := page[block.End:]; got != "" {
t.Errorf("bytes after the block = %q, want none", got)
}
}
func TestWrapNormalisesTrailingNewlines(t *testing.T) {
page := Wrap("demo", "sig", "body\n\n\n")
if strings.HasSuffix(page, "\n") {
t.Error("Wrap must not end with a newline")
}
if !strings.HasSuffix(page, "\n"+EndMarker) {
t.Errorf("the closing marker must start its own line: %q", lastLine(page))
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func routeStrings(routes []Route) []string {
out := make([]string, 0, len(routes))
for _, route := range routes {
out = append(out, route.String())
}
return out
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func lastLine(s string) string {
if i := strings.LastIndexByte(s, '\n'); i >= 0 {
return s[i+1:]
}
return s
}
// TestMergeIgnoresAMinuteOldTimestamp is the regression test for a bug that
// only showed up once a minute: two renders of an unchanged project carry
// different "generated=" stamps, and a pattern that stopped at the first space
// left "15:04 UTC" in place, so every sync in a new minute rewrote all four
// pages and reported "updated" for content that had not changed at all.
func TestMergeIgnoresAMinuteOldTimestamp(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatal(err)
}
// Simulate a page imported an hour ago, down to the stamp.
stale := strings.Replace(page, "generated="+Timestamp(), "generated=2001-01-01 00:00 UTC", 1)
if stale == page {
t.Fatal("could not age the timestamp; the marker format changed")
}
merged, status, err := Merge(stale, page, false)
if err != nil {
t.Fatal(err)
}
if status != StatusUnchanged {
t.Errorf("status = %q, want unchanged for an hour-old page with identical content", status)
}
if merged != stale {
t.Error("an unchanged sync must return the file untouched, timestamp included")
}
// A genuine content change must still be reported, even with an old stamp.
edited := strings.Replace(stale, "# demo", "# demo, revised", 1)
if edited == stale {
t.Fatal("could not edit the title")
}
merged, status, err = Merge(stale, edited, false)
if err != nil {
t.Fatal(err)
}
if status != StatusUpdated {
t.Errorf("status = %q, want updated for a real content change", status)
}
if !strings.Contains(merged, "# demo, revised") {
t.Error("the edit did not reach the merged page")
}
}
// TestWrapStampIsFullyStripped guards the pattern itself: every byte of the
// timestamp has to be removed, not just the date.
func TestWrapStampIsFullyStripped(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatal(err)
}
stripped := stampPattern.ReplaceAllString(page, stampReplacement)
if strings.Contains(stripped, " UTC") {
t.Errorf("the timestamp was not fully stripped: %q", firstLineOf(stripped))
}
if strings.Count(stripped, "-->") != strings.Count(page, "-->") {
t.Error("stripping the stamp swallowed more than the timestamp")
}
}
func firstLineOf(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}

560
backend/docgen/extract.go Normal file
View file

@ -0,0 +1,560 @@
package docgen
import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// ---------------------------------------------------------------------------
// Go source extraction
// ---------------------------------------------------------------------------
var (
// net/http style registrations, including the Go 1.22 method-prefixed form
// mux.HandleFunc("GET /health", h). The literal is captured whole and
// validated in parseRoute, which is where the method prefix is split off.
routeHandleCall = regexp.MustCompile(`\b(?:Handle|HandleFunc)\(\s*"([^"]+)"\s*(?:,\s*([A-Za-z0-9_.]+))?`)
// Route tables that pass the method as an http.MethodXxx constant, as
// go-guess does: register(router, spec, false, http.MethodGet, "/api/health", h)
routeMethodConst = regexp.MustCompile(`\bhttp\.Method([A-Za-z]+)\s*,\s*"(/[^"]*)"\s*(?:,\s*([A-Za-z0-9_.]+)([,)]))?`)
// Router-value registrations: r.Get("/api/x", h.X)
routeRouterCall = regexp.MustCompile(`\b\w+\.(Get|Post|Put|Patch|Delete|Head|Options|Trace)\(\s*"(/[^"]*)"\s*(?:,\s*([A-Za-z0-9_.]+)([,)]))?`)
// "GET /api/v1/x" as a literal argument.
methodPrefix = regexp.MustCompile(`^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\S.*)$`)
// flag.String("name", default, "usage")
flagCall = regexp.MustCompile(`flag\.(\w+)\(\s*"([^"]+)"\s*(?:,\s*([^,)]+))?\s*(?:,\s*"([^"]*)")?`)
// os.Getenv("X") / os.LookupEnv("X")
envCall = regexp.MustCompile(`os\.(?:Getenv|LookupEnv)\(\s*"([A-Z][A-Z0-9_]*)"`)
// Port literals anywhere in the project.
portLiteral = regexp.MustCompile(`(?:\bPORT\b|\bport\b|EXPOSE)[^0-9]{0,20}([0-9]{2,5})\b`)
testFuncDecl = regexp.MustCompile(`(?m)^func\s+(Test|Benchmark|Fuzz)\w*\(`)
)
// analyzeGoFile pulls routes, flags and environment variables out of one source
// file. Each extractor is independent: a syntax oddity in one never affects the
// others because these are textual scans, not a type-checked AST walk.
//
// Test files are excluded. They build requests with httptest.NewRequest, which
// looks exactly like a route registration, and a route table is never declared
// in a _test.go file.
func analyzeGoFile(p *Project, file scannedFile) {
if strings.HasSuffix(file.rel, "_test.go") {
return
}
source := file.text()
if source == "" {
return
}
for _, match := range routeHandleCall.FindAllStringSubmatch(source, -1) {
if route := parseRoute(match[1], match[2]); route.Path != "" {
p.Routes = append(p.Routes, route)
}
}
for _, match := range routeMethodConst.FindAllStringSubmatch(source, -1) {
p.Routes = append(p.Routes, Route{
Method: strings.ToUpper(match[1]),
Path: match[2],
Handler: handlerName(match[3]),
})
}
for _, match := range routeRouterCall.FindAllStringSubmatch(source, -1) {
p.Routes = append(p.Routes, Route{
Method: strings.ToUpper(match[1]),
Path: match[2],
Handler: handlerName(match[3]),
})
}
for _, match := range flagCall.FindAllStringSubmatch(source, -1) {
p.Flags = append(p.Flags, Flag{
Name: match[2],
Type: strings.ToLower(match[1]),
Default: unquote(strings.TrimSpace(match[3])),
Usage: strings.TrimSpace(match[4]),
})
}
for _, match := range envCall.FindAllStringSubmatch(source, -1) {
p.EnvVars = append(p.EnvVars, EnvVar{Name: match[1]})
}
}
// parseRoute splits an optional method prefix and normalises the path. A
// literal that is not a route — a query key, a header name, a MIME type — is
// rejected by requiring the path to look like one.
func parseRoute(raw, handler string) Route {
method, path := "", strings.TrimSpace(raw)
if match := methodPrefix.FindStringSubmatch(raw); match != nil {
method, path = match[1], strings.TrimSpace(match[2])
}
if !strings.HasPrefix(path, "/") {
return Route{}
}
return Route{Method: method, Path: path, Handler: handlerName(handler)}
}
// handlerNames that are never useful in a route table: they are literal
// arguments, receivers or framework values rather than handler functions.
var noiseHandlers = map[string]struct{}{
"nil": {}, "true": {}, "false": {}, "spec": {}, "router": {}, "mux": {},
"r": {}, "g": {}, "chi": {}, "group": {}, "protected": {}, "public": {},
"api": {}, "v1": {}, "sub": {}, "app": {}, "server": {}, "srv": {},
"ctx": {}, "c": {}, "w": {}, "req": {}, "request": {}, "http": {},
}
// handlerName reduces a handler expression to a readable name, dropping the
// receiver and rejecting anything that is not plainly a function reference.
func handlerName(handler string) string {
handler = strings.TrimSpace(handler)
if handler == "" {
return ""
}
if i := strings.LastIndex(handler, "."); i >= 0 {
handler = handler[i+1:]
}
handler = strings.TrimSuffix(handler, ".func1")
if _, noise := noiseHandlers[handler]; noise {
return ""
}
if handler == "HandlerFunc" || strings.Contains(handler, "(") {
return ""
}
return handler
}
// ---------------------------------------------------------------------------
// go.mod
// ---------------------------------------------------------------------------
var (
modLine = regexp.MustCompile(`^module\s+(\S+)`)
goVersionLine = regexp.MustCompile(`^go\s+(\S+)`)
requireLine = regexp.MustCompile(`^\s*(\S+)\s+(v\S+)(.*)$`)
)
// parseGoMod reads a go.mod outside any require block.
func parseGoMod(rel, source string) Module {
module := Module{File: rel}
inBlock := false
for _, raw := range strings.Split(source, "\n") {
line := strings.TrimRight(raw, " \t")
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "module "):
if module.Path == "" {
module.Path = strings.TrimSpace(strings.TrimPrefix(trimmed, "module "))
}
case strings.HasPrefix(trimmed, "go "):
if module.GoVersion == "" {
if match := goVersionLine.FindStringSubmatch(trimmed); match != nil {
module.GoVersion = match[1]
}
}
case trimmed == "require (":
inBlock = true
case inBlock && trimmed == ")":
inBlock = false
case inBlock && trimmed != "" && !strings.HasPrefix(trimmed, "//"):
match := requireLine.FindStringSubmatch(line)
if match != nil {
addDependency(&module, match[1], match[2], strings.Contains(match[3], "// indirect"))
}
case strings.HasPrefix(trimmed, "require ") && !inBlock:
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "require "))
if match := requireLine.FindStringSubmatch(" " + rest); match != nil {
addDependency(&module, match[1], match[2], strings.Contains(match[3], "// indirect"))
}
}
}
return module
}
func addDependency(module *Module, path, version string, indirect bool) {
if path == "" || strings.HasPrefix(path, "//") {
return
}
if indirect {
module.Indirect++
return
}
module.Direct = append(module.Direct, Dependency{Path: path, Version: version})
}
// ---------------------------------------------------------------------------
// Makefile
// ---------------------------------------------------------------------------
var makeTargetLine = regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9_.-]*):`)
// makeTargets extracts invocable targets, skipping .PHONY declarations and
// variable assignments.
func makeTargets(source string) []string {
var targets []string
phony := map[string]struct{}{}
for _, raw := range strings.Split(source, "\n") {
if strings.HasPrefix(raw, "\t") || strings.HasPrefix(raw, " ") {
continue
}
line := strings.TrimRight(raw, " \t")
if strings.HasPrefix(line, ".PHONY") {
for _, name := range strings.Fields(line) {
if name == ".PHONY:" || name == ".PHONY" {
continue
}
phony[strings.TrimSuffix(name, ":")] = struct{}{}
}
continue
}
match := makeTargetLine.FindStringSubmatch(line)
if match == nil {
continue
}
name := match[1]
if strings.HasPrefix(name, ".") {
continue
}
if _, isPhony := phony[name]; isPhony {
continue
}
targets = append(targets, name)
}
return targets
}
// ---------------------------------------------------------------------------
// Docker
// ---------------------------------------------------------------------------
var (
dockerFrom = regexp.MustCompile(`(?mi)^\s*FROM\s+(\S+)(?:\s+AS\s+(\S+))?`)
dockerExp = regexp.MustCompile(`(?mi)^\s*EXPOSE\s+(\S+)`)
)
func parseDockerfile(rel, source string) *DockerInfo {
info := &DockerInfo{File: rel}
for _, match := range dockerFrom.FindAllStringSubmatch(source, -1) {
info.Bases = append(info.Bases, match[1])
if match[2] != "" {
info.Stages = append(info.Stages, match[2])
}
}
for _, match := range dockerExp.FindAllStringSubmatch(source, -1) {
info.Exposed = append(info.Exposed, match[1])
}
// Order matters: the first FROM is the build stage and the last is the
// runtime image, so these lists are deduplicated but never sorted.
info.Bases = dedupeInOrder(info.Bases)
info.Stages = dedupeInOrder(info.Stages)
info.Exposed = dedupeInOrder(info.Exposed)
return info
}
// ---------------------------------------------------------------------------
// docker-compose
// ---------------------------------------------------------------------------
// parseCompose reads service names, images and published ports out of a compose
// file. It is a line scanner rather than a YAML parser: compose files in these
// projects are consistently indented two spaces per level, and avoiding a YAML
// dependency keeps the module dependency-free.
func parseCompose(rel, source string) []ComposeService {
var (
services []ComposeService
inBlock bool
current int = -1
)
for _, raw := range strings.Split(source, "\n") {
line := strings.TrimRight(raw, " \t")
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
indent := len(line) - len(strings.TrimLeft(line, " "))
if indent == 0 {
inBlock = trimmed == "services:"
current = -1
continue
}
if !inBlock {
continue
}
if indent == 2 && strings.HasSuffix(trimmed, ":") {
services = append(services, ComposeService{Name: strings.TrimSuffix(trimmed, ":"), File: rel})
current = len(services) - 1
continue
}
if current < 0 || indent != 4 {
continue
}
key, value, found := strings.Cut(trimmed, ":")
if !found {
continue
}
switch strings.TrimSpace(key) {
case "image":
services[current].Image = strings.Trim(strings.TrimSpace(value), `"'`)
case "ports":
if services[current].Ports == "" {
services[current].Ports = strings.Trim(strings.TrimSpace(value), `"'`)
} else {
services[current].Ports += ", " + strings.Trim(strings.TrimSpace(value), `"'`)
}
}
}
// Drop the well-known internal networks and volume declarations that the
// indentation scanner otherwise mistakes for services.
names := map[string]struct{}{}
for _, name := range []string{
"networks", "volumes", "configs", "secrets", "services",
} {
names[name] = struct{}{}
}
out := services[:0]
for _, service := range services {
if _, skip := names[service.Name]; skip {
continue
}
out = append(out, service)
}
return out
}
// ---------------------------------------------------------------------------
// .env files
// ---------------------------------------------------------------------------
// unquote strips the quoting Go string literals carry around literal values.
func unquote(value string) string {
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '`' && value[len(value)-1] == '`') {
return value[1 : len(value)-1]
}
}
return value
}
// envFromExample reads KEY=VALUE pairs out of a .env or .env.example file.
func envFromExample(source string) []EnvVar {
var out []EnvVar
for _, raw := range strings.Split(source, "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, "=")
if !found {
continue
}
key = strings.TrimSpace(key)
if key == "" || !strings.ContainsAny(key, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
continue
}
out = append(out, EnvVar{Name: key, Example: strings.Trim(strings.TrimSpace(value), `"'`), FromEnv: true})
}
return out
}
// ---------------------------------------------------------------------------
// Misc
// ---------------------------------------------------------------------------
// portsIn extracts plausible TCP port literals from any file.
func portsIn(source string) []string {
if source == "" {
return nil
}
var out []string
for _, match := range portLiteral.FindAllStringSubmatch(source, -1) {
port, err := strconv.Atoi(match[1])
if err != nil || port < 1024 || port > 65535 {
continue
}
out = append(out, match[1])
}
return out
}
// portBearingFiles reports whether a file is a plausible source of a declared
// port. Scanning every source file for port literals produces noise (version
// numbers, row counts), so only operational files are considered.
func portBearingFiles(rel string) bool {
base := strings.ToLower(filepath.Base(rel))
switch {
case isDockerfile(rel), isComposeFile(rel), isHelmFile(rel):
return true
case base == "makefile", base == "makefile.am":
return true
case base == "readme.md":
return true
case strings.HasPrefix(base, ".env"):
return true
case strings.HasSuffix(base, ".service"), strings.HasSuffix(base, ".env.example"):
return true
default:
return false
}
}
// isHelmFile reports whether a path is part of a Helm chart, anchored on the two
// files that define one.
func isHelmFile(rel string) bool {
base := strings.ToLower(filepath.Base(rel))
return base == "chart.yaml" || base == "values.yaml"
}
// markdownTitle returns the first heading of a Markdown document.
func markdownTitle(source string) string {
for _, raw := range strings.Split(source, "\n") {
line := strings.TrimSpace(raw)
if strings.HasPrefix(line, "#") {
return strings.TrimSpace(strings.TrimLeft(line, "#"))
}
if line != "" {
return ""
}
}
return ""
}
// findReadme locates the project's README, preferring the repository root.
func findReadme(p *Project) string {
for _, candidate := range []string{"README.md", "readme.md", "README.markdown", "Readme.md"} {
if info, err := os.Stat(filepath.Join(p.Path, candidate)); err == nil && !info.IsDir() {
return candidate
}
}
for _, doc := range p.Docs {
if strings.EqualFold(filepath.Base(doc.Path), "readme.md") {
return doc.Path
}
}
return ""
}
// summarize produces the one-paragraph preamble shown above the audience tabs.
// It prefers the README's opening prose and falls back to a description derived
// from what the analyser found, so a project without a README still gets one.
func summarize(p *Project) string {
if p.Readme != "" {
if data, err := readCapped(p.Path+"/"+p.Readme, maxReadBytes); err == nil {
if summary := firstParagraph(string(data)); summary != "" {
return collapse(summary, 460)
}
}
}
parts := []string{}
if module := primaryModule(p); module != "" {
parts = append(parts, "Go module `"+module+"`")
}
if p.Stats.GoFiles > 0 {
parts = append(parts, plural(p.Stats.GoFiles, "Go source file", "Go source files"))
}
if len(p.Routes) > 0 {
parts = append(parts, plural(len(p.Routes), "HTTP endpoint", "HTTP endpoints"))
}
if len(p.EnvVars) > 0 {
parts = append(parts, plural(len(p.EnvVars), "environment variable", "environment variables"))
}
if len(parts) == 0 {
return "This project has no README yet. The sections below were derived from its structure on disk."
}
return "This project has no README yet. The analyser found " + joinList(parts) + "."
}
// firstParagraph returns the opening prose of a document: the first run of
// non-empty lines that is not a heading, badge, table or HTML block.
func firstParagraph(source string) string {
var lines []string
for _, raw := range strings.Split(source, "\n") {
trimmed := strings.TrimSpace(raw)
if strings.HasPrefix(trimmed, "```") {
break // prose never starts after a code fence in these READMEs
}
switch {
case trimmed == "":
if len(lines) > 0 {
return strings.Join(lines, " ")
}
case strings.HasPrefix(trimmed, "#"):
continue
case strings.HasPrefix(trimmed, "!"), strings.HasPrefix(trimmed, "[!"),
strings.HasPrefix(trimmed, "<"), strings.HasPrefix(trimmed, "|"):
continue // badge, HTML or table row
default:
lines = append(lines, trimmed)
}
}
return strings.Join(lines, " ")
}
// collapse folds whitespace and truncates on a word boundary.
func collapse(text string, limit int) string {
fields := strings.Fields(text)
if len(fields) == 0 {
return ""
}
out := strings.Join(fields, " ")
if len(out) <= limit {
return out
}
cut := out[:limit]
if i := strings.LastIndexByte(cut, ' '); i > limit/2 {
cut = cut[:i]
}
return strings.TrimRight(cut, " ,;:.") + "…"
}
func primaryModule(p *Project) string {
for _, module := range p.Modules {
if module.Path != "" {
return module.Path
}
}
return ""
}
func plural(n int, singular, many string) string {
if n == 1 {
return "1 " + singular
}
return strconv.Itoa(n) + " " + many
}
func joinList(parts []string) string {
switch len(parts) {
case 0:
return ""
case 1:
return parts[0]
case 2:
return parts[0] + " and " + parts[1]
default:
return strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1]
}
}

143
backend/docgen/merge.go Normal file
View file

@ -0,0 +1,143 @@
package docgen
import (
"fmt"
"regexp"
"strings"
)
// beginPrefix opens the machine-written region of an imported page. Everything
// between begin and EndMarker is replaced on every sync; anything outside it is
// preserved exactly as written.
const beginPrefix = "<!-- go-doc:generated:begin"
// EndMarker closes the machine-written region.
const EndMarker = "<!-- go-doc:generated:end -->"
// stampPattern matches the generated=<time> field of the opening marker. It is
// stripped before comparing an existing page with a freshly rendered one, because
// the timestamp changes on every run and would otherwise make every sync look
// like a real edit.
//
// The field has to be matched all the way to the "-->" that closes the comment:
// Timestamp writes "2006-01-02 15:04 UTC", so a pattern that stopped at the
// first space would leave the time behind and every sync that crossed a minute
// boundary would rewrite all four pages for nothing.
var stampPattern = regexp.MustCompile(`generated=[^\n]*?-->`)
// stampReplacement stands in for the stripped timestamp on both sides of the
// comparison, so an old page and a fresh render still compare equal.
const stampReplacement = "generated=STAMP-->"
// Status describes what Merge did, so callers can log precisely.
type Status string
// Possible merge outcomes.
const (
// StatusCreated means no file existed, so the page was written whole.
StatusCreated Status = "created"
// StatusUpdated means the generated region was replaced in place.
StatusUpdated Status = "updated"
// StatusUnchanged means the generated region already matched.
StatusUnchanged Status = "unchanged"
// StatusSkipped means the file had no generated markers and force was off.
StatusSkipped Status = "skipped"
)
// Block locates the three boundaries of the generated region inside a page:
// the opening marker, the body that follows it, and the byte after the closing
// marker. All offsets are absolute and end-exclusive where it matters, so a
// caller can rebuild a page as prefix + replacement + suffix.
type Block struct {
// Marker is the index of the opening marker.
Marker int
// Body is the index of the first byte after the opening marker's line.
Body int
// End is the index of the first byte after the closing marker.
End int
Found bool
}
// FindBlock locates the generated markers. Both must be present and the opening
// marker must sit at the start of a line, otherwise Found is false — which is
// the signal that a file was authored by hand and must not be clobbered.
func FindBlock(content string) Block {
start := strings.Index(content, beginPrefix)
if start < 0 {
return Block{}
}
// The marker must own its line: either it opens the file or a newline
// precedes it.
if start > 0 && content[start-1] != '\n' {
return Block{}
}
lineEnd := strings.IndexByte(content[start:], '\n')
if lineEnd < 0 {
return Block{}
}
body := start + lineEnd + 1
rest := content[body:]
end := strings.Index(rest, EndMarker)
if end < 0 {
return Block{}
}
return Block{
Marker: start,
Body: body,
End: body + end + len(EndMarker),
Found: true,
}
}
// Merge folds a freshly generated page into the existing file.
//
// Hand-written prose outside the markers survives untouched. A file with no
// markers at all is left alone unless force is set, so importing can never
// destroy a document a human wrote. An identical page is reported unchanged and
// returned verbatim, so a no-op sync does not rewrite the file or churn its
// modification time.
func Merge(existing string, generated string, force bool) (string, Status, error) {
if strings.TrimSpace(existing) == "" {
return generated, StatusCreated, nil
}
block := FindBlock(existing)
if !block.Found {
if !force {
return existing, StatusSkipped, nil
}
return generated, StatusUpdated, nil
}
// The replacement carries its own markers, so the preserved prefix stops
// before the old opening marker rather than after it.
var b strings.Builder
b.WriteString(existing[:block.Marker])
b.WriteString(generated)
b.WriteString(existing[block.End:])
merged := b.String()
if stampPattern.ReplaceAllString(merged, stampReplacement) ==
stampPattern.ReplaceAllString(existing, stampReplacement) {
return existing, StatusUnchanged, nil
}
return merged, StatusUpdated, nil
}
// Wrap surrounds body with the generated markers, stamping provenance so a
// reader can tell which project and which source revision produced the page.
//
// The body is normalised to exactly one trailing newline so the closing marker
// always starts its own line, and no newline is emitted after that marker: the
// marker is the last thing Wrap writes, so replacing a region in place and
// keeping the following bytes of the file cannot double the trailing blank line.
func Wrap(source, signature, body string) string {
return fmt.Sprintf(
"%s source=%s signature=%s generated=%s -->\n%s\n%s",
beginPrefix, source, signature, Timestamp(),
strings.TrimRight(body, "\n"), EndMarker,
)
}

565
backend/docgen/render.go Normal file
View file

@ -0,0 +1,565 @@
package docgen
import (
"fmt"
"sort"
"strings"
)
// Render produces the full Markdown page for a project, wrapped in generated
// markers. The output always carries the three level-2 audience headings that
// go-doc's reader keys off, so an imported page is immediately navigable.
func Render(p *Project) (string, error) {
view := buildView(p)
var b builder
b.h1(p.Name)
b.para(p.Summary)
b.table(
[]string{"", ""},
[]row{
{"Module", view.ModuleSummary},
{"Endpoints", view.EndpointCount},
{"Components", view.ComponentCount},
{"Tests", view.TestSummary},
{"Go lines", view.LineSummary},
},
)
b.note("Everything between the generated markers is rewritten by every sync. " +
"Notes written outside the markers are kept.")
users(&b, view)
operators(&b, view)
developers(&b, view)
return Wrap(p.Name, p.Signature, strings.TrimRight(b.String(), "\n")), nil
}
// ---------------------------------------------------------------------------
// Audience sections
// ---------------------------------------------------------------------------
func users(b *builder, v docView) {
b.h2("For Users")
b.h3("What this application does")
b.para(v.Summary)
if v.HasReadme {
b.para(fmt.Sprintf(
"The project ships its own README at `%s`, which is the long form of this page. "+
"The three audience tabs split the same facts three ways so you only read the part "+
"that concerns you.", v.Readme))
} else {
b.para("This project ships no README, so the summary above was derived from the source " +
"tree. If that is wrong, write a README and re-run the sync.")
}
b.h3("Reading this in go-doc")
b.para("Open the page and pick the tab that matches you:")
b.bullets([]string{
"**User** — you use the finished application. This section is all you need.",
"**Operator** — you run it. Configuration, ports and deployment notes are under that tab.",
"**Developer** — you integrate with it or change it. The API and layout tables are under that tab.",
})
b.para("The feedback panel stores a note per audience, tagged with your badge, so operators " +
"and developers can see which audience raised it.")
if len(v.Docs) > 0 {
b.h3("Where the original documentation lives")
b.para(fmt.Sprintf(
"This project already carries %s. They stay in the project repository: this page "+
"points at them instead of copying them, so there is always exactly one copy of "+
"the truth.", plural(len(v.Docs), "Markdown document", "Markdown documents")))
rows := make([]row, 0, len(v.Docs))
for _, doc := range v.Docs {
rows = append(rows, row{"`" + doc.Path + "`", fmt.Sprintf("%d bytes", doc.Bytes)})
}
b.table([]string{"Document", "Size"}, rows)
}
}
func operators(b *builder, v docView) {
b.rule()
b.h2("For Operators")
b.h3("Running it locally")
if len(v.MakeTargets) > 0 {
b.para(fmt.Sprintf("The root Makefile defines %s:",
plural(len(v.MakeTargets), "target", "targets")))
b.bullets(backtickEach(v.MakeTargets, "make "))
} else {
b.para("This project has no root Makefile, so the binary or container is run directly.")
}
if len(v.Compose) > 0 {
b.h3("Local containers")
b.para("The compose file defines these services:")
rows := make([]row, 0, len(v.Compose))
for _, service := range v.Compose {
image := "built locally"
if service.Image != "" {
image = "`" + service.Image + "`"
}
ports := "internal only"
if service.Ports != "" {
ports = service.Ports
}
rows = append(rows, row{service.Name, image, ports})
}
b.table([]string{"Service", "Image", "Published ports"}, rows)
}
if v.Docker != nil {
b.h3("Image")
var sentence strings.Builder
fmt.Fprintf(&sentence, "`%s` builds from", v.Docker.File)
for i, base := range v.Docker.Bases {
if i > 0 {
sentence.WriteString(",")
}
fmt.Fprintf(&sentence, " `%s`", base)
}
if len(v.Docker.Stages) > 0 {
fmt.Fprintf(&sentence, " in stages: %s", strings.Join(v.Docker.Stages, ", "))
}
if len(v.Docker.Exposed) > 0 {
fmt.Fprintf(&sentence, " and exposes %s", strings.Join(v.Docker.Exposed, ", "))
}
sentence.WriteString(".")
b.para(sentence.String())
}
b.h3("Configuration")
if len(v.EnvVars) > 0 {
b.para("Every environment variable found in the source or in a `.env` file:")
rows := make([]row, 0, len(v.EnvVars))
for _, env := range v.EnvVars {
example := "—"
if env.Example != "" {
example = "`" + env.Example + "`"
}
source := "source"
if env.FromEnv {
source = ".env file"
}
rows = append(rows, row{"`" + env.Name + "`", example, source})
}
b.table([]string{"Variable", "Example", "Found in"}, rows)
} else {
b.para("No environment variables were found in the source or in a `.env` file.")
}
if len(v.Flags) > 0 {
b.para("Command-line flags override the environment where both exist:")
rows := make([]row, 0, len(v.Flags))
for _, flag := range v.Flags {
decl := "-" + flag.Name
if flag.Default != "" {
decl += "=" + flag.Default
}
def := "—"
if flag.Default != "" {
def = "`" + flag.Default + "`"
}
usage := "—"
if flag.Usage != "" {
usage = collapse(flag.Usage, 90)
}
rows = append(rows, row{"`" + decl + "`", def, usage})
}
b.table([]string{"Flag", "Default", "Purpose"}, rows)
}
b.h3("Network surface")
if len(v.Ports) > 0 {
quoted := make([]string, 0, len(v.Ports))
for _, port := range v.Ports {
quoted = append(quoted, "`"+port+"`")
}
b.para("Ports referenced across the project: " + strings.Join(quoted, ", ") + ".")
} else {
b.para("No explicit port declaration was found. Check the README and the compose file " +
"before assuming a default.")
}
b.h3("Deployment artefacts")
if len(v.HelmCharts) > 0 {
b.para(fmt.Sprintf("Helm %s found:",
plural(len(v.HelmCharts), "chart", "charts")))
b.bullets(backtickEach(v.HelmCharts, ""))
} else {
b.para("No Helm chart was found, so deployment is expected to be plain Docker or a " +
"managed platform.")
}
if len(v.OperatorDocs) > 0 {
b.h3("Operations documentation that already exists")
b.para("Read these in place rather than duplicating them here:")
b.bullets(docBullets(v.OperatorDocs))
}
for _, note := range v.Notes {
b.note(note)
}
}
func developers(b *builder, v docView) {
b.rule()
b.h2("For Developers")
b.h3("Module layout")
if len(v.ModuleSummaries) > 0 {
b.bullets(v.ModuleSummaries)
} else {
b.para("No go.mod in this tree: the project may be vendored, a workspace member, or " +
"not Go at all.")
}
b.h3("HTTP API")
if len(v.Routes) > 0 {
b.para("Every route registration found in the source:")
rows := make([]row, 0, len(v.Routes))
for _, route := range v.Routes {
method := "ANY"
if route.Method != "" {
method = "`" + route.Method + "`"
}
handler := "—"
if route.Handler != "" {
handler = "`" + route.Handler + "`"
}
rows = append(rows, row{method, "`" + route.Path + "`", handler})
}
b.table([]string{"Method", "Path", "Handler"}, rows)
} else {
b.para("No route registrations were detected, which is expected for a CLI, a library " +
"or a worker.")
}
b.h3("Direct dependencies")
if deps := v.DirectDeps(); len(deps) > 0 {
items := make([]string, 0, len(deps))
for _, dep := range deps {
items = append(items, "`"+dep.Path+"` "+dep.Version)
}
b.bullets(items)
} else {
b.para("No third-party dependencies are declared.")
}
b.h3("Directory map")
if len(v.Dirs) > 0 {
rows := make([]row, 0, len(v.Dirs))
for _, dir := range v.Dirs {
rows = append(rows, row{
"`" + dir.Name + "/`",
fmt.Sprintf("%d", dir.Files),
fmt.Sprintf("%d", dir.Lines),
directoryPurpose(dir.Name),
})
}
b.table([]string{"Directory", "Files", "Lines", "Likely purpose"}, rows)
} else {
b.para("No first-level directories were found.")
}
b.h3("Languages")
if len(v.Languages) > 0 {
items := make([]string, 0, len(v.Languages))
for _, lang := range v.Languages {
items = append(items, fmt.Sprintf("**%s** — %s, %d lines", lang.Name,
plural(lang.Files, "file", "files"), lang.Lines))
}
b.bullets(items)
} else {
b.para("No source files were recognised.")
}
if len(v.DeveloperDocs) > 0 {
b.h3("Developer documentation that already exists")
b.bullets(docBullets(v.DeveloperDocs))
}
}
// ---------------------------------------------------------------------------
// Markdown builder
// ---------------------------------------------------------------------------
// builder assembles Markdown with predictable spacing. Block elements ask for a
// separator instead of writing blank lines by hand, so every block is preceded
// by exactly one blank line no matter which branch produced it — the failure
// mode this replaces was headings glued to the paragraph above them.
type builder struct {
out strings.Builder
lastBlank bool
}
func (b *builder) String() string { return b.out.String() }
// ensureBlank separates the next line from the previous one unless it already is.
func (b *builder) ensureBlank() {
if b.out.Len() == 0 || b.lastBlank {
return
}
b.out.WriteString("\n")
}
func (b *builder) line(text string) {
b.out.WriteString(text)
b.out.WriteString("\n")
b.lastBlank = text == ""
}
func (b *builder) h1(text string) { b.ensureBlank(); b.line("# " + text) }
func (b *builder) h2(text string) { b.ensureBlank(); b.line("## " + text) }
func (b *builder) h3(text string) { b.ensureBlank(); b.line("### " + text) }
func (b *builder) para(text string) { b.ensureBlank(); b.line(text) }
func (b *builder) note(text string) { b.ensureBlank(); b.line("> " + text) }
// rule writes a horizontal rule, which always gets its own block.
func (b *builder) rule() { b.ensureBlank(); b.line("---") }
// bullets writes a tight list: the separator goes before the list, never between
// its items.
func (b *builder) bullets(items []string) {
if len(items) == 0 {
return
}
b.ensureBlank()
for _, item := range items {
b.line("- " + item)
}
}
// row is one Markdown table row.
type row []string
// table writes a GitHub-flavoured table. Cell content is emitted verbatim: every
// caller has already added its own backticks, so the builder must not guess.
func (b *builder) table(headers []string, rows []row) {
b.ensureBlank()
cells := make([]string, len(headers))
for i, header := range headers {
cells[i] = header
}
b.line("| " + strings.Join(cells, " | ") + " |")
separators := make([]string, len(headers))
for i := range separators {
separators[i] = "---"
}
b.line("| " + strings.Join(separators, " | ") + " |")
for _, r := range rows {
b.line("| " + strings.Join(r, " | ") + " |")
}
}
// backtickEach renders values as a list of code-formatted bullet items.
func backtickEach(values []string, prefix string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
out = append(out, "`"+prefix+value+"`")
}
return out
}
// docBullets renders discovered documents as code-formatted bullets with their
// titles when they have one.
func docBullets(docs []DocFile) []string {
out := make([]string, 0, len(docs))
for _, doc := range docs {
item := "`" + doc.Path + "`"
if doc.Title != "" {
item += " — " + doc.Title
}
out = append(out, item)
}
return out
}
// ---------------------------------------------------------------------------
// View model
// ---------------------------------------------------------------------------
// docView is the pre-computed, presentation-ready view of a project.
type docView struct {
*Project
ModuleSummary string
EndpointCount string
ComponentCount string
TestSummary string
LineSummary string
HasReadme bool
OperatorDocs []DocFile
DeveloperDocs []DocFile
ModuleSummaries []string
Notes []string
}
// DirectDeps flattens the direct requirements of every module in the tree.
func (v docView) DirectDeps() []Dependency {
var out []Dependency
for _, m := range v.Modules {
out = append(out, m.Direct...)
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out
}
func buildView(p *Project) docView {
v := docView{Project: p}
switch module := primaryModule(p); {
case module == "":
v.ModuleSummary = "no go.mod in this tree"
default:
parts := []string{module}
for _, m := range p.Modules {
if m.GoVersion != "" {
parts = append(parts, "go "+m.GoVersion)
break
}
}
parts = append(parts, plural(p.Stats.GoFiles, "Go file", "Go files"))
v.ModuleSummary = strings.Join(parts, " · ")
}
v.EndpointCount = plural(len(p.Routes), "endpoint", "endpoints")
v.ComponentCount = plural(p.Stats.Components, "component directory", "component directories")
v.LineSummary = plural(p.Stats.TotalLines, "line", "lines")
v.HasReadme = p.Readme != ""
if p.Stats.TestFuncs > 0 {
v.TestSummary = fmt.Sprintf("%d in %s", p.Stats.TestFuncs,
plural(p.Stats.TestFiles, "file", "files"))
} else {
v.TestSummary = "none detected"
}
// Route existing project docs under the audience that most likely owns them,
// so the generated page points rather than duplicates.
for _, doc := range p.Docs {
lower := strings.ToLower(doc.Path)
switch {
case containsAny(lower, "operat", "runbook", "deploy", "infra", "install", "troubleshoot"):
v.OperatorDocs = append(v.OperatorDocs, doc)
case containsAny(lower, "architect", "develop", "api", "integration", "contract", "coding", "design"):
v.DeveloperDocs = append(v.DeveloperDocs, doc)
}
}
for _, m := range p.Modules {
var summary strings.Builder
summary.WriteString(m.File)
if m.Path != "" {
summary.WriteString(" — module " + m.Path)
}
if m.GoVersion != "" {
summary.WriteString(", go " + m.GoVersion)
}
fmt.Fprintf(&summary, ", %d direct and %d indirect dependencies", len(m.Direct), m.Indirect)
v.ModuleSummaries = append(v.ModuleSummaries, summary.String())
}
// Anything a reader should know before trusting the generated content.
if !v.HasReadme {
v.Notes = append(v.Notes,
"This project ships no README, so the summary was derived from the tree layout. Add one and re-run the sync.")
}
if len(p.Modules) == 0 {
v.Notes = append(v.Notes, "No go.mod was found, so this tree was analysed as plain source files.")
}
if p.Docker == nil {
v.Notes = append(v.Notes, "No Dockerfile was found at any level.")
}
if len(p.Routes) == 0 && len(p.Modules) > 0 {
v.Notes = append(v.Notes,
"No HTTP route registrations were detected. If this is a server, the API table is incomplete — check for routes built dynamically.")
}
return v
}
func containsAny(haystack string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(haystack, needle) {
return true
}
}
return false
}
// directoryPurpose guesses what a top-level directory is for. It is a heuristic
// on names only, and it admits ignorance rather than inventing an answer.
func directoryPurpose(name string) string {
switch strings.ToLower(name) {
case "internal":
return "unimportable application internals"
case "pkg":
return "public library packages"
case "cmd", "bin", "main":
return "binary entry points"
case "api":
return "HTTP handlers and routing"
case "server":
return "server entry point"
case "services", "service":
return "domain services"
case "web", "ui", "frontend", "client":
return "frontend assets"
case "bdd", "e2e", "tests", "test", "features":
return "tests"
case "deploy", "deployment", "charts", "helm", "k8s", "kubernetes":
return "deployment manifests"
case "docker", "scripts", "script", "hack":
return "operational tooling"
case "docs", "documentation":
return "project documentation"
case "migrations":
return "schema migrations"
case "contracts", "openapi", "proto", "schemas":
return "shared schemas or contracts"
case "data":
return "flat-file data"
case "dist", "build", "out":
return "build output"
case "examples", "example":
return "worked examples"
case "auth":
return "authentication"
case "config":
return "configuration loading"
case "security":
return "security controls"
case "store", "storage":
return "persistence layer"
case "database":
return "database access"
case "models", "model", "schema":
return "domain models"
case "middleware":
return "HTTP middleware"
case "templates", "views":
return "render templates"
case "assets", "static":
return "static assets"
case "feedback", "comments":
return "user feedback"
default:
return "unknown — inspect directly"
}
}