Files
esengine 6b6a41d367 docs: a documentation standard with named owners, held by repolint
Documents on the studio line had no owner, no status and no shape. Of
120 Markdown files, 544 paragraphs and list items ran past 320
characters. Chinese copies had drifted from the English ones (SPEC.md
1301 lines, SPEC.zh-CN.md 492). CODEOWNERS still named five paths that
no longer exist. The Studio release doc claimed a required approval and a
main-v2-only signing policy, neither of which is true any more.

- docs/DOCS_STANDARD.md states the rules as ID tables: ownership,
  document classes, header, writing width, section shape per type,
  language, and lifecycle.
- .github/CODEOWNERS gives every area a primary and a backup, in that
  order. Every rule still lists both maintainers, so review requests do
  not change.
- docs/STUDIO_RELEASE.md becomes a runbook: owners, preconditions,
  steps, verification, recovery, release-note format, reference. Each
  fact was checked against release-studio.yml, the environment's branch
  policy and the 2.17.0 release.
- repolint gains five rules:
  - codeowners: a rule must match a file and name two people.
  - doc-owner: a document header's owner and backup must equal the
    matching CODEOWNERS rule.
  - doc-prose: a block must stay within 320 display columns, with a CJK
    character counting as two.
  - doc-language: only three onboarding pages keep a Chinese copy.
  - release-note: fixed group headings, one referenced short line per
    change.

Document classes are declared by path. Landing pages, model-read files,
GitHub templates and release notes carry no header, because each is
rendered or consumed verbatim; CODEOWNERS owns them.

Baseline: -allow-widen raises only the five new rules, from nothing to
the debt that exists today (doc-owner 91, doc-prose 2755 weighted,
doc-language 33, release-note 661, codeowners 0). No existing rule
widened. Deleting stale documents and the untranslated copies follows
in its own change, after the list is reviewed.
2026-09-16 20:57:15 -07:00

125 lines
3.7 KiB
Go

// codeowners.go — who answers for a path, read from .github/CODEOWNERS.
// The first owner on a line is the primary and the second the backup; GitHub
// requests review from both, and the documentation gate reads the order.
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
)
const codeownersPath = ".github/CODEOWNERS"
type ownerRule struct {
line int
pattern string
owners []string
match *regexp.Regexp
}
func loadOwnerRules(root string) ([]ownerRule, error) {
data, err := os.ReadFile(filepath.Join(root, codeownersPath))
if err != nil {
return nil, err
}
var rules []ownerRule
for i, raw := range strings.Split(string(data), "\n") {
fields := strings.Fields(raw)
if len(fields) == 0 || strings.HasPrefix(fields[0], "#") {
continue
}
rules = append(rules, ownerRule{line: i + 1, pattern: fields[0], owners: fields[1:], match: ownerPattern(fields[0])})
}
return rules, nil
}
// ownersOf is the last matching rule, which is the one GitHub applies.
func ownersOf(rules []ownerRule, rel string) (ownerRule, bool) {
for _, r := range slices.Backward(rules) {
if r.match.MatchString(rel) {
return r, true
}
}
return ownerRule{}, false
}
// ownerPattern translates the subset of CODEOWNERS syntax this repository
// uses: a leading slash anchors, a trailing slash names a directory, * stays
// within a segment and ** crosses them.
func ownerPattern(pattern string) *regexp.Regexp {
anchored := strings.HasPrefix(pattern, "/") || strings.Contains(strings.Trim(pattern, "/"), "/")
dir := strings.HasSuffix(pattern, "/")
body := strings.Trim(pattern, "/")
var b strings.Builder
for i := 0; i < len(body); i++ {
switch {
case strings.HasPrefix(body[i:], "**"):
b.WriteString(".*")
i++
case body[i] == '*':
b.WriteString("[^/]*")
case body[i] == '?':
b.WriteString("[^/]")
default:
b.WriteString(regexp.QuoteMeta(body[i : i+1]))
}
}
prefix := "^"
if !anchored {
prefix = "^(?:.*/)?"
}
suffix := "(?:/.*)?$"
if dir {
suffix = "/.*$"
}
return regexp.MustCompile(prefix + b.String() + suffix)
}
// checkCodeOwners holds every rule to a path that exists and to a primary and
// a backup who are two different people. A rule for a deleted file routes
// nothing, and reading it tells a newcomer the file is still owned.
func checkCodeOwners(root string) []Finding {
rules, err := loadOwnerRules(root)
if err != nil {
return []Finding{{codeownersPath, 1, ruleCodeOwners, "no CODEOWNERS: nothing names who answers for a path", 1}}
}
paths, err := repoFiles(root)
var out []Finding
for _, r := range rules {
if len(r.owners) < 2 || r.owners[0] == r.owners[1] {
out = append(out, Finding{codeownersPath, r.line, ruleCodeOwners,
fmt.Sprintf("%s names %v; a rule needs a primary and a different backup", r.pattern, r.owners), 1})
}
if err == nil && !anyMatch(r.match, paths) {
out = append(out, Finding{codeownersPath, r.line, ruleCodeOwners,
fmt.Sprintf("%s matches no file in the tree; remove the rule with the file it owned", r.pattern), 1})
}
}
return out
}
func anyMatch(re *regexp.Regexp, paths []string) bool {
return slices.ContainsFunc(paths, re.MatchString)
}
// repoFiles is the tree a reviewer would see: tracked files plus new ones git
// does not ignore, minus anything deleted from the working tree. A walk would
// also read local worktrees and caches nobody commits.
func repoFiles(root string) ([]string, error) {
listed, err := gitPaths(root, "ls-files", "--cached", "--others", "--exclude-standard")
if err != nil {
return nil, err
}
out := listed[:0]
for _, rel := range listed {
if _, err := os.Stat(filepath.Join(root, rel)); err == nil {
out = append(out, rel)
}
}
return out, nil
}