A .gitignore => .gitignore +17 -0
@@ 0,0 1,17 @@
+# Compiled service binaries
+/specsrht
+/specsrht-migrate
+
+# Transient build artifacts
+*.tmp
+
+# Local instance config
+/config.ini
+
+# Local bare repos and the bleve index + render cache. Both are the service's
+# runtime state, not source; the cache is safe to delete at any time.
+/repos/
+/cache/
+
+# Intermediate stylesheet; only the content-hashed main.min.<sha>.css ships.
+/web/static/main.css
A Makefile => Makefile +79 -0
@@ 0,0 1,79 @@
+# spec.sr.ht — build scaffolding (compare.sr.ht / sourcehut-dolt style).
+#
+# One Go binary plus a brant migration wrapper, both under cmd/. The CSS is
+# built from the shared sourcehut scss partials with sassc; there is no
+# frontend bundle — the prose differ renders server-side.
+
+SERVICE=spec.sr.ht
+BIN=specsrht
+MIGRATE_BIN=specsrht-migrate
+
+PREFIX?=/usr/local
+BINDIR?=$(PREFIX)/bin
+SHAREDIR?=$(PREFIX)/share
+ASSETS?=/usr/share/sourcehut
+STATICDIR?=$(ASSETS)/$(SERVICE)/static
+MIGRATIONDIR?=$(SHAREDIR)/sourcehut/migrations/$(SERVICE)
+
+SASSC?=sassc
+SASSC_INCLUDE=-I$(ASSETS)/scss
+
+all: build
+
+# Compile the binaries. cmd/ lands in Phase 1's Wave B, so until then each
+# target skips rather than failing — a red default target during the build-out
+# trains everyone to ignore it.
+build: $(BIN) $(MIGRATE_BIN)
+
+$(BIN):
+ @if [ -d ./cmd/$(BIN) ]; then \
+ echo "go build -o $@ ./cmd/$(BIN)"; \
+ go build -o $@ ./cmd/$(BIN); \
+ else \
+ echo "skip $@: ./cmd/$(BIN) not present yet"; \
+ fi
+
+$(MIGRATE_BIN):
+ @if [ -d ./cmd/$(MIGRATE_BIN) ]; then \
+ echo "go build -o $@ ./cmd/$(MIGRATE_BIN)"; \
+ go build -o $@ ./cmd/$(MIGRATE_BIN); \
+ else \
+ echo "skip $@: ./cmd/$(MIGRATE_BIN) not present yet"; \
+ fi
+
+test:
+ go test ./...
+
+# CSS pipeline: sassc -> minify -> content-hashed filename. The running service
+# globs web/static/main.min.*.css at startup, so the hash in the name is the
+# cache-busting version. Requires the shared scss partials installed at
+# $(ASSETS)/scss (core.sr.ht `make install`) and scss/main.scss to exist.
+# Produces exactly ONE web/static/main.min.<sha256[:8]>.css; old ones and the
+# intermediate main.css are removed.
+css:
+ mkdir -p web/static
+ rm -f web/static/main.css web/static/main.min.*.css
+ $(SASSC) $(SASSC_INCLUDE) scss/main.scss web/static/main.css
+ minify -o web/static/main.min.css web/static/main.css
+ mv web/static/main.min.css \
+ web/static/main.min.$$(sha256sum web/static/main.min.css | cut -c1-8).css
+ rm -f web/static/main.css
+
+# Local development run. Requires a ./config.ini in the working directory (or
+# ../config.ini, /etc/sr.ht/config.ini) carrying the instance's shared
+# [sr.ht]/[webhooks] keys plus a [spec.sr.ht] section.
+run-dev: build
+ ./$(BIN) -b localhost:5091
+
+install: build
+ install -Dm755 $(BIN) $(DESTDIR)$(BINDIR)/$(BIN)
+ install -Dm755 $(MIGRATE_BIN) $(DESTDIR)$(BINDIR)/$(MIGRATE_BIN)
+ mkdir -p $(DESTDIR)$(STATICDIR) $(DESTDIR)$(MIGRATIONDIR)
+ install -Dm644 -t $(DESTDIR)$(STATICDIR) web/static/*
+ install -Dm644 -t $(DESTDIR)$(MIGRATIONDIR) migrations/*.sql
+
+clean:
+ rm -f $(BIN) $(MIGRATE_BIN)
+ rm -f web/static/main.css web/static/main.min.*.css
+
+.PHONY: all build test css run-dev install clean $(BIN) $(MIGRATE_BIN)
A core/errors.go => core/errors.go +69 -0
@@ 0,0 1,69 @@
+// Package core holds the pure domain logic of spec.sr.ht: owner and space name
+// validation, safe document paths, the globally-unique document ID grammar, the
+// frontmatter schema contract, `.spec.yml` space policy, and the proposal state
+// machine.
+//
+// It depends on nothing but the standard library and gopkg.in/yaml.v3, never
+// touches the network or the filesystem, and knows nothing about git, Postgres
+// or HTTP. Dependency direction is strictly downward: gitx, db, service, api and
+// web import core; core imports none of them. That is what keeps the three
+// agent-facing surfaces (REST, MCP, GraphQL) behaviourally identical — they
+// share these rules rather than each re-deriving them.
+package core
+
+import "errors"
+
+// Sentinel errors, one per failure class. Callers compare with errors.Is;
+// wrapping with %w adds the offending value without losing the class, which is
+// what lets the API layer map a failure to a status code (422 for a schema
+// violation, 400 for a malformed name) without string matching.
+var (
+ // ErrInvalidName is returned for a malformed owner or space name.
+ ErrInvalidName = errors.New("invalid name")
+
+ // ErrInvalidPath is returned for a path that is not a safe relative path
+ // inside a space: absolute, traversing, or carrying bytes that would be
+ // unsafe in a git tree or misleading in the review UI.
+ ErrInvalidPath = errors.New("invalid path")
+
+ // ErrInvalidDocID is returned for a document ID that does not match the
+ // PREFIX-DIGITS grammar. IDs are globally unique and are the anchor for
+ // cross-space links, comments and staleness checks, so shape is enforced
+ // at every door.
+ ErrInvalidDocID = errors.New("invalid document id")
+
+ // ErrMalformedFrontmatter marks a document whose YAML frontmatter block is
+ // missing, unterminated, not a mapping, or not parseable. It is distinct
+ // from ErrMissingField: this is "cannot read", not "read but incomplete".
+ ErrMalformedFrontmatter = errors.New("malformed frontmatter")
+
+ // ErrMissingField marks frontmatter that parsed but omits a key the space's
+ // schema requires. This is the 422 an agent gets for forgetting `status:`.
+ ErrMissingField = errors.New("missing required frontmatter field")
+
+ // ErrInvalidStatus is returned for a `status:` value outside the allowed
+ // enum. Note "approved" is deliberately not a status — approval is a
+ // property of the branch a document is reachable from, never of authored
+ // metadata.
+ ErrInvalidStatus = errors.New("invalid status")
+
+ // ErrInvalidPolicy is returned for a `.spec.yml` that parses as YAML but
+ // does not describe a usable policy.
+ ErrInvalidPolicy = errors.New("invalid .spec.yml policy")
+
+ // ErrInvalidPattern is returned for a malformed auto_merge path pattern.
+ // A pattern that silently matches nothing would quietly turn the bimodal
+ // cadence back into "everything waits for a human", so it is rejected.
+ ErrInvalidPattern = errors.New("invalid path pattern")
+
+ // ErrInvalidState is returned for a proposal state string outside the
+ // open/merged/rejected set, typically read back from Postgres.
+ ErrInvalidState = errors.New("invalid proposal state")
+
+ // ErrInvalidTransition is returned for a proposal state change the machine
+ // does not allow, such as re-merging an already rejected proposal.
+ ErrInvalidTransition = errors.New("invalid proposal state transition")
+
+ // ErrInvalidApproval is returned for an approval kind outside human/policy.
+ ErrInvalidApproval = errors.New("invalid approval kind")
+)
A core/frontmatter.go => core/frontmatter.go +276 -0
@@ 0,0 1,276 @@
+package core
+
+import (
+ "fmt"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+// Status is the authored lifecycle marker of a document.
+//
+// The enum is exactly draft/review/superseded. "approved" is deliberately not a
+// status: a document is approved when it is reachable from the approved ref,
+// and that is the whole definition. Had "approved" stayed here, either the
+// approved branch would permanently carry `status: draft`, or the merge would
+// have to rewrite frontmatter nobody authored — which would also invalidate the
+// agent's If-Match base on its next read.
+type Status string
+
+const (
+ StatusDraft Status = "draft"
+ StatusReview Status = "review"
+ StatusSuperseded Status = "superseded"
+)
+
+// DefaultStatuses returns the full status enum, in the order a space's
+// `.spec.yml` would list it. A fresh slice each call, so a caller cannot mutate
+// the default out from under everyone else.
+func DefaultStatuses() []Status {
+ return []Status{StatusDraft, StatusReview, StatusSuperseded}
+}
+
+// ParseStatus validates a status string against the enum.
+func ParseStatus(s string) (Status, error) {
+ switch Status(s) {
+ case StatusDraft, StatusReview, StatusSuperseded:
+ return Status(s), nil
+ }
+ return "", fmt.Errorf("%w: %q is not one of %s", ErrInvalidStatus, s, joinStatuses(DefaultStatuses()))
+}
+
+func joinStatuses(ss []Status) string {
+ parts := make([]string, len(ss))
+ for i, s := range ss {
+ parts[i] = string(s)
+ }
+ return strings.Join(parts, "|")
+}
+
+// Frontmatter is the YAML header of a document. The modelled fields are the
+// ones the service reasons about; anything else an author writes is preserved
+// in git and reported by Present, but is not interpreted here.
+type Frontmatter struct {
+ ID string `yaml:"id"`
+ Title string `yaml:"title"`
+ Status Status `yaml:"status"`
+ Supersedes string `yaml:"supersedes"`
+ Owners []string `yaml:"owners"`
+ Tags []string `yaml:"tags"`
+ Type string `yaml:"type"`
+ Summary string `yaml:"summary"`
+
+ // Present is the set of top-level keys that actually appeared in the
+ // source. Required-key validation asks about presence, not emptiness:
+ // `title:` with no value is present and fails for being blank, whereas an
+ // absent `title:` is a different error with a different fix. A zero-valued
+ // struct field cannot tell those apart.
+ Present map[string]bool `yaml:"-"`
+}
+
+// Has reports whether key appeared in the parsed source. Nil-safe, so a
+// hand-constructed Frontmatter reports every key as absent rather than panicking.
+func (f Frontmatter) Has(key string) bool { return f.Present[key] }
+
+const (
+ frontDelim = "---"
+ // docEndDelim is YAML's explicit end-of-document marker. Editors emit it
+ // occasionally, and treating it as a closing fence costs one comparison.
+ docEndDelim = "..."
+)
+
+// SplitFrontmatter separates the YAML frontmatter block from the markdown body.
+// The document must open with a "---" line and close the block with a "---"
+// (or "...") line; both delimiters are excluded from the returned slices.
+//
+// It is strict on purpose. A document whose frontmatter is unterminated would
+// otherwise parse as a body-only document with no ID, silently dropping out of
+// the registry and the index instead of failing at the door.
+func SplitFrontmatter(src []byte) (front, body []byte, err error) {
+ s := string(src)
+ if strings.HasPrefix(s, "\ufeff") {
+ return nil, nil, fmt.Errorf("%w: document starts with a UTF-8 BOM before the %q delimiter",
+ ErrMalformedFrontmatter, frontDelim)
+ }
+
+ first, rest, hasMore := strings.Cut(s, "\n")
+ if strings.TrimSuffix(first, "\r") != frontDelim {
+ return nil, nil, fmt.Errorf("%w: document does not start with a %q delimiter",
+ ErrMalformedFrontmatter, frontDelim)
+ }
+ if !hasMore {
+ return nil, nil, fmt.Errorf("%w: frontmatter block is not terminated by %q",
+ ErrMalformedFrontmatter, frontDelim)
+ }
+
+ for pos := 0; pos <= len(rest); {
+ line, tail, ok := strings.Cut(rest[pos:], "\n")
+ switch strings.TrimSuffix(line, "\r") {
+ case frontDelim, docEndDelim:
+ return []byte(rest[:pos]), []byte(tail), nil
+ }
+ if !ok {
+ break
+ }
+ pos = len(rest) - len(tail)
+ }
+ return nil, nil, fmt.Errorf("%w: frontmatter block is not terminated by %q",
+ ErrMalformedFrontmatter, frontDelim)
+}
+
+// ParseFrontmatter decodes a frontmatter block into a Frontmatter, recording
+// which keys were present.
+//
+// Duplicate keys are rejected explicitly rather than last-one-wins: a document
+// carrying two `id:` lines is exactly the typo that corrupts the global ID
+// registry, and it is far cheaper to reject at the door than to find weeks later.
+func ParseFrontmatter(front []byte) (Frontmatter, error) {
+ var node yaml.Node
+ if err := yaml.Unmarshal(front, &node); err != nil {
+ return Frontmatter{}, fmt.Errorf("%w: %v", ErrMalformedFrontmatter, err)
+ }
+ if node.Kind == yaml.DocumentNode {
+ if len(node.Content) != 1 {
+ return Frontmatter{}, fmt.Errorf("%w: expected exactly one YAML document, got %d",
+ ErrMalformedFrontmatter, len(node.Content))
+ }
+ node = *node.Content[0]
+ }
+
+ // An empty block is well-formed YAML and yields a zero node. It is not a
+ // parse failure; it fails schema validation instead, which names the
+ // missing keys and is the more useful error.
+ if node.Kind == 0 {
+ return Frontmatter{Present: map[string]bool{}}, nil
+ }
+ if node.Kind != yaml.MappingNode {
+ return Frontmatter{}, fmt.Errorf("%w: frontmatter must be a YAML mapping", ErrMalformedFrontmatter)
+ }
+
+ present := make(map[string]bool, len(node.Content)/2)
+ for i := 0; i+1 < len(node.Content); i += 2 {
+ key := node.Content[i]
+ if key.Kind != yaml.ScalarNode {
+ return Frontmatter{}, fmt.Errorf("%w: non-scalar key at line %d", ErrMalformedFrontmatter, key.Line)
+ }
+ if present[key.Value] {
+ return Frontmatter{}, fmt.Errorf("%w: duplicate key %q at line %d",
+ ErrMalformedFrontmatter, key.Value, key.Line)
+ }
+ present[key.Value] = true
+ }
+
+ var fm Frontmatter
+ if err := node.Decode(&fm); err != nil {
+ return Frontmatter{}, fmt.Errorf("%w: %v", ErrMalformedFrontmatter, err)
+ }
+ fm.Present = present
+ return fm, nil
+}
+
+// ParseDocument splits and parses a whole document, returning its frontmatter
+// and its markdown body.
+func ParseDocument(src []byte) (Frontmatter, []byte, error) {
+ front, body, err := SplitFrontmatter(src)
+ if err != nil {
+ return Frontmatter{}, nil, err
+ }
+ fm, err := ParseFrontmatter(front)
+ if err != nil {
+ return Frontmatter{}, nil, err
+ }
+ return fm, body, nil
+}
+
+// Schema is a space's frontmatter contract, as carried by `.spec.yml`. It is
+// enforced at propose time and at push time — schema validation at the door is
+// the cheapest available defence against agent slop.
+type Schema struct {
+ Required []string `yaml:"required"`
+ Status []Status `yaml:"status"`
+}
+
+// DefaultSchema is the contract a space gets when its `.spec.yml` says nothing.
+func DefaultSchema() Schema {
+ return Schema{
+ Required: []string{"id", "title", "status"},
+ Status: DefaultStatuses(),
+ }
+}
+
+// Validate reports whether the schema itself is usable. The status list is the
+// interesting half: a space may narrow the enum (say, forbid `review`) but may
+// not invent a member, which is what keeps "approved" from creeping back in via
+// a per-space config file.
+func (s Schema) Validate() error {
+ seenKey := make(map[string]bool, len(s.Required))
+ for _, key := range s.Required {
+ if key == "" {
+ return fmt.Errorf("%w: schema.required contains an empty key", ErrInvalidPolicy)
+ }
+ if seenKey[key] {
+ return fmt.Errorf("%w: schema.required lists %q twice", ErrInvalidPolicy, key)
+ }
+ seenKey[key] = true
+ }
+
+ if len(s.Status) == 0 {
+ return fmt.Errorf("%w: schema.status must list at least one status", ErrInvalidPolicy)
+ }
+ seenStatus := make(map[Status]bool, len(s.Status))
+ for _, st := range s.Status {
+ if _, err := ParseStatus(string(st)); err != nil {
+ return fmt.Errorf("%w: schema.status: %v", ErrInvalidPolicy, err)
+ }
+ if seenStatus[st] {
+ return fmt.Errorf("%w: schema.status lists %q twice", ErrInvalidPolicy, st)
+ }
+ seenStatus[st] = true
+ }
+ return nil
+}
+
+// AllowsStatus reports whether st is permitted by this schema.
+func (s Schema) AllowsStatus(st Status) bool {
+ for _, allowed := range s.Status {
+ if st == allowed {
+ return true
+ }
+ }
+ return false
+}
+
+// ValidateFrontmatter checks a parsed document header against the schema. It
+// answers "may this document be proposed into this space", and every write path
+// (REST, MCP, and the update hook on your own pushes) runs it.
+func (s Schema) ValidateFrontmatter(fm Frontmatter) error {
+ for _, key := range s.Required {
+ if !fm.Has(key) {
+ return fmt.Errorf("%w: %q", ErrMissingField, key)
+ }
+ }
+ if fm.Has("id") {
+ if err := ValidateDocID(fm.ID); err != nil {
+ return err
+ }
+ }
+ if fm.Has("title") && strings.TrimSpace(fm.Title) == "" {
+ return fmt.Errorf("%w: %q is present but blank", ErrMissingField, "title")
+ }
+ if fm.Has("status") && !s.AllowsStatus(fm.Status) {
+ return fmt.Errorf("%w: %q is not one of %s", ErrInvalidStatus, fm.Status, joinStatuses(s.Status))
+ }
+ // `supersedes:` with no value is a half-finished edit, not an absent key,
+ // so it is rejected rather than ignored.
+ if fm.Has("supersedes") {
+ if err := ValidateDocID(fm.Supersedes); err != nil {
+ return fmt.Errorf("supersedes: %w", err)
+ }
+ }
+ for _, owner := range fm.Owners {
+ if err := ValidateOwner(strings.TrimPrefix(owner, "~")); err != nil {
+ return fmt.Errorf("owners: %w", err)
+ }
+ }
+ return nil
+}
A core/frontmatter_test.go => core/frontmatter_test.go +405 -0
@@ 0,0 1,405 @@
+package core
+
+import (
+ "errors"
+ "strings"
+ "testing"
+)
+
+func TestParseStatus(t *testing.T) {
+ tests := []struct {
+ in string
+ ok bool
+ }{
+ {"draft", true},
+ {"review", true},
+ {"superseded", true},
+
+ // "approved" is a property of the branch, never of the frontmatter.
+ // This case is the whole reason the enum is validated at all.
+ {"approved", false},
+ {"Approved", false},
+ {"APPROVED", false},
+ {"merged", false},
+ {"Draft", false},
+ {"draft ", false},
+ {" draft", false},
+ {"", false},
+ {"черновик", false},
+ }
+ for _, tc := range tests {
+ st, err := ParseStatus(tc.in)
+ if (err == nil) != tc.ok {
+ t.Errorf("ParseStatus(%q) = %q, %v, want ok=%v", tc.in, st, err, tc.ok)
+ }
+ if err != nil && !errors.Is(err, ErrInvalidStatus) {
+ t.Errorf("ParseStatus(%q) error %v is not ErrInvalidStatus", tc.in, err)
+ }
+ }
+}
+
+func TestSplitFrontmatter(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ wantFront string
+ wantBody string
+ ok bool
+ }{
+ {"minimal", "---\nid: SPEC-1\n---\n# Title\n", "id: SPEC-1\n", "# Title\n", true},
+ {"empty block", "---\n---\n", "", "", true},
+ {"empty body", "---\nid: SPEC-1\n---\n", "id: SPEC-1\n", "", true},
+ {"no trailing newline after fence", "---\nid: SPEC-1\n---", "id: SPEC-1\n", "", true},
+ {"crlf", "---\r\nid: SPEC-1\r\n---\r\nbody\r\n", "id: SPEC-1\r\n", "body\r\n", true},
+ {"dot fence", "---\nid: SPEC-1\n...\nbody\n", "id: SPEC-1\n", "body\n", true},
+ {"hr in body is not a fence", "---\nid: SPEC-1\n---\ntext\n\n---\n\nmore\n", "id: SPEC-1\n", "text\n\n---\n\nmore\n", true},
+
+ {"empty document", "", "", "", false},
+ {"no frontmatter", "# Just markdown\n", "", "", false},
+ {"leading blank line", "\n---\nid: SPEC-1\n---\n", "", "", false},
+ {"fence only", "---", "", "", false},
+ {"unterminated", "---\nid: SPEC-1\nbody\n", "", "", false},
+ {"indented fence", " ---\nid: SPEC-1\n---\n", "", "", false},
+ {"four dashes", "----\nid: SPEC-1\n----\n", "", "", false},
+ {"utf-8 bom", "\ufeff---\nid: SPEC-1\n---\n", "", "", false},
+ // A fence with trailing whitespace is not a fence; treating it as one
+ // would make an unterminated block parse as a body-only document.
+ {"fence with trailing space", "--- \nid: SPEC-1\n---\n", "", "", false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ front, body, err := SplitFrontmatter([]byte(tc.in))
+ if (err == nil) != tc.ok {
+ t.Fatalf("SplitFrontmatter(%q) = %q, %q, %v, want ok=%v", tc.in, front, body, err, tc.ok)
+ }
+ if !tc.ok {
+ if !errors.Is(err, ErrMalformedFrontmatter) {
+ t.Fatalf("error %v is not ErrMalformedFrontmatter", err)
+ }
+ return
+ }
+ if string(front) != tc.wantFront {
+ t.Errorf("front = %q, want %q", front, tc.wantFront)
+ }
+ if string(body) != tc.wantBody {
+ t.Errorf("body = %q, want %q", body, tc.wantBody)
+ }
+ })
+ }
+}
+
+func TestParseFrontmatter(t *testing.T) {
+ full := "id: SPEC-0007\n" +
+ "title: Proposal storage model\n" +
+ "status: draft\n" +
+ "supersedes: SPEC-0003\n" +
+ "owners: [~bigbes]\n" +
+ "tags: [storage, review]\n" +
+ "type: spec\n" +
+ "summary: how proposals are stored\n"
+
+ fm, err := ParseFrontmatter([]byte(full))
+ if err != nil {
+ t.Fatalf("ParseFrontmatter: %v", err)
+ }
+ if fm.ID != "SPEC-0007" || fm.Title != "Proposal storage model" || fm.Status != StatusDraft {
+ t.Fatalf("unexpected frontmatter: %+v", fm)
+ }
+ if fm.Supersedes != "SPEC-0003" || fm.Type != "spec" || fm.Summary != "how proposals are stored" {
+ t.Fatalf("unexpected frontmatter: %+v", fm)
+ }
+ if len(fm.Owners) != 1 || fm.Owners[0] != "~bigbes" {
+ t.Fatalf("owners = %v", fm.Owners)
+ }
+ if len(fm.Tags) != 2 || fm.Tags[0] != "storage" {
+ t.Fatalf("tags = %v", fm.Tags)
+ }
+ for _, key := range []string{"id", "title", "status", "supersedes", "owners", "tags", "type", "summary"} {
+ if !fm.Has(key) {
+ t.Errorf("Has(%q) = false, want true", key)
+ }
+ }
+ if fm.Has("approved") {
+ t.Error(`Has("approved") = true for a key that is not in the source`)
+ }
+}
+
+func TestParseFrontmatterPresenceVsEmptiness(t *testing.T) {
+ // `title:` with no value is present-but-blank, which is a different error
+ // from an absent title. The distinction is the reason Present exists.
+ fm, err := ParseFrontmatter([]byte("id: SPEC-1\ntitle:\n"))
+ if err != nil {
+ t.Fatalf("ParseFrontmatter: %v", err)
+ }
+ if !fm.Has("title") {
+ t.Fatal(`Has("title") = false for a key that is present with a null value`)
+ }
+ if fm.Title != "" {
+ t.Fatalf("Title = %q, want empty", fm.Title)
+ }
+ if fm.Has("status") {
+ t.Fatal(`Has("status") = true for an absent key`)
+ }
+}
+
+func TestParseFrontmatterUnknownKeysArePreserved(t *testing.T) {
+ // Unknown keys are authored metadata we do not model; they must not fail
+ // the parse, and they must be reportable so a schema can require them.
+ fm, err := ParseFrontmatter([]byte("id: SPEC-1\nreviewers: [~someone]\n"))
+ if err != nil {
+ t.Fatalf("ParseFrontmatter: %v", err)
+ }
+ if !fm.Has("reviewers") {
+ t.Fatal(`Has("reviewers") = false, want true`)
+ }
+}
+
+func TestParseFrontmatterErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ ok bool
+ }{
+ {"empty block", "", true},
+ {"whitespace only", "\n \n", true},
+ {"comment only", "# nothing here\n", true},
+
+ {"duplicate id", "id: SPEC-1\nid: SPEC-2\n", false},
+ {"duplicate title far apart", "id: SPEC-1\ntitle: a\nstatus: draft\ntitle: b\n", false},
+ {"sequence not mapping", "- id: SPEC-1\n", false},
+ {"scalar not mapping", "just a string\n", false},
+ {"tab indentation", "id: SPEC-1\n\ttitle: x\n", false},
+ {"unclosed flow seq", "tags: [a, b\n", false},
+ {"owners as scalar", "owners: bigbes\n", false},
+ {"tags as mapping", "tags: {a: 1}\n", false},
+ {"complex key", "? [a, b]\n: value\n", false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ fm, err := ParseFrontmatter([]byte(tc.in))
+ if (err == nil) != tc.ok {
+ t.Fatalf("ParseFrontmatter(%q) = %+v, %v, want ok=%v", tc.in, fm, err, tc.ok)
+ }
+ if err != nil && !errors.Is(err, ErrMalformedFrontmatter) {
+ t.Fatalf("error %v is not ErrMalformedFrontmatter", err)
+ }
+ if err == nil && fm.Present == nil {
+ t.Fatal("Present must never be nil on a successful parse")
+ }
+ })
+ }
+}
+
+func TestParseDocument(t *testing.T) {
+ src := "---\nid: SPEC-0007\ntitle: T\nstatus: draft\n---\n\n# Heading\n\nbody\n"
+ fm, body, err := ParseDocument([]byte(src))
+ if err != nil {
+ t.Fatalf("ParseDocument: %v", err)
+ }
+ if fm.ID != "SPEC-0007" || fm.Status != StatusDraft {
+ t.Fatalf("frontmatter = %+v", fm)
+ }
+ if string(body) != "\n# Heading\n\nbody\n" {
+ t.Fatalf("body = %q", body)
+ }
+
+ if _, _, err := ParseDocument([]byte("# no frontmatter\n")); !errors.Is(err, ErrMalformedFrontmatter) {
+ t.Fatalf("ParseDocument without frontmatter = %v, want ErrMalformedFrontmatter", err)
+ }
+}
+
+func TestSchemaValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ in Schema
+ ok bool
+ }{
+ {"default", DefaultSchema(), true},
+ {"narrowed status", Schema{Required: []string{"id"}, Status: []Status{StatusDraft}}, true},
+ {"no required keys", Schema{Required: nil, Status: DefaultStatuses()}, true},
+ {"custom required key", Schema{Required: []string{"id", "reviewers"}, Status: DefaultStatuses()}, true},
+
+ {"empty status list", Schema{Required: []string{"id"}, Status: nil}, false},
+ // A per-space config file must not be able to reintroduce "approved".
+ {"approved status", Schema{Required: []string{"id"}, Status: []Status{StatusDraft, "approved"}}, false},
+ {"unknown status", Schema{Required: []string{"id"}, Status: []Status{"wip"}}, false},
+ {"duplicate status", Schema{Required: []string{"id"}, Status: []Status{StatusDraft, StatusDraft}}, false},
+ {"empty required key", Schema{Required: []string{""}, Status: DefaultStatuses()}, false},
+ {"duplicate required key", Schema{Required: []string{"id", "id"}, Status: DefaultStatuses()}, false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := tc.in.Validate()
+ if (err == nil) != tc.ok {
+ t.Fatalf("Schema.Validate() = %v, want ok=%v", err, tc.ok)
+ }
+ if err != nil && !errors.Is(err, ErrInvalidPolicy) {
+ t.Fatalf("error %v is not ErrInvalidPolicy", err)
+ }
+ })
+ }
+}
+
+func TestSchemaValidateFrontmatter(t *testing.T) {
+ tests := []struct {
+ name string
+ schema Schema
+ front string
+ wantErr error // nil means "must succeed"
+ }{
+ {
+ name: "complete document",
+ schema: DefaultSchema(),
+ front: "id: SPEC-0007\ntitle: Storage\nstatus: draft\n",
+ },
+ {
+ name: "optional keys absent",
+ schema: DefaultSchema(),
+ front: "id: SPEC-0007\ntitle: Storage\nstatus: review\n",
+ },
+ {
+ name: "owners with and without tilde",
+ schema: DefaultSchema(),
+ front: "id: SPEC-1\ntitle: T\nstatus: draft\nowners: [~bigbes, bigbes]\n",
+ },
+ {
+ name: "extra keys ignored",
+ schema: DefaultSchema(),
+ front: "id: SPEC-1\ntitle: T\nstatus: draft\nreviewers: [~x]\n",
+ },
+ {
+ name: "missing status",
+ schema: DefaultSchema(),
+ front: "id: SPEC-1\ntitle: T\n",
+ wantErr: ErrMissingField,
+ },
+ {
+ name: "missing id",
+ schema: DefaultSchema(),
+ front: "title: T\nstatus: draft\n",
+ wantErr: ErrMissingField,
+ },
+ {
+ name: "empty frontmatter",
+ schema: DefaultSchema(),
+ front: "",
+ wantErr: ErrMissingField,
+ },
+ {
+ name: "blank title",
+ schema: DefaultSchema(),
+ front: "id: SPEC-1\ntitle: \" \"\nstatus: draft\n",
+ wantErr: ErrMissingField,
+ },
+ {
+ name: "null id",
+ schema: DefaultSchema(),
+ front: "id:\ntitle: T\nstatus: draft\n",
+ wantErr: ErrInvalidDocID,
+ },
+ {
+ name: "malformed id",
+ schema: DefaultSchema(),
+ front: "id: spec-7\ntitle: T\nstatus: draft\n",
+ wantErr: ErrInvalidDocID,
+ },
+ {
+ name: "approved status rejected",
+ schema: DefaultSchema(),
+ front: "id: SPEC-1\ntitle: T\nstatus: approved\n",
+ wantErr: ErrInvalidStatus,
+ },
+ {
+ name: "status outside narrowed enum",
+ schema: Schema{Required: []string{"id", "title", "status"}, Status: []Status{StatusDraft}},
+ front: "id: SPEC-1\ntitle: T\nstatus: review\n",
+ wantErr: ErrInvalidStatus,
+ },
+ {
+ name: "supersedes present but empty",
+ schema: DefaultSchema(),
+ front: "id: SPEC-1\ntitle: T\nstatus: draft\nsupersedes:\n",
+ wantErr: ErrInvalidDocID,
+ },
+ {
+ name: "supersedes malformed",
+ schema: DefaultSchema(),
+ front: "id: SPEC-1\ntitle: T\nstatus: draft\nsupersedes: SPEC 3\n",
+ wantErr: ErrInvalidDocID,
+ },
+ {
+ name: "bad owner",
+ schema: DefaultSchema(),
+ front: "id: SPEC-1\ntitle: T\nstatus: draft\nowners: [~Big Bes]\n",
+ wantErr: ErrInvalidName,
+ },
+ {
+ name: "empty owner entry",
+ schema: DefaultSchema(),
+ front: "id: SPEC-1\ntitle: T\nstatus: draft\nowners: ['']\n",
+ wantErr: ErrInvalidName,
+ },
+ {
+ name: "custom required key missing",
+ schema: Schema{Required: []string{"id", "reviewers"}, Status: DefaultStatuses()},
+ front: "id: SPEC-1\ntitle: T\nstatus: draft\n",
+ wantErr: ErrMissingField,
+ },
+ {
+ name: "status not required and absent",
+ schema: Schema{Required: []string{"id"}, Status: DefaultStatuses()},
+ front: "id: SPEC-1\n",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ fm, err := ParseFrontmatter([]byte(tc.front))
+ if err != nil {
+ t.Fatalf("ParseFrontmatter(%q): %v", tc.front, err)
+ }
+ err = tc.schema.ValidateFrontmatter(fm)
+ if tc.wantErr == nil {
+ if err != nil {
+ t.Fatalf("ValidateFrontmatter = %v, want nil", err)
+ }
+ return
+ }
+ if !errors.Is(err, tc.wantErr) {
+ t.Fatalf("ValidateFrontmatter = %v, want %v", err, tc.wantErr)
+ }
+ })
+ }
+}
+
+func TestSchemaAllowsStatus(t *testing.T) {
+ s := DefaultSchema()
+ for _, st := range DefaultStatuses() {
+ if !s.AllowsStatus(st) {
+ t.Errorf("default schema must allow %q", st)
+ }
+ }
+ if s.AllowsStatus("approved") {
+ t.Error(`default schema must not allow "approved"`)
+ }
+ if s.AllowsStatus("") {
+ t.Error("default schema must not allow the empty status")
+ }
+}
+
+func TestDefaultStatusesIsNotShared(t *testing.T) {
+ a := DefaultStatuses()
+ a[0] = "approved"
+ if b := DefaultStatuses(); b[0] != StatusDraft {
+ t.Fatalf("DefaultStatuses() leaked a shared slice: %v", b)
+ }
+}
+
+func TestDefaultSchemaMatchesDesign(t *testing.T) {
+ s := DefaultSchema()
+ if got, want := strings.Join(s.Required, ","), "id,title,status"; got != want {
+ t.Fatalf("default required = %q, want %q", got, want)
+ }
+ if got, want := joinStatuses(s.Status), "draft|review|superseded"; got != want {
+ t.Fatalf("default status enum = %q, want %q", got, want)
+ }
+}
A core/id.go => core/id.go +101 -0
@@ 0,0 1,101 @@
+package core
+
+import (
+ "fmt"
+ "strings"
+)
+
+const (
+ // MaxDocIDPrefixLen bounds the alphabetic part of an ID ("SPEC", "RFC").
+ MaxDocIDPrefixLen = 16
+
+ // MaxDocIDSeqLen bounds the numeric part. Eight digits is far past the
+ // volume this service will ever see; the cap exists so a pathological ID
+ // cannot become a pathological registry key.
+ MaxDocIDSeqLen = 8
+)
+
+// DocID is a parsed document identifier such as "SPEC-0007".
+//
+// Seq is kept as a string rather than an int on purpose: leading zeros are part
+// of how these IDs are written and read, and "SPEC-7" and "SPEC-0007" are
+// therefore different IDs in a registry that is globally unique. Normalizing
+// them to the same integer would silently merge two documents.
+type DocID struct {
+ Prefix string
+ Seq string
+}
+
+// String renders the canonical "PREFIX-SEQ" form.
+func (d DocID) String() string { return d.Prefix + "-" + d.Seq }
+
+// ParseDocID parses and validates a document ID. The grammar is deliberately
+// tiny:
+//
+// ID = PREFIX "-" SEQ
+// PREFIX = [A-Z] [A-Z0-9]* (1..MaxDocIDPrefixLen)
+// SEQ = [0-9]+ (1..MaxDocIDSeqLen)
+//
+// Exactly one '-' separates the two, so the split is unambiguous and IDs sort
+// predictably. ASCII-only and uppercase-only is the load-bearing part: IDs are
+// the global registry key, so a Cyrillic "С" or a lowercase "spec" must be a
+// different-looking ID that is rejected outright rather than a homograph that
+// quietly registers alongside the real one.
+//
+// Core validates shape only. Global uniqueness is enforced by the registry
+// table, which is the only thing that can know about other spaces.
+func ParseDocID(s string) (DocID, error) {
+ if s == "" {
+ return DocID{}, fmt.Errorf("%w: empty id", ErrInvalidDocID)
+ }
+ i := strings.IndexByte(s, '-')
+ if i < 0 {
+ return DocID{}, fmt.Errorf("%w: id %q has no '-' separator", ErrInvalidDocID, s)
+ }
+ prefix, seq := s[:i], s[i+1:]
+ if strings.IndexByte(seq, '-') >= 0 {
+ return DocID{}, fmt.Errorf("%w: id %q has more than one '-'", ErrInvalidDocID, s)
+ }
+
+ if prefix == "" {
+ return DocID{}, fmt.Errorf("%w: id %q has an empty prefix", ErrInvalidDocID, s)
+ }
+ if len(prefix) > MaxDocIDPrefixLen {
+ return DocID{}, fmt.Errorf("%w: id %q prefix is too long (%d > %d)",
+ ErrInvalidDocID, s, len(prefix), MaxDocIDPrefixLen)
+ }
+ if !isUpperAlpha(prefix[0]) {
+ return DocID{}, fmt.Errorf("%w: id %q prefix must start with A-Z", ErrInvalidDocID, s)
+ }
+ for i := 0; i < len(prefix); i++ {
+ if c := prefix[i]; !isUpperAlpha(c) && !isDigit(c) {
+ return DocID{}, fmt.Errorf("%w: id %q prefix contains disallowed byte %q", ErrInvalidDocID, s, c)
+ }
+ }
+
+ if seq == "" {
+ return DocID{}, fmt.Errorf("%w: id %q has an empty sequence", ErrInvalidDocID, s)
+ }
+ if len(seq) > MaxDocIDSeqLen {
+ return DocID{}, fmt.Errorf("%w: id %q sequence is too long (%d > %d)",
+ ErrInvalidDocID, s, len(seq), MaxDocIDSeqLen)
+ }
+ for i := 0; i < len(seq); i++ {
+ if !isDigit(seq[i]) {
+ return DocID{}, fmt.Errorf("%w: id %q sequence contains disallowed byte %q", ErrInvalidDocID, s, seq[i])
+ }
+ }
+
+ return DocID{Prefix: prefix, Seq: seq}, nil
+}
+
+// ValidateDocID reports whether s is a well-formed document ID, discarding the
+// parse. Use it where only the verdict matters (frontmatter schema checks, push
+// validation).
+func ValidateDocID(s string) error {
+ _, err := ParseDocID(s)
+ return err
+}
+
+func isUpperAlpha(c byte) bool { return c >= 'A' && c <= 'Z' }
+func isDigit(c byte) bool { return c >= '0' && c <= '9' }
A core/id_test.go => core/id_test.go +101 -0
@@ 0,0 1,101 @@
+package core
+
+import (
+ "errors"
+ "strings"
+ "testing"
+)
+
+func TestParseDocID(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ wantPrefix string
+ wantSeq string
+ ok bool
+ }{
+ {"spec", "SPEC-0007", "SPEC", "0007", true},
+ {"rfc", "RFC-1", "RFC", "1", true},
+ {"single letter prefix", "X-42", "X", "42", true},
+ {"digits in prefix", "C4-0001", "C4", "0001", true},
+ {"all zeros", "SPEC-0000", "SPEC", "0000", true},
+ {"max prefix", strings.Repeat("A", MaxDocIDPrefixLen) + "-1", strings.Repeat("A", MaxDocIDPrefixLen), "1", true},
+ {"max seq", "SPEC-" + strings.Repeat("9", MaxDocIDSeqLen), "SPEC", strings.Repeat("9", MaxDocIDSeqLen), true},
+
+ {"empty", "", "", "", false},
+ {"no separator", "SPEC0007", "", "", false},
+ {"two separators", "HOME-OPS-0001", "", "", false},
+ {"double dash", "SPEC--1", "", "", false},
+ {"empty prefix", "-0007", "", "", false},
+ {"empty seq", "SPEC-", "", "", false},
+ {"lowercase prefix", "spec-0007", "", "", false},
+ {"mixed case prefix", "Spec-0007", "", "", false},
+ {"digit-leading prefix", "0SPEC-1", "", "", false},
+ {"underscore in prefix", "SPEC_A-1", "", "", false},
+ {"dot in prefix", "SPEC.A-1", "", "", false},
+ {"non-digit seq", "SPEC-007a", "", "", false},
+ {"signed seq", "SPEC-+1", "", "", false},
+ {"hex seq", "SPEC-0x7", "", "", false},
+ {"prefix too long", strings.Repeat("A", MaxDocIDPrefixLen+1) + "-1", "", "", false},
+ {"seq too long", "SPEC-" + strings.Repeat("9", MaxDocIDSeqLen+1), "", "", false},
+ {"leading space", " SPEC-0007", "", "", false},
+ {"trailing space", "SPEC-0007 ", "", "", false},
+ {"inner space", "SPEC - 0007", "", "", false},
+ {"newline", "SPEC-0007\n", "", "", false},
+ // Homograph: Cyrillic С/Р/Е/С look identical to Latin in most fonts.
+ // Accepting them would let two visually identical IDs both register.
+ {"cyrillic homograph", "СПЕС-0007", "", "", false},
+ {"fullwidth digits", "SPEC-0007", "", "", false},
+ {"arabic-indic digits", "SPEC-٠٠٠٧", "", "", false},
+ {"path injection", "SPEC-0007/../x", "", "", false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ id, err := ParseDocID(tc.in)
+ if (err == nil) != tc.ok {
+ t.Fatalf("ParseDocID(%q) = %+v, %v, want ok=%v", tc.in, id, err, tc.ok)
+ }
+ if !tc.ok {
+ if !errors.Is(err, ErrInvalidDocID) {
+ t.Fatalf("error %v is not ErrInvalidDocID", err)
+ }
+ return
+ }
+ if id.Prefix != tc.wantPrefix || id.Seq != tc.wantSeq {
+ t.Fatalf("ParseDocID(%q) = %+v, want {%q %q}", tc.in, id, tc.wantPrefix, tc.wantSeq)
+ }
+ if got := id.String(); got != tc.in {
+ t.Fatalf("String() = %q, want round-trip %q", got, tc.in)
+ }
+ })
+ }
+}
+
+// Leading zeros are significant: the registry is global and "SPEC-7" is a
+// different key from "SPEC-0007". Normalizing them together would silently
+// merge two documents.
+func TestDocIDLeadingZerosAreSignificant(t *testing.T) {
+ a, err := ParseDocID("SPEC-7")
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := ParseDocID("SPEC-0007")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if a == b {
+ t.Fatalf("SPEC-7 and SPEC-0007 must not compare equal, got %+v", a)
+ }
+ if a.String() == b.String() {
+ t.Fatal("SPEC-7 and SPEC-0007 must not render identically")
+ }
+}
+
+func TestValidateDocID(t *testing.T) {
+ if err := ValidateDocID("SPEC-0007"); err != nil {
+ t.Fatalf("ValidateDocID(SPEC-0007) = %v, want nil", err)
+ }
+ if err := ValidateDocID("spec-0007"); !errors.Is(err, ErrInvalidDocID) {
+ t.Fatalf("ValidateDocID(spec-0007) = %v, want ErrInvalidDocID", err)
+ }
+}
A core/names.go => core/names.go +220 -0
@@ 0,0 1,220 @@
+package core
+
+import (
+ "fmt"
+ "strings"
+ "unicode/utf8"
+)
+
+const (
+ // MaxOwnerLen bounds owner names. meta.sr.ht already bounds them at
+ // registration, so this is a sanity cap rather than the authority.
+ MaxOwnerLen = 64
+
+ // MaxSpaceNameLen bounds space names. A space name becomes a directory
+ // under the repos root and a URL segment, so it stays short.
+ MaxSpaceNameLen = 100
+
+ // MaxPathLen bounds a document or attachment path within a space. Well
+ // under any filesystem limit; the point is to keep a hostile path out of
+ // the index and the render cache, not to be permissive.
+ MaxPathLen = 512
+)
+
+const (
+ // DocExt is the only extension a document may carry. Matched
+ // case-sensitively: git trees are case-sensitive, so accepting ".MD" would
+ // create documents that the renderer and indexer disagree about.
+ DocExt = ".md"
+
+ // PolicyFile is the space policy, versioned in the space itself so that
+ // policy changes are reviewable like any other change.
+ PolicyFile = ".spec.yml"
+)
+
+// SpaceRef identifies a space: one bare git repo, owned by one user. Both
+// fields are stored without decoration — Owner never carries the leading '~'.
+type SpaceRef struct {
+ Owner string
+ Name string
+}
+
+// String renders the canonical URL and on-disk form, "~owner/name".
+func (r SpaceRef) String() string { return "~" + r.Owner + "/" + r.Name }
+
+// isNameByte reports whether c is allowed in an owner or space name: lowercase
+// alphanumerics plus '_', '-' and '.'. '/' is deliberately excluded, so a name
+// can never span path components.
+func isNameByte(c byte) bool {
+ switch {
+ case c >= 'a' && c <= 'z':
+ return true
+ case c >= '0' && c <= '9':
+ return true
+ case c == '_' || c == '-' || c == '.':
+ return true
+ default:
+ return false
+ }
+}
+
+// validateName holds the rules shared by owners and spaces: non-empty, within
+// the allowed byte set, not starting with '-' (which would read as an option to
+// anything shelling out) and containing no ".." (path traversal, since both
+// kinds of name become a path segment under the repos root).
+func validateName(kind, s string, maxLen int) error {
+ if s == "" {
+ return fmt.Errorf("%w: empty %s", ErrInvalidName, kind)
+ }
+ if len(s) > maxLen {
+ return fmt.Errorf("%w: %s %q is too long (%d > %d)", ErrInvalidName, kind, s, len(s), maxLen)
+ }
+ if s[0] == '-' {
+ return fmt.Errorf("%w: %s %q must not start with '-'", ErrInvalidName, kind, s)
+ }
+ if strings.Contains(s, "..") {
+ return fmt.Errorf("%w: %s %q must not contain '..'", ErrInvalidName, kind, s)
+ }
+ // '.' is in the allowed byte set, so the bare current-directory name has to
+ // be excluded by hand: a space named "." would resolve to the repos root.
+ if s == "." {
+ return fmt.Errorf("%w: %s %q is not allowed", ErrInvalidName, kind, s)
+ }
+ for i := 0; i < len(s); i++ {
+ if !isNameByte(s[i]) {
+ return fmt.Errorf("%w: %s %q contains disallowed byte %q", ErrInvalidName, kind, s, s[i])
+ }
+ }
+ return nil
+}
+
+// ValidateOwner reports whether s is a well-formed sourcehut owner name (the
+// part after '~' in a URL). Callers must strip the leading '~' first.
+func ValidateOwner(s string) error { return validateName("owner", s, MaxOwnerLen) }
+
+// ValidateSpaceName reports whether s is a well-formed space name — the same
+// character family as an owner, capped at MaxSpaceNameLen.
+func ValidateSpaceName(s string) error { return validateName("space", s, MaxSpaceNameLen) }
+
+// ParseSpaceRef parses "~owner/name" (or "owner/name") into a validated
+// SpaceRef. Surrounding slashes are tolerated because the same string arrives
+// both as a URL path and as a config value, but anything else that does not
+// split into exactly two non-empty segments is rejected rather than repaired.
+func ParseSpaceRef(s string) (SpaceRef, error) {
+ trimmed := strings.Trim(s, "/")
+ if trimmed == "" {
+ return SpaceRef{}, fmt.Errorf("%w: empty space reference", ErrInvalidName)
+ }
+ segs := strings.Split(trimmed, "/")
+ if len(segs) != 2 {
+ return SpaceRef{}, fmt.Errorf("%w: space reference %q must have exactly 2 segments, got %d",
+ ErrInvalidName, s, len(segs))
+ }
+ ref := SpaceRef{Owner: strings.TrimPrefix(segs[0], "~"), Name: segs[1]}
+ if err := ValidateOwner(ref.Owner); err != nil {
+ return SpaceRef{}, err
+ }
+ if err := ValidateSpaceName(ref.Name); err != nil {
+ return SpaceRef{}, err
+ }
+ return ref, nil
+}
+
+// badPathRune reports whether r must never appear in a path. Two families:
+// control characters, which git tolerates in a tree entry but which corrupt
+// logs, JSON and the index; and the Unicode bidirectional overrides, which can
+// make a path render in the review UI as something other than what will be
+// committed. Reviewing agent output is the product, so a path that lies about
+// itself on screen is a correctness bug, not a nicety.
+func badPathRune(r rune) bool {
+ if r < 0x20 || r == 0x7f {
+ return true
+ }
+ switch r {
+ case 0x200e, 0x200f, // LRM, RLM
+ 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, // LRE, RLE, PDF, LRO, RLO
+ 0x2066, 0x2067, 0x2068, 0x2069: // LRI, RLI, FSI, PDI
+ return true
+ }
+ return false
+}
+
+// ValidatePath reports whether p is a safe relative path inside a space, usable
+// as a git tree path for a document or an attachment. The rules:
+//
+// - non-empty and no longer than MaxPathLen;
+// - valid UTF-8, no control characters, no bidi overrides (see badPathRune);
+// - relative: no leading '/', no trailing '/';
+// - no backslashes — on a git tree a '\' is an ordinary filename byte, so
+// accepting it produces paths that mean different things to different
+// clients;
+// - no empty, "." or ".." components: traversal, and the whole point of this
+// function;
+// - no ".git" component, which git refuses to track and which is the classic
+// checkout-escape vector;
+// - no component ending in '.' or ' ', which are invisible on screen and
+// therefore an easy way to shadow an existing document.
+//
+// ValidatePath deliberately allows dotfiles (".spec.yml" is one) and non-ASCII
+// letters (specs here are written in Russian as well as English).
+func ValidatePath(p string) error {
+ if p == "" {
+ return fmt.Errorf("%w: empty path", ErrInvalidPath)
+ }
+ if len(p) > MaxPathLen {
+ return fmt.Errorf("%w: path is too long (%d > %d)", ErrInvalidPath, len(p), MaxPathLen)
+ }
+ if !utf8.ValidString(p) {
+ return fmt.Errorf("%w: path is not valid UTF-8", ErrInvalidPath)
+ }
+ for _, r := range p {
+ if badPathRune(r) {
+ return fmt.Errorf("%w: path %q contains disallowed rune %U", ErrInvalidPath, p, r)
+ }
+ }
+ if strings.HasPrefix(p, "/") {
+ return fmt.Errorf("%w: path %q must be relative", ErrInvalidPath, p)
+ }
+ if strings.HasSuffix(p, "/") {
+ return fmt.Errorf("%w: path %q must not end in '/'", ErrInvalidPath, p)
+ }
+ if strings.Contains(p, `\`) {
+ return fmt.Errorf("%w: path %q must not contain a backslash", ErrInvalidPath, p)
+ }
+ for _, comp := range strings.Split(p, "/") {
+ switch comp {
+ case "":
+ return fmt.Errorf("%w: path %q has an empty component", ErrInvalidPath, p)
+ case ".", "..":
+ return fmt.Errorf("%w: path %q has a traversal component %q", ErrInvalidPath, p, comp)
+ case ".git":
+ return fmt.Errorf("%w: path %q has a %q component", ErrInvalidPath, p, comp)
+ }
+ if strings.HasSuffix(comp, ".") || strings.HasSuffix(comp, " ") {
+ return fmt.Errorf("%w: path %q component %q ends in '.' or a space", ErrInvalidPath, p, comp)
+ }
+ }
+ return nil
+}
+
+// ValidateDocPath reports whether p is a valid path for a markdown document:
+// everything ValidatePath requires, plus a ".md" extension on a non-empty base
+// name. The extension carries meaning here — it is what tells the indexer and
+// the renderer that a blob is a document rather than an attachment — so a
+// document named exactly ".md" is rejected as having no name at all.
+func ValidateDocPath(p string) error {
+ if err := ValidatePath(p); err != nil {
+ return err
+ }
+ if !strings.HasSuffix(p, DocExt) {
+ return fmt.Errorf("%w: document path %q must end in %q", ErrInvalidPath, p, DocExt)
+ }
+ base := p
+ if i := strings.LastIndex(p, "/"); i >= 0 {
+ base = p[i+1:]
+ }
+ if base == DocExt {
+ return fmt.Errorf("%w: document path %q has an empty base name", ErrInvalidPath, p)
+ }
+ return nil
+}
A core/names_test.go => core/names_test.go +216 -0
@@ 0,0 1,216 @@
+package core
+
+import (
+ "errors"
+ "strings"
+ "testing"
+)
+
+func TestValidateOwner(t *testing.T) {
+ tests := []struct {
+ in string
+ ok bool
+ }{
+ {"bigbes", true},
+ {"user_name", true},
+ {"user-name", true},
+ {"user.name", true},
+ {"u123", true},
+ {"a", true},
+ {strings.Repeat("a", MaxOwnerLen), true},
+
+ {"", false},
+ {strings.Repeat("a", MaxOwnerLen+1), false},
+ {"-leading", false},
+ {"~bigbes", false}, // caller must strip the '~' first
+ {"has/slash", false},
+ {"has..dots", false},
+ {"..", false},
+ {"Upper", false},
+ {"has space", false},
+ {"tilde~", false},
+ {"emoji😀", false},
+ {"привет", false},
+ {"nul\x00byte", false},
+ }
+ for _, tc := range tests {
+ err := ValidateOwner(tc.in)
+ if (err == nil) != tc.ok {
+ t.Errorf("ValidateOwner(%q) = %v, want ok=%v", tc.in, err, tc.ok)
+ }
+ if err != nil && !errors.Is(err, ErrInvalidName) {
+ t.Errorf("ValidateOwner(%q) error %v is not ErrInvalidName", tc.in, err)
+ }
+ }
+}
+
+func TestValidateSpaceName(t *testing.T) {
+ tests := []struct {
+ in string
+ ok bool
+ }{
+ {"rfcs", true},
+ {"tarantool-rfcs", true},
+ {"home_ops", true},
+ {"v1.0", true},
+ {strings.Repeat("a", MaxSpaceNameLen), true},
+
+ {"", false},
+ {strings.Repeat("a", MaxSpaceNameLen+1), false},
+ {"-dashfirst", false},
+ {"a/b", false},
+ {"a..b", false},
+ {".", false}, // single dot is a legal byte set but "." alone is a directory
+ {"..", false}, // traversal
+ {"CamelCase", false},
+ {"+everything", false}, // project namespace, not a space name
+ }
+ for _, tc := range tests {
+ err := ValidateSpaceName(tc.in)
+ if (err == nil) != tc.ok {
+ t.Errorf("ValidateSpaceName(%q) = %v, want ok=%v", tc.in, err, tc.ok)
+ }
+ }
+}
+
+func TestValidateSpaceNameRejectsBareDot(t *testing.T) {
+ // "." passes the byte-set rule, so it must be caught somewhere: a space
+ // literally named "." would resolve to the repos root.
+ if err := ValidateSpaceName("."); err == nil {
+ t.Fatal(`ValidateSpaceName(".") must fail: it would resolve to the repos root`)
+ }
+}
+
+func TestParseSpaceRef(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ wantOwner string
+ wantName string
+ ok bool
+ }{
+ {"tilde form", "~bigbes/rfcs", "bigbes", "rfcs", true},
+ {"bare form", "bigbes/rfcs", "bigbes", "rfcs", true},
+ {"leading slash", "/~bigbes/rfcs", "bigbes", "rfcs", true},
+ {"trailing slash", "~bigbes/rfcs/", "bigbes", "rfcs", true},
+
+ {"empty", "", "", "", false},
+ {"slashes only", "///", "", "", false},
+ {"one segment", "~bigbes", "", "", false},
+ {"three segments", "~bigbes/rfcs/docs", "", "", false},
+ {"empty owner", "~/rfcs", "", "", false},
+ {"empty name", "~bigbes/", "", "", false},
+ {"traversal owner", "~../rfcs", "", "", false},
+ {"traversal name", "~bigbes/..", "", "", false},
+ {"uppercase owner", "~BigBes/rfcs", "", "", false},
+ {"space in name", "~bigbes/my rfcs", "", "", false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ ref, err := ParseSpaceRef(tc.in)
+ if (err == nil) != tc.ok {
+ t.Fatalf("ParseSpaceRef(%q) = %v, %v, want ok=%v", tc.in, ref, err, tc.ok)
+ }
+ if !tc.ok {
+ if !errors.Is(err, ErrInvalidName) {
+ t.Fatalf("error %v is not ErrInvalidName", err)
+ }
+ return
+ }
+ if ref.Owner != tc.wantOwner || ref.Name != tc.wantName {
+ t.Fatalf("ParseSpaceRef(%q) = %+v, want {%q %q}", tc.in, ref, tc.wantOwner, tc.wantName)
+ }
+ if got, want := ref.String(), "~"+tc.wantOwner+"/"+tc.wantName; got != want {
+ t.Fatalf("String() = %q, want %q", got, want)
+ }
+ })
+ }
+}
+
+func TestValidatePath(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ ok bool
+ }{
+ {"simple", "specs/0007-storage.md", true},
+ {"root file", "README.md", true},
+ {"dotfile", ".spec.yml", true},
+ {"deep", "a/b/c/d/e.md", true},
+ {"cyrillic", "спеки/хранилище.md", true},
+ {"attachment", "assets/diagram.png", true},
+ {"dot in name", "v1.2.3.md", true},
+ {"leading dot component", "notes/.private.md", true},
+ {"max length", strings.Repeat("a", MaxPathLen), true},
+
+ {"empty", "", false},
+ {"too long", strings.Repeat("a", MaxPathLen+1), false},
+ {"absolute", "/etc/passwd", false},
+ {"trailing slash", "specs/", false},
+ {"parent traversal", "../etc/passwd", false},
+ {"embedded traversal", "specs/../../etc/passwd", false},
+ {"interior traversal", "specs/../notes/a.md", false},
+ {"dot component", "./a.md", false},
+ {"interior dot component", "a/./b.md", false},
+ {"empty component", "specs//a.md", false},
+ {"git dir", ".git/config", false},
+ {"nested git dir", "specs/.git/HEAD", false},
+ {"backslash", `specs\a.md`, false},
+ {"windows traversal", `..\a.md`, false},
+ {"nul byte", "specs/a\x00.md", false},
+ {"newline", "specs/a\nb.md", false},
+ {"tab", "specs/a\tb.md", false},
+ {"del", "specs/a\x7f.md", false},
+ {"rtl override", "specs/exe\u202egnp.md", false},
+ {"rtl mark", "specs/a\u200fb.md", false},
+ {"pop directional isolate", "specs/a\u2069b.md", false},
+ {"invalid utf-8", "specs/\xff\xfe.md", false},
+ {"trailing dot component", "specs/a./b.md", false},
+ {"trailing space component", "specs/a /b.md", false},
+ {"trailing space at end", "specs/a.md ", false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := ValidatePath(tc.in)
+ if (err == nil) != tc.ok {
+ t.Fatalf("ValidatePath(%q) = %v, want ok=%v", tc.in, err, tc.ok)
+ }
+ if err != nil && !errors.Is(err, ErrInvalidPath) {
+ t.Fatalf("error %v is not ErrInvalidPath", err)
+ }
+ })
+ }
+}
+
+func TestValidateDocPath(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ ok bool
+ }{
+ {"spec", "specs/0007-storage.md", true},
+ {"root", "README.md", true},
+ {"hidden doc", "notes/.draft.md", true},
+ {"cyrillic", "спеки/хранилище.md", true},
+
+ {"no extension", "specs/0007-storage", false},
+ {"wrong extension", "specs/0007-storage.markdown", false},
+ {"uppercase extension", "specs/0007-storage.MD", false},
+ {"extension only", ".md", false},
+ {"extension only in dir", "specs/.md", false},
+ {"extension in middle", "specs/a.md.txt", false},
+ {"traversal wins", "../a.md", false},
+ {"empty", "", false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := ValidateDocPath(tc.in)
+ if (err == nil) != tc.ok {
+ t.Fatalf("ValidateDocPath(%q) = %v, want ok=%v", tc.in, err, tc.ok)
+ }
+ if err != nil && !errors.Is(err, ErrInvalidPath) {
+ t.Fatalf("error %v is not ErrInvalidPath", err)
+ }
+ })
+ }
+}
A core/policy.go => core/policy.go +240 -0
@@ 0,0 1,240 @@
+package core
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+ "unicode/utf8"
+
+ "gopkg.in/yaml.v3"
+)
+
+// Policy is a space's `.spec.yml`, versioned in the space itself so that policy
+// changes are reviewable like any other change.
+//
+// review:
+// auto_merge: [notes/**, reports/**]
+// schema:
+// required: [id, title, status]
+// status: [draft, review, superseded]
+type Policy struct {
+ Review ReviewPolicy `yaml:"review"`
+ Schema Schema `yaml:"schema"`
+}
+
+// ReviewPolicy carries the one real review knob. Single-user means the owner is
+// the only approver, so there is no approver list and no approval count — only
+// which paths skip the gate.
+type ReviewPolicy struct {
+ // AutoMerge lists path patterns whose proposals land immediately. This is
+ // what implements the bimodal cadence: `specs/` waits for a human, `notes/`
+ // and `reports/` flow through.
+ AutoMerge []string `yaml:"auto_merge"`
+}
+
+// DefaultPolicy is what a space with no `.spec.yml` gets: the house frontmatter
+// contract and nothing auto-merged. Defaulting auto_merge to empty is the
+// fail-closed direction — a space that has not said anything about review must
+// not be quietly laundering unreviewed agent output onto the approved branch.
+func DefaultPolicy() Policy {
+ return Policy{Schema: DefaultSchema()}
+}
+
+// ParsePolicy parses and validates a `.spec.yml`.
+//
+// Unknown keys are rejected: a `auto-merge:` typo would otherwise parse fine
+// and silently do nothing, and a policy file that lies about what it enforces
+// is worse than one that fails to load.
+//
+// A section the file omits falls back to DefaultPolicy. An omitted `schema:`
+// means "the house contract", not "a space where no status is allowed and
+// therefore no document can ever validate". An explicitly empty list
+// (`required: []`) is honoured as written — YAML distinguishes it from an
+// absent key by nil-ness, and a space that deliberately turns off required-key
+// checking must be able to say so.
+func ParsePolicy(data []byte) (Policy, error) {
+ var p Policy
+ dec := yaml.NewDecoder(bytes.NewReader(data))
+ dec.KnownFields(true)
+ if err := dec.Decode(&p); err != nil {
+ // An empty or comment-only file decodes to io.EOF. That is a valid way
+ // to say "defaults", unlike a file that fails to parse.
+ if !errors.Is(err, io.EOF) {
+ return Policy{}, fmt.Errorf("%w: %v", ErrInvalidPolicy, err)
+ }
+ return DefaultPolicy(), nil
+ }
+ // A second YAML document would be silently ignored, so a policy split over
+ // two '---' blocks would enforce only half of what it says.
+ if err := dec.Decode(new(Policy)); !errors.Is(err, io.EOF) {
+ return Policy{}, fmt.Errorf("%w: %s must contain exactly one YAML document", ErrInvalidPolicy, PolicyFile)
+ }
+
+ def := DefaultPolicy()
+ if p.Schema.Required == nil {
+ p.Schema.Required = def.Schema.Required
+ }
+ if p.Schema.Status == nil {
+ p.Schema.Status = def.Schema.Status
+ }
+
+ if err := p.Validate(); err != nil {
+ return Policy{}, err
+ }
+ return p, nil
+}
+
+// Validate reports whether the policy is internally usable: every auto_merge
+// pattern well-formed, and the schema itself well-formed.
+func (p Policy) Validate() error {
+ for _, pat := range p.Review.AutoMerge {
+ if err := ValidatePattern(pat); err != nil {
+ return fmt.Errorf("%w: review.auto_merge: %v", ErrInvalidPolicy, err)
+ }
+ }
+ return p.Schema.Validate()
+}
+
+// AutoMerges reports whether path may skip human review under this policy.
+//
+// Fail-closed: a path that does not match, or that is not a valid path at all,
+// means "needs a human". The cost of a wrong false is one click; the cost of a
+// wrong true is unreviewed agent output landing on the approved branch, which
+// is the exact failure this service exists to prevent.
+func (p Policy) AutoMerges(path string) bool {
+ if err := ValidatePath(path); err != nil {
+ return false
+ }
+ for _, pat := range p.Review.AutoMerge {
+ if MatchPattern(pat, path) {
+ return true
+ }
+ }
+ return false
+}
+
+// ValidatePattern reports whether pat is a well-formed auto_merge pattern.
+// The grammar is the familiar globstar subset, and nothing more:
+//
+// ** as a whole component, matches zero or more path components
+// * matches any run of characters within one component, never '/'
+// ? matches exactly one character within one component
+//
+// Bracket expressions are deliberately absent, so '[' is a literal. Structural
+// rules mirror ValidatePath (relative, no empty or traversal components), plus
+// one of its own: "**" must be an entire component, because "a**b" has no
+// obvious meaning and guessing one is how a policy comes to mean something its
+// author did not intend.
+func ValidatePattern(pat string) error {
+ if pat == "" {
+ return fmt.Errorf("%w: empty pattern", ErrInvalidPattern)
+ }
+ if len(pat) > MaxPathLen {
+ return fmt.Errorf("%w: pattern is too long (%d > %d)", ErrInvalidPattern, len(pat), MaxPathLen)
+ }
+ if !utf8.ValidString(pat) {
+ return fmt.Errorf("%w: pattern is not valid UTF-8", ErrInvalidPattern)
+ }
+ for _, r := range pat {
+ if badPathRune(r) {
+ return fmt.Errorf("%w: pattern %q contains disallowed rune %U", ErrInvalidPattern, pat, r)
+ }
+ }
+ if strings.HasPrefix(pat, "/") {
+ return fmt.Errorf("%w: pattern %q must be relative", ErrInvalidPattern, pat)
+ }
+ if strings.HasSuffix(pat, "/") {
+ return fmt.Errorf("%w: pattern %q must not end in '/' (write %q to match a subtree)",
+ ErrInvalidPattern, pat, pat+"**")
+ }
+ if strings.Contains(pat, `\`) {
+ return fmt.Errorf("%w: pattern %q must not contain a backslash", ErrInvalidPattern, pat)
+ }
+ for _, comp := range strings.Split(pat, "/") {
+ switch comp {
+ case "":
+ return fmt.Errorf("%w: pattern %q has an empty component", ErrInvalidPattern, pat)
+ case ".", "..":
+ return fmt.Errorf("%w: pattern %q has a traversal component %q", ErrInvalidPattern, pat, comp)
+ case "**":
+ continue
+ }
+ if strings.Contains(comp, "**") {
+ return fmt.Errorf("%w: pattern %q: %q must be a whole path component", ErrInvalidPattern, pat, "**")
+ }
+ }
+ return nil
+}
+
+// MatchPattern reports whether path matches pat under the grammar documented on
+// ValidatePattern. An invalid pattern matches nothing; Policy.Validate rejects
+// those up front, so a live policy never contains one.
+//
+// Note that "**" matching zero components means "notes/**" also matches the
+// bare path "notes". That is the price of "**/*.md" matching a document at the
+// space root, which is the case that actually comes up.
+func MatchPattern(pat, path string) bool {
+ if ValidatePattern(pat) != nil {
+ return false
+ }
+ return matchComponents(strings.Split(pat, "/"), strings.Split(path, "/"))
+}
+
+func matchComponents(pats, segs []string) bool {
+ for len(pats) > 0 {
+ if pats[0] == "**" {
+ if len(pats) == 1 {
+ return true
+ }
+ for i := 0; i <= len(segs); i++ {
+ if matchComponents(pats[1:], segs[i:]) {
+ return true
+ }
+ }
+ return false
+ }
+ if len(segs) == 0 {
+ return false
+ }
+ if !matchSegment(pats[0], segs[0]) {
+ return false
+ }
+ pats, segs = pats[1:], segs[1:]
+ }
+ return len(segs) == 0
+}
+
+// matchSegment matches one path component against one pattern component using
+// '*' and '?'. Iterative with a single backtrack point, so a pathological
+// pattern such as "*a*a*a*a*" stays linear-ish instead of exponential — these
+// patterns come from a config file, but the config file is versioned content
+// that an agent can propose.
+func matchSegment(pat, s string) bool {
+ p := []rune(pat)
+ t := []rune(s)
+ var pi, ti, resume int
+ star := -1
+ for ti < len(t) {
+ switch {
+ case pi < len(p) && (p[pi] == '?' || p[pi] == t[ti]):
+ pi++
+ ti++
+ case pi < len(p) && p[pi] == '*':
+ star = pi
+ resume = ti
+ pi++
+ case star >= 0:
+ resume++
+ pi = star + 1
+ ti = resume
+ default:
+ return false
+ }
+ }
+ for pi < len(p) && p[pi] == '*' {
+ pi++
+ }
+ return pi == len(p)
+}
A core/policy_test.go => core/policy_test.go +321 -0
@@ 0,0 1,321 @@
+package core
+
+import (
+ "errors"
+ "strings"
+ "testing"
+)
+
+func TestValidatePattern(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ ok bool
+ }{
+ {"subtree", "notes/**", true},
+ {"literal", "specs/0007.md", true},
+ {"star in component", "specs/*.md", true},
+ {"question mark", "specs/000?.md", true},
+ {"leading globstar", "**/*.md", true},
+ {"interior globstar", "notes/**/drafts/*.md", true},
+ {"bare globstar", "**", true},
+ {"bare star", "*", true},
+ {"cyrillic", "спеки/**", true},
+ {"bracket is literal", "specs/[draft].md", true},
+
+ {"empty", "", false},
+ {"too long", strings.Repeat("a", MaxPathLen+1), false},
+ {"absolute", "/notes/**", false},
+ {"trailing slash", "notes/", false},
+ {"empty component", "notes//**", false},
+ {"traversal", "../notes/**", false},
+ {"interior traversal", "notes/../specs/**", false},
+ {"dot component", "./notes/**", false},
+ {"backslash", `notes\**`, false},
+ // "a**b" has no obvious meaning; guessing one is how a policy comes to
+ // mean something its author did not intend.
+ {"globstar not whole component", "notes**", false},
+ {"globstar prefixed", "**notes/x", false},
+ {"globstar suffixed", "notes/**x", false},
+ {"triple star", "notes/***", false},
+ {"nul byte", "notes/\x00**", false},
+ {"newline", "notes/**\n", false},
+ {"rtl override", "notes/\u202e**", false},
+ {"invalid utf-8", "notes/\xff\xfe", false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := ValidatePattern(tc.in)
+ if (err == nil) != tc.ok {
+ t.Fatalf("ValidatePattern(%q) = %v, want ok=%v", tc.in, err, tc.ok)
+ }
+ if err != nil && !errors.Is(err, ErrInvalidPattern) {
+ t.Fatalf("error %v is not ErrInvalidPattern", err)
+ }
+ })
+ }
+}
+
+func TestMatchPattern(t *testing.T) {
+ tests := []struct {
+ pat string
+ path string
+ want bool
+ }{
+ // Subtree matching, the shape the design's example uses.
+ {"notes/**", "notes/a.md", true},
+ {"notes/**", "notes/2026/07/a.md", true},
+ {"notes/**", "notes", true}, // "**" matches zero components; documented
+ {"notes/**", "notesx/a.md", false},
+ {"notes/**", "specs/a.md", false},
+ {"notes/**", "a/notes/b.md", false},
+ {"reports/**", "reports/weekly/2026-07.md", true},
+
+ // "*" never crosses a component boundary.
+ {"specs/*.md", "specs/0007.md", true},
+ {"specs/*.md", "specs/sub/0007.md", false},
+ {"specs/*", "specs/0007.md", true},
+ {"specs/*", "specs", false},
+ {"*", "a.md", true},
+ {"*", "a/b.md", false},
+ {"*.md", "a.md", true},
+ {"*.md", "a.markdown", false},
+
+ // "?" is exactly one character, counted in runes.
+ {"specs/000?.md", "specs/0007.md", true},
+ {"specs/000?.md", "specs/00007.md", false},
+ {"?.md", "я.md", true},
+ {"??.md", "я.md", false},
+
+ // "**" in the middle and at the front.
+ {"**/*.md", "a.md", true},
+ {"**/*.md", "notes/a.md", true},
+ {"**/*.md", "a/b/c.md", true},
+ {"**/*.md", "a/b/c.png", false},
+ {"notes/**/*.md", "notes/a.md", true},
+ {"notes/**/*.md", "notes/2026/a.md", true},
+ {"notes/**/drafts/*.md", "notes/x/y/drafts/a.md", true},
+ {"notes/**/drafts/*.md", "notes/drafts/a.md", true},
+ {"notes/**/drafts/*.md", "notes/drafts/sub/a.md", false},
+ {"**", "anything/at/all.md", true},
+
+ // Backtracking within a component.
+ {"a*b*c", "abc", true},
+ {"a*b*c", "axxbxxc", true},
+ {"a*b*c", "axxbxx", false},
+ {"*a*a*a*a*b", strings.Repeat("a", 40), false},
+
+ // Literals, including the deliberately unsupported bracket syntax.
+ {"specs/0007.md", "specs/0007.md", true},
+ {"specs/0007.md", "specs/0008.md", false},
+ {"specs/[ab].md", "specs/a.md", false},
+ {"specs/[ab].md", "specs/[ab].md", true},
+
+ // An invalid pattern matches nothing rather than everything.
+ {"", "a.md", false},
+ {"/notes/**", "notes/a.md", false},
+ {"notes**", "notes/a.md", false},
+ {"../**", "a.md", false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.pat+" ~ "+tc.path, func(t *testing.T) {
+ if got := MatchPattern(tc.pat, tc.path); got != tc.want {
+ t.Fatalf("MatchPattern(%q, %q) = %v, want %v", tc.pat, tc.path, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestParsePolicy(t *testing.T) {
+ const full = `review:
+ auto_merge: [notes/**, reports/**]
+schema:
+ required: [id, title, status]
+ status: [draft, review, superseded]
+`
+ p, err := ParsePolicy([]byte(full))
+ if err != nil {
+ t.Fatalf("ParsePolicy: %v", err)
+ }
+ if got, want := strings.Join(p.Review.AutoMerge, ","), "notes/**,reports/**"; got != want {
+ t.Fatalf("auto_merge = %q, want %q", got, want)
+ }
+ if got, want := joinStatuses(p.Schema.Status), "draft|review|superseded"; got != want {
+ t.Fatalf("schema.status = %q, want %q", got, want)
+ }
+ if got, want := strings.Join(p.Schema.Required, ","), "id,title,status"; got != want {
+ t.Fatalf("schema.required = %q, want %q", got, want)
+ }
+}
+
+func TestParsePolicyDefaults(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ }{
+ {"empty file", ""},
+ {"comment only", "# nothing configured yet\n"},
+ {"review section only", "review:\n auto_merge: [notes/**]\n"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ p, err := ParsePolicy([]byte(tc.in))
+ if err != nil {
+ t.Fatalf("ParsePolicy: %v", err)
+ }
+ // An omitted schema means the house contract, not "no status is
+ // allowed and therefore no document can ever validate".
+ if got, want := joinStatuses(p.Schema.Status), "draft|review|superseded"; got != want {
+ t.Fatalf("schema.status = %q, want the default %q", got, want)
+ }
+ if got, want := strings.Join(p.Schema.Required, ","), "id,title,status"; got != want {
+ t.Fatalf("schema.required = %q, want the default %q", got, want)
+ }
+ })
+ }
+}
+
+func TestParsePolicyEmptyListIsHonoured(t *testing.T) {
+ // `required: []` is an explicit "no required keys", which YAML tells apart
+ // from an absent key by nil-ness. Overwriting it with the default would
+ // make the setting unexpressible.
+ p, err := ParsePolicy([]byte("schema:\n required: []\n"))
+ if err != nil {
+ t.Fatalf("ParsePolicy: %v", err)
+ }
+ if len(p.Schema.Required) != 0 {
+ t.Fatalf("schema.required = %v, want an empty list", p.Schema.Required)
+ }
+ if len(p.Schema.Status) != 3 {
+ t.Fatalf("schema.status = %v, want the default enum", p.Schema.Status)
+ }
+}
+
+func TestParsePolicyErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ }{
+ // A typo'd key would parse fine and silently enforce nothing.
+ {"typo'd auto_merge", "review:\n auto-merge: [notes/**]\n"},
+ {"unknown top-level key", "reviews:\n auto_merge: [notes/**]\n"},
+ {"unknown schema key", "schema:\n requires: [id]\n"},
+ {"approvers reintroduced", "review:\n approvers: [~bigbes]\n"},
+
+ {"not a mapping", "- review\n"},
+ {"scalar", "just a string\n"},
+ {"broken yaml", "review:\n auto_merge: [notes/**\n"},
+ {"tab indentation", "review:\n\tauto_merge: [a]\n"},
+ {"two documents", "review:\n auto_merge: [notes/**]\n---\nschema:\n required: [id]\n"},
+ {"auto_merge as scalar", "review:\n auto_merge: notes/**\n"},
+
+ {"invalid pattern", "review:\n auto_merge: [../etc/**]\n"},
+ {"absolute pattern", "review:\n auto_merge: [/notes/**]\n"},
+ {"malformed globstar", "review:\n auto_merge: [notes**]\n"},
+ // The critical one: a per-space file must not be able to make
+ // "approved" an authored status.
+ {"approved status", "schema:\n status: [draft, approved]\n"},
+ {"unknown status", "schema:\n status: [wip]\n"},
+ {"empty status list", "schema:\n status: []\n"},
+ {"duplicate required key", "schema:\n required: [id, id]\n"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ p, err := ParsePolicy([]byte(tc.in))
+ if err == nil {
+ t.Fatalf("ParsePolicy(%q) = %+v, want an error", tc.in, p)
+ }
+ if !errors.Is(err, ErrInvalidPolicy) {
+ t.Fatalf("error %v is not ErrInvalidPolicy", err)
+ }
+ })
+ }
+}
+
+func TestPolicyAutoMerges(t *testing.T) {
+ p, err := ParsePolicy([]byte("review:\n auto_merge: [notes/**, reports/**, drafts/*.md]\n"))
+ if err != nil {
+ t.Fatalf("ParsePolicy: %v", err)
+ }
+
+ tests := []struct {
+ path string
+ want bool
+ }{
+ {"notes/a.md", true},
+ {"notes/2026/07/standup.md", true},
+ {"reports/weekly.md", true},
+ {"drafts/x.md", true},
+
+ {"specs/0007-storage.md", false},
+ {"drafts/sub/x.md", false},
+ {"README.md", false},
+ {".spec.yml", false},
+ {"notesx/a.md", false},
+
+ // Fail-closed: a path that is not a valid path cannot auto-merge, so a
+ // traversal attempt goes to a human rather than straight to approved.
+ {"notes/../specs/0007.md", false},
+ {"../notes/a.md", false},
+ {"/notes/a.md", false},
+ {"notes/a\x00.md", false},
+ {"", false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.path, func(t *testing.T) {
+ if got := p.AutoMerges(tc.path); got != tc.want {
+ t.Fatalf("AutoMerges(%q) = %v, want %v", tc.path, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestDefaultPolicyAutoMergesNothing(t *testing.T) {
+ // A space that has said nothing about review must not be laundering
+ // unreviewed agent output onto the approved branch.
+ p := DefaultPolicy()
+ if err := p.Validate(); err != nil {
+ t.Fatalf("DefaultPolicy().Validate() = %v, want nil", err)
+ }
+ for _, path := range []string{"notes/a.md", "specs/a.md", "anything.md"} {
+ if p.AutoMerges(path) {
+ t.Errorf("DefaultPolicy().AutoMerges(%q) = true, want false", path)
+ }
+ }
+}
+
+func TestPolicyValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ in Policy
+ ok bool
+ }{
+ {"default", DefaultPolicy(), true},
+ {
+ "good patterns",
+ Policy{Review: ReviewPolicy{AutoMerge: []string{"notes/**", "*.md"}}, Schema: DefaultSchema()},
+ true,
+ },
+ {
+ "bad pattern",
+ Policy{Review: ReviewPolicy{AutoMerge: []string{"notes/**", "../x"}}, Schema: DefaultSchema()},
+ false,
+ },
+ {
+ "bad schema",
+ Policy{Schema: Schema{Status: []Status{"approved"}}},
+ false,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := tc.in.Validate()
+ if (err == nil) != tc.ok {
+ t.Fatalf("Policy.Validate() = %v, want ok=%v", err, tc.ok)
+ }
+ if err != nil && !errors.Is(err, ErrInvalidPolicy) {
+ t.Fatalf("error %v is not ErrInvalidPolicy", err)
+ }
+ })
+ }
+}
A core/proposal.go => core/proposal.go +93 -0
@@ 0,0 1,93 @@
+package core
+
+import "fmt"
+
+// ProposalState is the lifecycle of a proposal.
+//
+// open ──► merged
+// └───► rejected
+//
+// Collapsed from the usual five-state machine because there is exactly one
+// reviewer: with nobody else in the loop, "approve" is "merge now", and there
+// is no one to request changes from — a proposal you dislike is rejected and
+// the agent proposes again. Keeping `approved` and `merged` apart, or a
+// `changes-requested` cycle, would be machinery serving a review conversation
+// that has no second party.
+type ProposalState string
+
+const (
+ StateOpen ProposalState = "open"
+ StateMerged ProposalState = "merged"
+ StateRejected ProposalState = "rejected"
+)
+
+// ProposalStates returns every state, in lifecycle order.
+func ProposalStates() []ProposalState {
+ return []ProposalState{StateOpen, StateMerged, StateRejected}
+}
+
+// ParseProposalState validates a state string, typically one read back from
+// Postgres or an API request.
+func ParseProposalState(s string) (ProposalState, error) {
+ switch ProposalState(s) {
+ case StateOpen, StateMerged, StateRejected:
+ return ProposalState(s), nil
+ }
+ return "", fmt.Errorf("%w: %q is not one of open|merged|rejected", ErrInvalidState, s)
+}
+
+// Terminal reports whether the proposal has been resolved. Terminal proposals
+// keep their URL — a link still resolves after merge or rejection, showing the
+// outcome — but they never move again.
+func (s ProposalState) Terminal() bool {
+ return s == StateMerged || s == StateRejected
+}
+
+// CanTransitionTo reports whether the proposal may move from s to next,
+// returning ErrInvalidTransition with both states named if it may not.
+//
+// Only open→merged and open→rejected are legal. Self-transitions are rejected
+// too: the reconciler repairs a crashed merge by comparing the ref against the
+// row and only writing when they differ, so a "merged→merged" call is a bug in
+// the caller rather than an idempotent retry.
+func (s ProposalState) CanTransitionTo(next ProposalState) error {
+ if _, err := ParseProposalState(string(s)); err != nil {
+ return err
+ }
+ if _, err := ParseProposalState(string(next)); err != nil {
+ return err
+ }
+ if !ValidTransition(s, next) {
+ return fmt.Errorf("%w: %s -> %s", ErrInvalidTransition, s, next)
+ }
+ return nil
+}
+
+// ValidTransition is the transition table itself.
+func ValidTransition(from, to ProposalState) bool {
+ return from == StateOpen && (to == StateMerged || to == StateRejected)
+}
+
+// Approval records how a merge was authorized, and is set on merge.
+//
+// Auto-merged is not human-approved, and readers must be able to tell: a bot
+// asking for the approved text of a spec should be able to require human
+// approval and get a different answer than for a firehose note. Collapsing the
+// two would quietly launder unreviewed agent output as blessed.
+type Approval string
+
+const (
+ // ApprovalHuman means the owner clicked approve.
+ ApprovalHuman Approval = "human"
+ // ApprovalPolicy means the path matched the space's auto_merge patterns.
+ ApprovalPolicy Approval = "policy"
+)
+
+// ParseApproval validates an approval kind read back from Postgres or an API.
+func ParseApproval(s string) (Approval, error) {
+ switch Approval(s) {
+ case ApprovalHuman, ApprovalPolicy:
+ return Approval(s), nil
+ }
+ return "", fmt.Errorf("%w: %q is not one of human|policy", ErrInvalidApproval, s)
+}
A core/proposal_test.go => core/proposal_test.go +147 -0
@@ 0,0 1,147 @@
+package core
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestParseProposalState(t *testing.T) {
+ tests := []struct {
+ in string
+ ok bool
+ }{
+ {"open", true},
+ {"merged", true},
+ {"rejected", true},
+
+ // The states that were deliberately collapsed away. Accepting either
+ // would resurrect a review conversation that has no second party.
+ {"approved", false},
+ {"changes-requested", false},
+ {"closed", false},
+ {"draft", false},
+
+ {"", false},
+ {"Open", false},
+ {"OPEN", false},
+ {"open ", false},
+ {" open", false},
+ {"открыт", false},
+ }
+ for _, tc := range tests {
+ st, err := ParseProposalState(tc.in)
+ if (err == nil) != tc.ok {
+ t.Errorf("ParseProposalState(%q) = %q, %v, want ok=%v", tc.in, st, err, tc.ok)
+ }
+ if err != nil && !errors.Is(err, ErrInvalidState) {
+ t.Errorf("ParseProposalState(%q) error %v is not ErrInvalidState", tc.in, err)
+ }
+ }
+}
+
+func TestProposalStatesIsExactlyThree(t *testing.T) {
+ got := ProposalStates()
+ if len(got) != 3 {
+ t.Fatalf("ProposalStates() = %v, want exactly open/merged/rejected", got)
+ }
+ want := []ProposalState{StateOpen, StateMerged, StateRejected}
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("ProposalStates() = %v, want %v", got, want)
+ }
+ }
+}
+
+func TestProposalTransitions(t *testing.T) {
+ all := []ProposalState{StateOpen, StateMerged, StateRejected}
+
+ // The whole table, explicitly: only the two edges out of `open` exist.
+ want := map[ProposalState]map[ProposalState]bool{
+ StateOpen: {StateOpen: false, StateMerged: true, StateRejected: true},
+ StateMerged: {StateOpen: false, StateMerged: false, StateRejected: false},
+ StateRejected: {StateOpen: false, StateMerged: false, StateRejected: false},
+ }
+
+ for _, from := range all {
+ for _, to := range all {
+ t.Run(string(from)+"->"+string(to), func(t *testing.T) {
+ expect := want[from][to]
+ if got := ValidTransition(from, to); got != expect {
+ t.Fatalf("ValidTransition(%s, %s) = %v, want %v", from, to, got, expect)
+ }
+ err := from.CanTransitionTo(to)
+ if (err == nil) != expect {
+ t.Fatalf("%s.CanTransitionTo(%s) = %v, want ok=%v", from, to, err, expect)
+ }
+ if err != nil && !errors.Is(err, ErrInvalidTransition) {
+ t.Fatalf("error %v is not ErrInvalidTransition", err)
+ }
+ })
+ }
+ }
+}
+
+func TestCanTransitionToRejectsUnknownStates(t *testing.T) {
+ // An unparseable state is a different failure class from an illegal edge:
+ // it means something wrote garbage into the proposal row.
+ if err := ProposalState("approved").CanTransitionTo(StateMerged); !errors.Is(err, ErrInvalidState) {
+ t.Fatalf("from=approved: %v, want ErrInvalidState", err)
+ }
+ if err := StateOpen.CanTransitionTo("approved"); !errors.Is(err, ErrInvalidState) {
+ t.Fatalf("to=approved: %v, want ErrInvalidState", err)
+ }
+ if err := ProposalState("").CanTransitionTo(StateMerged); !errors.Is(err, ErrInvalidState) {
+ t.Fatalf("from=empty: %v, want ErrInvalidState", err)
+ }
+}
+
+func TestProposalStateTerminal(t *testing.T) {
+ tests := []struct {
+ in ProposalState
+ want bool
+ }{
+ {StateOpen, false},
+ {StateMerged, true},
+ {StateRejected, true},
+ {"", false},
+ {"approved", false},
+ }
+ for _, tc := range tests {
+ if got := tc.in.Terminal(); got != tc.want {
+ t.Errorf("%q.Terminal() = %v, want %v", tc.in, got, tc.want)
+ }
+ }
+}
+
+func TestParseApproval(t *testing.T) {
+ tests := []struct {
+ in string
+ ok bool
+ }{
+ {"human", true},
+ {"policy", true},
+
+ {"", false},
+ {"auto", false},
+ {"Human", false},
+ {"human ", false},
+ {"approved", false},
+ }
+ for _, tc := range tests {
+ a, err := ParseApproval(tc.in)
+ if (err == nil) != tc.ok {
+ t.Errorf("ParseApproval(%q) = %q, %v, want ok=%v", tc.in, a, err, tc.ok)
+ }
+ if err != nil && !errors.Is(err, ErrInvalidApproval) {
+ t.Errorf("ParseApproval(%q) error %v is not ErrInvalidApproval", tc.in, err)
+ }
+ }
+}
+
+// A policy-merged document must be distinguishable from a human-approved one:
+// collapsing the two would quietly launder unreviewed agent output as blessed.
+func TestApprovalKindsAreDistinct(t *testing.T) {
+ if ApprovalHuman == ApprovalPolicy {
+ t.Fatal("human and policy approval must not be the same value")
+ }
+}
A go.mod => go.mod +107 -0
@@ 0,0 1,107 @@
+module sourcecraft.dev/bigbes/sr-ht-spec
+
+go 1.26.4
+
+require (
+ git.sr.ht/~bitfehler/brant v0.5.1
+ github.com/blevesearch/bleve/v2 v2.6.0
+ github.com/go-chi/chi/v5 v5.3.1
+ github.com/go-git/go-git/v5 v5.19.1
+ github.com/lib/pq v1.10.9
+ github.com/modelcontextprotocol/go-sdk v1.6.1
+ github.com/stretchr/testify v1.11.1
+ github.com/yuin/goldmark v1.8.2
+ go.bigb.es/auxilia v0.5.0
+ gopkg.in/yaml.v3 v3.0.1
+ sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152
+)
+
+require (
+ dario.cat/mergo v1.0.0 // indirect
+ git.sr.ht/~sircmpwn/dowork v0.0.0-20241216125407-2b00aa42322c // indirect
+ git.sr.ht/~sircmpwn/getopt v1.0.0 // indirect
+ git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9 // indirect
+ github.com/99designs/gqlgen v0.17.36 // indirect
+ github.com/Masterminds/squirrel v1.5.4 // indirect
+ github.com/ProtonMail/go-crypto v1.3.0 // indirect
+ github.com/RoaringBitmap/roaring/v2 v2.14.5 // indirect
+ github.com/agnivade/levenshtein v1.1.1 // indirect
+ github.com/alexflint/go-arg v1.6.0 // indirect
+ github.com/alexflint/go-scalar v1.2.0 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/bits-and-blooms/bitset v1.24.2 // indirect
+ github.com/blevesearch/bleve_index_api v1.3.11 // indirect
+ github.com/blevesearch/geo v0.2.5 // indirect
+ github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
+ github.com/blevesearch/gtreap v0.1.1 // indirect
+ github.com/blevesearch/mmap-go v1.2.0 // indirect
+ github.com/blevesearch/scorch_segment_api/v2 v2.4.7 // indirect
+ github.com/blevesearch/segment v0.9.1 // indirect
+ github.com/blevesearch/snowballstem v0.9.0 // indirect
+ github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
+ github.com/blevesearch/vellum v1.2.0 // indirect
+ github.com/blevesearch/zapx/v11 v11.4.3 // indirect
+ github.com/blevesearch/zapx/v12 v12.4.3 // indirect
+ github.com/blevesearch/zapx/v13 v13.4.3 // indirect
+ github.com/blevesearch/zapx/v14 v14.4.3 // indirect
+ github.com/blevesearch/zapx/v15 v15.4.3 // indirect
+ github.com/blevesearch/zapx/v16 v16.3.4 // indirect
+ github.com/blevesearch/zapx/v17 v17.1.2 // indirect
+ github.com/cespare/xxhash/v2 v2.2.0 // indirect
+ github.com/cloudflare/circl v1.6.3 // indirect
+ github.com/cyphar/filepath-securejoin v0.6.1 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+ github.com/emersion/go-message v0.18.2 // indirect
+ github.com/emersion/go-pgpmail v0.2.2 // indirect
+ github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
+ github.com/emersion/go-smtp v0.21.3 // indirect
+ github.com/emirpasic/gods v1.18.1 // indirect
+ github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee // indirect
+ github.com/go-chi/cors v1.2.2 // indirect
+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+ github.com/go-git/go-billy/v5 v5.9.0 // indirect
+ github.com/go-redis/redis/v8 v8.11.5 // indirect
+ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
+ github.com/golang/protobuf v1.5.4 // indirect
+ github.com/golang/snappy v1.0.0 // indirect
+ github.com/google/jsonschema-go v0.4.3 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/websocket v1.5.0 // indirect
+ github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/kavu/go_reuseport v1.5.0 // indirect
+ github.com/kevinburke/ssh_config v1.6.0 // indirect
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
+ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
+ github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
+ github.com/mfridman/interpolate v0.0.2 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/mschoch/smat v0.2.0 // indirect
+ github.com/pjbgf/sha1cd v0.6.0 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/prometheus/client_golang v1.16.0 // indirect
+ github.com/prometheus/client_model v0.4.0 // indirect
+ github.com/prometheus/common v0.44.0 // indirect
+ github.com/prometheus/procfs v0.12.0 // indirect
+ github.com/segmentio/asm v1.1.3 // indirect
+ github.com/segmentio/encoding v0.5.4 // indirect
+ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
+ github.com/skeema/knownhosts v1.3.1 // indirect
+ github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec // indirect
+ github.com/vektah/gqlparser/v2 v2.5.8 // indirect
+ github.com/xanzy/ssh-agent v0.3.3 // indirect
+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+ go.etcd.io/bbolt v1.4.0 // indirect
+ golang.org/x/crypto v0.52.0 // indirect
+ golang.org/x/net v0.54.0 // indirect
+ golang.org/x/oauth2 v0.35.0 // indirect
+ golang.org/x/sys v0.45.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
+ google.golang.org/protobuf v1.36.6 // indirect
+ gopkg.in/warnings.v0 v0.1.2 // indirect
+)
A go.sum => go.sum +358 -0
@@ 0,0 1,358 @@
+dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+git.sr.ht/~bitfehler/brant v0.5.1 h1:emUbT5r1P0p2mgC/52NMAzGa2zw2To/Bim5ySeyGrks=
+git.sr.ht/~bitfehler/brant v0.5.1/go.mod h1:XD6RmtKTOT/RL+2iQ+KBcPhLYAjeJWdLj3yfM5+7k/g=
+git.sr.ht/~sircmpwn/dowork v0.0.0-20241216125407-2b00aa42322c h1:v2opuaN0C5ZpuCifRNR9ZQ8V9IG+Ja80otK1MFj5RnI=
+git.sr.ht/~sircmpwn/dowork v0.0.0-20241216125407-2b00aa42322c/go.mod h1:8neHEO3503w/rNtttnR0JFpQgM/GFhaafVwvkPsFIDw=
+git.sr.ht/~sircmpwn/getopt v0.0.0-20191230200459-23622cc906b3/go.mod h1:wMEGFFFNuPos7vHmWXfszqImLppbc0wEhh6JBfJIUgw=
+git.sr.ht/~sircmpwn/getopt v1.0.0 h1:/pRHjO6/OCbBF4puqD98n6xtPEgE//oq5U8NXjP7ROc=
+git.sr.ht/~sircmpwn/getopt v1.0.0/go.mod h1:wMEGFFFNuPos7vHmWXfszqImLppbc0wEhh6JBfJIUgw=
+git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9 h1:Ahny8Ud1LjVMMAlt8utUFKhhxJtwBAualvsbc/Sk7cE=
+git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9/go.mod h1:BVJwbDfVjCjoFiKrhkei6NdGcZYpkDkdyCdg1ukytRA=
+git.srht.bigb.es/~bigbes/core-go v0.0.0-20260718172441-c2c2f3848fa9 h1:S/CncMS83NIBz+V5OlZEHlnrl1Yd5I4C7hDQ7lzQsTM=
+git.srht.bigb.es/~bigbes/core-go v0.0.0-20260718172441-c2c2f3848fa9/go.mod h1:JmathMemB+hBk1IgrMn6RuBGusd+fTg1s/X6rNVKXXU=
+github.com/99designs/gqlgen v0.17.36 h1:u/o/rv2SZ9s5280dyUOOrkpIIkr/7kITMXYD3rkJ9go=
+github.com/99designs/gqlgen v0.17.36/go.mod h1:6RdyY8puhCoWAQVr2qzF2OMVfudQzc8ACxzpzluoQm4=
+github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
+github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
+github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
+github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
+github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
+github.com/RoaringBitmap/roaring/v2 v2.14.5 h1:ckd0o545JqDPeVJDgeFoaM21eBixUnlWfYgjE5VnyWw=
+github.com/RoaringBitmap/roaring/v2 v2.14.5/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4=
+github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8=
+github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
+github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/alexflint/go-arg v1.6.0 h1:wPP9TwTPO54fUVQl4nZoxbFfKCcy5E6HBCumj1XVRSo=
+github.com/alexflint/go-arg v1.6.0/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8=
+github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
+github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
+github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
+github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
+github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
+github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD3vOaFxp0=
+github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
+github.com/blevesearch/bleve/v2 v2.6.0 h1:Cyd3dd4q5tCbOV8MnKUVRUDYMHOir9xn12NZzXVSEd4=
+github.com/blevesearch/bleve/v2 v2.6.0/go.mod h1:gLmI8lWgHgrIYf7UpUX7JISI1CaqC6VScu46mHThuAY=
+github.com/blevesearch/bleve_index_api v1.3.11 h1:x29vbV8OjWfLcrDVd7Lr1q+BkLNS0JWNEig0MCVnKH4=
+github.com/blevesearch/bleve_index_api v1.3.11/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko=
+github.com/blevesearch/geo v0.2.5 h1:yJg9FX1oRwLnjXSXF+ECHfXFTF4diF02Ca/qUGVjJhE=
+github.com/blevesearch/geo v0.2.5/go.mod h1:Jhq7WE2K6mJTx1xS44M2pUO6Io+wjCSHh1+co3YOgH4=
+github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
+github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
+github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
+github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
+github.com/blevesearch/mmap-go v1.2.0 h1:l33nNKPFcBjJUMwem6sAYJPUzhUCABoK9FxZDGiFNBI=
+github.com/blevesearch/mmap-go v1.2.0/go.mod h1:Vd6+20GBhEdwJnU1Xohgt88XCD/CTWcqbCNxkZpyBo0=
+github.com/blevesearch/scorch_segment_api/v2 v2.4.7 h1:GlMzW08hcsM3DnLUxhyF/1PcDal1qtvvIuytuph5djw=
+github.com/blevesearch/scorch_segment_api/v2 v2.4.7/go.mod h1://IJ7tG3QCf0cWW/aVSXqy77tc1AvLu3fcJLYEvOAFs=
+github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
+github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
+github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
+github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
+github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
+github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
+github.com/blevesearch/vellum v1.2.0 h1:xkDiOEsHc2t3Cp0NsNZZ36pvc130sCzcGKOPMzXe+e0=
+github.com/blevesearch/vellum v1.2.0/go.mod h1:uEcfBJz7mAOf0Kvq6qoEKQQkLODBF46SINYNkZNae4k=
+github.com/blevesearch/zapx/v11 v11.4.3 h1:PTZOO5loKpHC/x/GzmPZNa9cw7GZIQxd5qRjwij9tHY=
+github.com/blevesearch/zapx/v11 v11.4.3/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
+github.com/blevesearch/zapx/v12 v12.4.3 h1:eElXvAaAX4m04t//CGBQAtHNPA+Q6A1hHZVrN3LSFYo=
+github.com/blevesearch/zapx/v12 v12.4.3/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
+github.com/blevesearch/zapx/v13 v13.4.3 h1:qsdhRhaSpVnqDFlRiH9vG5+KJ+dE7KAW9WyZz/KXAiE=
+github.com/blevesearch/zapx/v13 v13.4.3/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
+github.com/blevesearch/zapx/v14 v14.4.3 h1:GY4Hecx0C6UTmiNC2pKdeA2rOKiLR5/rwpU9WR51dgM=
+github.com/blevesearch/zapx/v14 v14.4.3/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
+github.com/blevesearch/zapx/v15 v15.4.3 h1:iJiMJOHrz216jyO6lS0m9RTCEkprUnzvqAI2lc/0/CU=
+github.com/blevesearch/zapx/v15 v15.4.3/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
+github.com/blevesearch/zapx/v16 v16.3.4 h1:hDAqA8qusZTNbPEL7//w5P65UZ2de6yhSeUaTbp0Po0=
+github.com/blevesearch/zapx/v16 v16.3.4/go.mod h1:zqkPPqs9GS9FzVWzCO3Wf1X044yWAV17+4zb+FTiEHg=
+github.com/blevesearch/zapx/v17 v17.1.2 h1:avbOk2igaASNoiy0BE/jPgcxAnRI2PGeydeP4hg7Ikk=
+github.com/blevesearch/zapx/v17 v17.1.2/go.mod h1:WQObxKrqUX7cd0G1GMvDfc/bmZzQvoy7APOPimx7DiI=
+github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
+github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
+github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
+github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
+github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
+github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
+github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
+github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
+github.com/emersion/go-message v0.17.0/go.mod h1:/9Bazlb1jwUNB0npYYBsdJ2EMOiiyN3m5UVHbY7GoNw=
+github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
+github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
+github.com/emersion/go-pgpmail v0.2.2 h1:cO2jwsE0gb8aDdCcVH5Dfe1XV3Rhhw2GVWsmQd3CbaI=
+github.com/emersion/go-pgpmail v0.2.2/go.mod h1:mRB5P7QKiAuOvcT36tdRZvm7nSt7V+f6jbzzup3HuvU=
+github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
+github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY=
+github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
+github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY=
+github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
+github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
+github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
+github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee h1:v6Eju/FhxsACGNipFEPBZZAzGr1F/jlRQr1qiBw2nEE=
+github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee/go.mod h1:2H9hjfbpSMHwY503FclkV/lZTBh2YlOmLLSda12uL8c=
+github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
+github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
+github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
+github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
+github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
+github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
+github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
+github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
+github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
+github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
+github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
+github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
+github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
+github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
+github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
+github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
+github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
+github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
+github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/kavu/go_reuseport v1.5.0 h1:UNuiY2OblcqAtVDE8Gsg1kZz8zbBWg907sP1ceBV+bk=
+github.com/kavu/go_reuseport v1.5.0/go.mod h1:CG8Ee7ceMFSMnx/xr25Vm0qXaj2Z4i5PWoUx+JZ5/CU=
+github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
+github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
+github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
+github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
+github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
+github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
+github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
+github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
+github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
+github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
+github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
+github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
+github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
+github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
+github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
+github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8=
+github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc=
+github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
+github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
+github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
+github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
+github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
+github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
+github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
+github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
+github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
+github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
+github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
+github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
+github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
+github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
+github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
+github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
+github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
+github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
+github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
+github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec h1:DGmKwyZwEB8dI7tbLt/I/gQuP559o/0FrAkHKlQM/Ks=
+github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec/go.mod h1:owBmyHYMLkxyrugmfwE/DLJyW8Ro9mkphwuVErQ0iUw=
+github.com/vektah/gqlparser/v2 v2.5.8 h1:pm6WOnGdzFOCfcQo9L3+xzW51mKrlwTEg4Wr7AH1JW4=
+github.com/vektah/gqlparser/v2 v2.5.8/go.mod h1:z8xXUff237NntSuH8mLFijZ+1tjV1swDbpDqjJmk6ME=
+github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
+github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+go.bigb.es/auxilia v0.5.0 h1:S5+btW6++4CQDOfAEZe1UxrXRl6nxtmu0rI3uIXwCaQ=
+go.bigb.es/auxilia v0.5.0/go.mod h1:hBkJvydQRfmgSTR2U4PvYgJcZnh7Bj/otukrk14iJU4=
+go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
+go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
+golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
+golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
+golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
+golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
+golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
+golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152 h1:9kQC+tDO2CO8avlKadb9Z0if4a6vJuEK80+4zcb6/fU=
+sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152/go.mod h1:Mu1Vx39ws/OTKWGoVERXvkdRSPLBdhuFTYv0ftVV31c=