initial commit
This commit is contained in:
commit
23816d00f7
71 changed files with 14497 additions and 0 deletions
728
backend/docgen/docgen.go
Normal file
728
backend/docgen/docgen.go
Normal 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") }
|
||||
621
backend/docgen/docgen_test.go
Normal file
621
backend/docgen/docgen_test.go
Normal 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
560
backend/docgen/extract.go
Normal 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
143
backend/docgen/merge.go
Normal 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
565
backend/docgen/render.go
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue