Collect production Go dependency notices for shared credits
Co-Authored-By: Codex <noreply@openai.com>
5 files changed,
+317
−4
cmd/oficina-notices/main.go+66 −0credits/catalog/catalog.go+145 −0credits/catalog/catalog_test.go+101 −0credits/templates/credits.html+1 −1implementation/open-source-credits.json+4 −3
CI passed — run details
diff --git a/cmd/oficina-notices/main.go b/cmd/oficina-notices/main.go| new file mode 100644 |
| index 0000000..03b1df1 |
| --- /dev/null |
| +++ b/cmd/oficina-notices/main.go |
| @@ -0,0 +1,66 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "flag" |
| + "fmt" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + |
| + "amadan.net/oficina/estilo.git/credits" |
| + "amadan.net/oficina/estilo.git/credits/catalog" |
| +) |
| + |
| +func main() { |
| + entry := flag.String("package", "", "production Go package") |
| + excludes := flag.String("exclude", "", "comma-separated first-party module paths") |
| + flag.Parse() |
| + if *entry == "" || flag.NArg() != 0 { |
| + fmt.Fprintln(os.Stderr, "oficina-notices: -package is required") |
| + os.Exit(2) |
| + } |
| + command := exec.Command("go", "list", "-deps", "-json", *entry) |
| + command.Stderr = os.Stderr |
| + raw, err := command.Output() |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, "oficina-notices: go list failed") |
| + os.Exit(1) |
| + } |
| + excluded := map[string]bool{} |
| + for _, name := range strings.Split(*excludes, ",") { |
| + if name = strings.TrimSpace(name); name != "" { |
| + excluded[name] = true |
| + } |
| + } |
| + inventory, err := catalog.Collect(bytes.NewReader(raw), excluded) |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, err) |
| + os.Exit(1) |
| + } |
| + env, err := exec.Command("go", "env", "-json", "GOROOT", "GOVERSION").Output() |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, "oficina-notices: Go provenance unavailable") |
| + os.Exit(1) |
| + } |
| + var toolchain struct{ GOROOT, GOVERSION string } |
| + if json.Unmarshal(env, &toolchain) != nil || toolchain.GOROOT == "" || toolchain.GOVERSION == "" { |
| + fmt.Fprintln(os.Stderr, "oficina-notices: invalid Go provenance") |
| + os.Exit(1) |
| + } |
| + license, err := os.ReadFile(filepath.Join(toolchain.GOROOT, "LICENSE")) |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, "oficina-notices: Go license unavailable") |
| + os.Exit(1) |
| + } |
| + inventory.Components = append(inventory.Components, credits.Component{Name: "Go", Version: toolchain.GOVERSION, SourceURL: "https://go.dev/LICENSE", Notices: []credits.Notice{{Name: "LICENSE", Text: string(license)}}}) |
| + sort.Slice(inventory.Components, func(i, j int) bool { return inventory.Components[i].Name < inventory.Components[j].Name }) |
| + encoder := json.NewEncoder(os.Stdout) |
| + encoder.SetIndent("", " ") |
| + if err := encoder.Encode(inventory); err != nil { |
| + os.Exit(1) |
| + } |
| +} |
diff --git a/credits/catalog/catalog.go b/credits/catalog/catalog.go| new file mode 100644 |
| index 0000000..cae740f |
| --- /dev/null |
| +++ b/credits/catalog/catalog.go |
| @@ -0,0 +1,145 @@ |
| +// Package catalog collects notices from the exact packages selected by go list. |
| +// It reads root and package-ancestor notice files, including vendored notices |
| +// such as gormlite/LICENSE and libc's third-party licence bundle. |
| +package catalog |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + "unicode/utf8" |
| + |
| + "amadan.net/oficina/estilo.git/credits" |
| +) |
| + |
| +type Module struct { |
| + Path, Version, Dir string |
| + Main bool |
| + Replace *Module |
| +} |
| +type Package struct { |
| + Dir string |
| + Module *Module |
| + Error *struct{ Err string } |
| +} |
| +type Inventory struct { |
| + Components []credits.Component `json:"components"` |
| + MissingNotices []string `json:"missing_notices"` |
| +} |
| + |
| +// Collect expects go list -deps -json for one production entry point. Standard |
| +// library notices are supplied separately by the command from that Go toolchain. |
| +// First-party module names are excluded only by the caller's exact list. |
| +func Collect(input io.Reader, exclude map[string]bool) (Inventory, error) { |
| + decoder := json.NewDecoder(input) |
| + modules := map[string]*Module{} |
| + directories := map[string]map[string]bool{} |
| + for { |
| + var p Package |
| + err := decoder.Decode(&p) |
| + if errors.Is(err, io.EOF) { |
| + break |
| + } |
| + if err != nil { |
| + return Inventory{}, err |
| + } |
| + if p.Error != nil { |
| + return Inventory{}, errors.New("credits: package resolution failed") |
| + } |
| + m := p.Module |
| + if m == nil || m.Main || exclude[m.Path] { |
| + continue |
| + } |
| + if m.Replace != nil { |
| + return Inventory{}, errors.New("credits: replaced module requires explicit inventory") |
| + } |
| + if m.Dir == "" || m.Path == "" || m.Version == "" || p.Dir == "" { |
| + return Inventory{}, errors.New("credits: incomplete module provenance") |
| + } |
| + root := filepath.Clean(m.Dir) |
| + rel, err := filepath.Rel(root, p.Dir) |
| + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { |
| + return Inventory{}, errors.New("credits: package outside module") |
| + } |
| + if previous := modules[m.Path]; previous != nil && (previous.Version != m.Version || previous.Dir != m.Dir) { |
| + return Inventory{}, errors.New("credits: conflicting module versions") |
| + } |
| + modules[m.Path] = m |
| + if directories[m.Path] == nil { |
| + directories[m.Path] = map[string]bool{} |
| + } |
| + for dir := filepath.Clean(p.Dir); ; dir = filepath.Dir(dir) { |
| + directories[m.Path][dir] = true |
| + if dir == root { |
| + break |
| + } |
| + } |
| + } |
| + out := Inventory{Components: []credits.Component{}, MissingNotices: []string{}} |
| + for name, m := range modules { |
| + component := credits.Component{Name: name, Version: m.Version, SourceURL: moduleURL(m), Notices: []credits.Notice{}} |
| + for dir := range directories[name] { |
| + files, err := os.ReadDir(dir) |
| + if err != nil { |
| + return Inventory{}, err |
| + } |
| + for _, entry := range files { |
| + if !noticeFilename(entry.Name()) { |
| + continue |
| + } |
| + full := filepath.Join(dir, entry.Name()) |
| + info, err := os.Lstat(full) |
| + if err != nil { |
| + return Inventory{}, err |
| + } |
| + if !info.Mode().IsRegular() || info.Size() > 2<<20 { |
| + return Inventory{}, errors.New("credits: unsupported notice file") |
| + } |
| + raw, err := os.ReadFile(full) |
| + if err != nil { |
| + return Inventory{}, err |
| + } |
| + if !utf8.Valid(raw) || len(raw) == 0 { |
| + return Inventory{}, errors.New("credits: empty or non-UTF-8 notice") |
| + } |
| + relative, err := filepath.Rel(m.Dir, full) |
| + if err != nil { |
| + return Inventory{}, err |
| + } |
| + component.Notices = append(component.Notices, credits.Notice{Name: filepath.ToSlash(relative), Text: string(raw)}) |
| + } |
| + } |
| + sort.Slice(component.Notices, func(i, j int) bool { return component.Notices[i].Name < component.Notices[j].Name }) |
| + if len(component.Notices) == 0 { |
| + out.MissingNotices = append(out.MissingNotices, name) |
| + } |
| + out.Components = append(out.Components, component) |
| + } |
| + sort.Slice(out.Components, func(i, j int) bool { return out.Components[i].Name < out.Components[j].Name }) |
| + sort.Strings(out.MissingNotices) |
| + return out, nil |
| +} |
| + |
| +func noticeFilename(name string) bool { |
| + name = strings.ToUpper(name) |
| + for _, prefix := range []string{"LICENSE", "LICENCE", "COPYING", "NOTICE"} { |
| + if name == prefix || strings.HasPrefix(name, prefix+".") || strings.HasPrefix(name, prefix+"-") { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| +func moduleURL(m *Module) string { |
| + if strings.HasPrefix(m.Path, "amadan.net/") { |
| + parts := strings.Split(m.Path, "/") |
| + if len(parts) >= 3 { |
| + return "https://" + parts[0] + "/" + parts[1] + "/" + strings.TrimSuffix(parts[2], ".git") |
| + } |
| + return "" |
| + } |
| + return "https://pkg.go.dev/" + m.Path + "@" + m.Version |
| +} |
diff --git a/credits/catalog/catalog_test.go b/credits/catalog/catalog_test.go| new file mode 100644 |
| index 0000000..26cedb6 |
| --- /dev/null |
| +++ b/credits/catalog/catalog_test.go |
| @@ -0,0 +1,101 @@ |
| +package catalog |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func moduleFixture(t *testing.T, name string) *Module { |
| + t.Helper() |
| + root := t.TempDir() |
| + if err := os.MkdirAll(filepath.Join(root, "vendorpart", "pkg"), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + for path, text := range map[string]string{"LICENSE": "Root copyright\nPermission retained.\n", "LICENSE-3RD-PARTY.md": "Additional copyright\n", "vendorpart/LICENSE": "Nested copyright\n", "vendorpart/pkg/not-license.go": "package pkg"} { |
| + if err := os.WriteFile(filepath.Join(root, path), []byte(text), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return &Module{Path: name, Version: "v1.2.3", Dir: root} |
| +} |
| +func encodePackages(t *testing.T, packages ...Package) *bytes.Buffer { |
| + t.Helper() |
| + var out bytes.Buffer |
| + for _, p := range packages { |
| + if err := json.NewEncoder(&out).Encode(p); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return &out |
| +} |
| +func TestCollectIncludesNestedAndBundledNoticesOnce(t *testing.T) { |
| + m := moduleFixture(t, "example.org/component") |
| + packages := []Package{{Dir: filepath.Join(m.Dir, "vendorpart", "pkg"), Module: m}, {Dir: m.Dir, Module: m}, {Dir: filepath.Join(m.Dir, "vendorpart"), Module: m}} |
| + got, err := Collect(encodePackages(t, packages...), nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got.Components) != 1 || len(got.MissingNotices) != 0 { |
| + t.Fatal("wrong dependency inventory") |
| + } |
| + c := got.Components[0] |
| + if len(c.Notices) != 3 { |
| + t.Fatalf("notices=%d, want root, bundled and nested", len(c.Notices)) |
| + } |
| + if c.Notices[0].Name != "LICENSE" || c.Notices[0].Text != "Root copyright\nPermission retained.\n" || c.Notices[2].Name != "vendorpart/LICENSE" { |
| + t.Fatal("notice path, ordering or original bytes changed") |
| + } |
| + again, err := Collect(encodePackages(t, packages[2], packages[0], packages[1]), nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + a, _ := json.Marshal(got) |
| + b, _ := json.Marshal(again) |
| + if !bytes.Equal(a, b) { |
| + t.Fatal("inventory depends on package order") |
| + } |
| +} |
| +func TestCollectReportsMissingAndExcludesOnlyExplicitModules(t *testing.T) { |
| + m := &Module{Path: "example.org/missing", Version: "v2.0.0", Dir: t.TempDir()} |
| + owned := moduleFixture(t, "example.org/owned") |
| + main := &Module{Main: true, Dir: t.TempDir()} |
| + got, err := Collect(encodePackages(t, Package{Dir: m.Dir, Module: m}, Package{Dir: owned.Dir, Module: owned}, Package{Dir: main.Dir, Module: main}), map[string]bool{owned.Path: true}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got.Components) != 1 || len(got.MissingNotices) != 1 || got.MissingNotices[0] != m.Path { |
| + t.Fatal("missing notice silently dropped") |
| + } |
| +} |
| +func TestCollectRefusesUnverifiableProvenance(t *testing.T) { |
| + for _, mode := range []string{"outside", "replacement", "missing-version", "symlink", "package-error"} { |
| + t.Run(mode, func(t *testing.T) { |
| + m := moduleFixture(t, "example.org/component") |
| + p := Package{Dir: m.Dir, Module: m} |
| + switch mode { |
| + case "outside": |
| + p.Dir = t.TempDir() |
| + case "replacement": |
| + m.Replace = &Module{Dir: t.TempDir()} |
| + case "missing-version": |
| + m.Version = "" |
| + case "symlink": |
| + if err := os.Symlink(filepath.Join(m.Dir, "LICENSE"), filepath.Join(m.Dir, "NOTICE")); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "package-error": |
| + p.Error = &struct{ Err string }{Err: "failed"} |
| + } |
| + if _, err := Collect(encodePackages(t, p), nil); err == nil { |
| + t.Fatal("unverifiable package accepted") |
| + } |
| + }) |
| + } |
| + if _, err := Collect(strings.NewReader(`{"Module":`), nil); err == nil { |
| + t.Fatal("truncated package stream accepted") |
| + } |
| +} |
diff --git a/credits/templates/credits.html b/credits/templates/credits.html| index 727c002..b30fc09 100644 |
| --- a/credits/templates/credits.html |
| +++ b/credits/templates/credits.html |
| @@ -5,7 +5,7 @@ |
| {{range .Components}} |
| <li><details> |
| <summary><bdi>{{.Name}}</bdi>{{if .Version}} <bdi estilo-credits-version>{{.Version}}</bdi>{{end}}</summary> |
| - {{if .SourceURL}}<p><a href="{{.SourceURL}}"><bdi>{{.SourceURL}}</bdi></a></p>{{end}} |
| + {{if .SourceURL}}<p><a href="{{.SourceURL}}" referrerpolicy="no-referrer" rel="noreferrer"><bdi>{{.SourceURL}}</bdi></a></p>{{end}} |
| {{range .Notices}}<h2>{{.Name}}</h2><pre>{{.Text}}</pre>{{end}} |
| </details></li> |
| {{end}} |
diff --git a/implementation/open-source-credits.json b/implementation/open-source-credits.json| index 8e16873..17643d5 100644 |
| --- a/implementation/open-source-credits.json |
| +++ b/implementation/open-source-credits.json |
| @@ -4,7 +4,8 @@ |
| "suite_requirement": "Extract reusable components and maintain consistent UI/UX via Estilo.", |
| "copy_review": "User waived copy review for this round. Component contains no fixed user-facing strings; callers supply translated title and original upstream notices.", |
| "first_consumer": "Home credits page; adoption in progress. Not an agreed suite interface until a second actual consumer adopts it.", |
| - "behavior": "Native details list of component name/version, source link, and complete original notice text. No JavaScript. Template autoescapes all notice data and source URL. Bare estilo attributes define wrapping and minimum24px controls. Theme colors remain inherited and unchanged.", |
| - "verification": "Full Estilo make test passed. Tests cover notice markup escaping, unsafe source URL filtering, translated caller title, bidi isolation and preservation of notice text. Native browser320px/keyboard review and actual Home catalog remain pending.", |
| - "review": "Pending Claude Fable after quota reset; no merge or deployment. A rendered credits list is not a claim that dependency/license inventory is complete." |
| + "behavior": "Native details list of component name/version, source link, and complete original notice text. No JavaScript. Template autoescapes all notice data and source URL. Bare estilo attributes define wrapping and minimum24px controls. Theme colors remain inherited and unchanged. External source links suppress referrers.", |
| + "verification": "Full Estilo make test passed. Exact Home-rendered page passed native Chromium320/1200px overflow and24px target checks; Enter opens notices while page scripting is disabled. Browser evidence is in oficina-provisioning-20260911/home-credits-browser. Component and catalog tests cover escaping, translated title, bidi, original root/bundled/nested notices, deterministic order, explicit missing notices and invalid source provenance.", |
| + "review": "Pending Claude Fable after quota reset; no merge or deployment. A rendered credits list is not a claim that dependency/license inventory is complete.", |
| + "inventory": "cmd/oficina-notices reads go list -deps -json for an app production package under the target build environment. It includes that Go toolchain LICENSE, selected module root and package-ancestor notice files, and an explicit missing-notices list. Exclusions are exact named first-party modules. Replacements and unverifiable provenance refuse; this does not claim to inventory separate JavaScript/npm or binary assets." |
| } |