Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/go-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ on:
required: false
type: string
default: "."
doc-coverage:
description: "Run the doccov README<->builtin consistency gate (starpkg domain modules)."
required: false
type: boolean
default: false
secrets:
CODECOV_TOKEN:
required: false
Expand Down Expand Up @@ -97,6 +102,13 @@ jobs:
run: |
go mod tidy
git diff --exit-code -- go.mod go.sum
# Doc coverage (opt-in): every starlark.NewBuiltin a module exposes to
# scripts must be documented in its README. The gate lives in meta and is
# fetched with `go run` like govulncheck below; enable it via the
# doc-coverage input on starpkg domain modules.
- name: Doc coverage
if: ${{ fromJSON(env.IS_CHECKS) && inputs.doc-coverage }}
run: go run github.com/1set/meta/doccov@master .
- name: Test
run: make ci
# govulncheck is informational (continue-on-error): it surfaces dependency
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/selftest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,17 @@ jobs:
go-floor: "1.19"
working-directory: "selftest"
secrets: inherit

# Tests the doccov gate itself (meta's own tool, in the root module). The
# doc-coverage step in go-ci.yml fetches this via `go run`, so it must stay green.
doccov:
name: doccov tool
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: "1.19.x"
- run: go test -race ./doccov/
53 changes: 53 additions & 0 deletions doccov/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# doccov

The documentation-consistency gate for the Star\* ecosystem.

A Star\* module exposes its script-facing API as Starlark builtins, each built
with `starlark.NewBuiltin("<name>", fn)`. `doccov` statically scans a module's Go
source for those builtins and **fails if any of them is not documented in the
module's README** — so the docs can never silently drift behind the code.

## Usage

```bash
# from a module directory
go run github.com/1set/meta/doccov@master .

# elsewhere / a specific path
go run github.com/1set/meta/doccov@master path/to/module
```

Flags:

| Flag | Default | Meaning |
|------|---------|---------|
| `-readme` | `README.md` | documentation file to check |
| `-ignore` | (empty) | comma-separated builtin names to exclude (deprecated/internal-but-registered) |

## How it decides

- **Surface** = the first string argument of every `starlark.NewBuiltin(...)` call
in the non-test (`*.go`, excluding `*_test.go`) files at the top of the directory.
A qualified name `"module.fn"` (and the `ModuleName + ".fn"` form) is reduced to `fn`.
- **Documented** = the name appears as a word inside a backtick span of the README.
- It checks for **omission**, not accuracy — a wrong description is a review concern.
- Exit status is non-zero on an undocumented builtin or a missing README; it is
**zero when no `starlark.NewBuiltin` calls are found** (the repo does not opt in).

## In CI

The reusable workflow `1set/meta/.github/workflows/go-ci.yml` runs this as an
opt-in gate. A starpkg domain module enables it in its caller:

```yaml
jobs:
ci:
uses: 1set/meta/.github/workflows/go-ci.yml@<pin>
with:
go-floor: "1.20"
doc-coverage: true # turn on the doccov gate
secrets: inherit
```

GoDoc completeness (a doc comment on every exported symbol) is a separate concern,
covered by `revive`'s `exported` rule in the same workflow's Analyze step.
193 changes: 193 additions & 0 deletions doccov/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
// Command doccov is the documentation-consistency gate for the Star* ecosystem.
//
// A Star* module exposes its script-facing API as Starlark builtins, each
// constructed with starlark.NewBuiltin("<name>", fn). doccov statically scans a
// module's Go source for those builtins and fails if any of them is not
// documented in the module's README — so the docs can never silently drift
// behind the code.
//
// Usage:
//
// doccov [flags] [dir] # dir defaults to the current directory
// go run github.com/1set/meta/doccov@<ref> .
//
// Flags:
//
// -readme <file> documentation file to check (default "README.md")
// -ignore a,b,c builtin names to exclude (deprecated/internal-but-registered)
//
// It scans only non-test *.go files in dir (top level), so test-only builtins do
// not count as public surface. A builtin name of the form "module.fn" is reduced
// to "fn" before the README is checked. A symbol counts as documented when it
// appears as a word inside any backtick span in the README; doccov guards against
// omission, not against an inaccurate description (that is a review concern).
//
// Exit status is non-zero when a builtin is undocumented or the README is
// missing; it is zero when no starlark.NewBuiltin calls are found (the repo does
// not opt into this convention).
package main

import (
"flag"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)

func main() {
readme := flag.String("readme", "README.md", "documentation file to check")
ignore := flag.String("ignore", "", "comma-separated builtin names to exclude")
flag.Parse()

dir := "."
if flag.NArg() > 0 {
dir = flag.Arg(0)
}

if err := run(dir, *readme, splitCSV(*ignore)); err != nil {
fmt.Fprintln(os.Stderr, "doccov: "+err.Error())
os.Exit(1)
}
}

func run(dir, readmeName string, ignore map[string]bool) error {
surface, err := scanSurface(dir)
if err != nil {
return err
}
if len(surface) == 0 {
fmt.Println("doccov: no starlark.NewBuiltin calls found; nothing to check")
return nil
}

readmePath := filepath.Join(dir, readmeName)
data, err := os.ReadFile(readmePath)
if err != nil {
return fmt.Errorf("cannot read %s: %v", readmePath, err)
}
documented := backtickWords(string(data))

var missing []string
for _, name := range surface {
if ignore[name] || documented[name] {
continue
}
missing = append(missing, name)
}
sort.Strings(missing)

fmt.Printf("doccov: %d script-facing builtins, %d documented, %d missing\n",
len(surface), len(surface)-len(missing), len(missing))
if len(missing) > 0 {
return fmt.Errorf("undocumented in %s: %s", readmeName, strings.Join(missing, ", "))
}
return nil
}

// scanSurface returns the sorted, de-duplicated set of script-facing builtin
// names declared in the non-test Go files at the top level of dir.
func scanSurface(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}

set := map[string]bool{}
fset := token.NewFileSet()
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
f, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0)
if err != nil {
return nil, fmt.Errorf("parse %s: %v", name, err)
}
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || len(call.Args) == 0 {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "NewBuiltin" {
return true
}
if pkg, ok := sel.X.(*ast.Ident); !ok || pkg.Name != "starlark" {
return true
}
if lit := stringLit(call.Args[0]); lit != "" {
set[shortName(lit)] = true
}
return true
})
}

out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out, nil
}

// stringLit extracts a string constant from a builtin's first argument. It
// resolves a plain literal ("module.fn") and the common "ModuleName + \".fn\""
// concatenation, returning the literal portion.
func stringLit(e ast.Expr) string {
switch v := e.(type) {
case *ast.BasicLit:
if v.Kind == token.STRING {
if s, err := strconv.Unquote(v.Value); err == nil {
return s
}
}
case *ast.BinaryExpr:
if l := stringLit(v.X); l != "" {
return l
}
return stringLit(v.Y)
}
return ""
}

// shortName reduces a qualified builtin name ("module.fn") to its final segment.
func shortName(name string) string {
if i := strings.LastIndex(name, "."); i >= 0 {
return name[i+1:]
}
return name
}

var (
backtickSpan = regexp.MustCompile("`[^`]+`")
wordToken = regexp.MustCompile(`[A-Za-z_][A-Za-z0-9_]*`)
)

// backtickWords collects every identifier word appearing inside a backtick span
// of the document.
func backtickWords(doc string) map[string]bool {
out := map[string]bool{}
for _, span := range backtickSpan.FindAllString(doc, -1) {
for _, w := range wordToken.FindAllString(span, -1) {
out[w] = true
}
}
return out
}

func splitCSV(s string) map[string]bool {
out := map[string]bool{}
for _, part := range strings.Split(s, ",") {
if p := strings.TrimSpace(part); p != "" {
out[p] = true
}
}
return out
}
97 changes: 97 additions & 0 deletions doccov/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Tests for the doccov gate, grouped by goal:
// - surface extraction (scanSurface, stringLit, shortName): which builtins are found
// - README coverage (backtickWords): which names count as documented
// - end-to-end (run): pass / fail / no-builtins / ignore behaviour
package main

import (
"go/parser"
"strings"
"testing"
)

func TestScanSurface(t *testing.T) {
got, err := scanSurface("testdata/good")
if err != nil {
t.Fatalf("scanSurface: %v", err)
}
want := []string{"alpha", "beta"} // "good.alpha" -> "alpha"; "test_only" excluded (_test.go)
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("surface = %v, want %v", got, want)
}
}

func TestStringLitFromExpr(t *testing.T) {
// stringLit must resolve a plain literal and the "ModuleName + \".fn\"" form.
cases := map[string]string{
`"connect"`: "connect",
`"sqlite.connect"`: "sqlite.connect",
`ModuleName + ".connect"`: ".connect",
}
for src, want := range cases {
expr, err := parser.ParseExpr(src)
if err != nil {
t.Fatalf("ParseExpr(%q): %v", src, err)
}
if got := stringLit(expr); got != want {
t.Errorf("stringLit(%q) = %q, want %q", src, got, want)
}
}
}

func TestShortName(t *testing.T) {
for in, want := range map[string]string{
"connect": "connect",
"sqlite.connect": "connect",
".connect": "connect",
} {
if got := shortName(in); got != want {
t.Errorf("shortName(%q) = %q, want %q", in, got, want)
}
}
}

func TestBacktickWords(t *testing.T) {
doc := "Use `alpha` and `db.beta(x)`; plain gamma is not in backticks."
words := backtickWords(doc)
if !words["alpha"] || !words["beta"] || !words["db"] {
t.Errorf("expected alpha/beta/db in backticks, got %v", words)
}
if words["gamma"] {
t.Errorf("gamma is outside backticks and must not count as documented")
}
}

func TestRunGood(t *testing.T) {
if err := run("testdata/good", "README.md", nil); err != nil {
t.Fatalf("good fixture should pass, got: %v", err)
}
}

func TestRunBad(t *testing.T) {
err := run("testdata/bad", "README.md", nil)
if err == nil {
t.Fatal("bad fixture should fail: gamma is undocumented")
}
if !strings.Contains(err.Error(), "gamma") {
t.Fatalf("error should name the undocumented builtin, got: %v", err)
}
}

func TestRunBadIgnored(t *testing.T) {
if err := run("testdata/bad", "README.md", map[string]bool{"gamma": true}); err != nil {
t.Fatalf("ignoring gamma should pass, got: %v", err)
}
}

func TestRunNoBuiltins(t *testing.T) {
if err := run("testdata/empty", "README.md", nil); err != nil {
t.Fatalf("a repo with no builtins must not fail, got: %v", err)
}
}

func TestRunMissingReadme(t *testing.T) {
if err := run("testdata/good", "NOPE.md", nil); err == nil {
t.Fatal("a missing documentation file should fail")
}
}
3 changes: 3 additions & 0 deletions doccov/testdata/bad/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# bad

Only `alpha` is documented here.
Loading
Loading