coverage~bigbes/sr-ht-spec3cb1c03dmcpsrv/backend.go

Coverage
100.0% 39/39 statements
Δ
Blob
73288ba
Uncovered nothing — every instrumented line ran
1 package mcpsrv
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8
9 "sourcecraft.dev/bigbes/sr-ht-spec/core"
10 "sourcecraft.dev/bigbes/sr-ht-spec/doc"
11 "sourcecraft.dev/bigbes/sr-ht-spec/search"
12 "sourcecraft.dev/bigbes/sr-ht-spec/service"
13 )
14
15 // Reader is the part of the orchestration layer the read tools call.
16 // *service.Service satisfies it.
17 //
18 // It is deliberately three methods. Every one of them is the same function the
19 // REST handlers, the GraphQL resolvers and the web UI call, which is what the
20 // design means by "the MCP tools call the same resolver layer, not a parallel
21 // implementation" — a fourth method here that service/ does not have would be
22 // the beginning of a second implementation.
23 //
24 // ReadDocument is absent on purpose. Addressing a document by id needs the
25 // space's whole document set anyway (see resolvePage), and reading the body
26 // from that set rather than issuing a second, path-only read keeps one code
27 // path instead of two that must agree about what an id names.
28 type Reader interface {
29 ListSpaces(ctx context.Context) ([]*service.Space, error)
30 OpenSpace(ctx context.Context, ref core.SpaceRef) (*service.Space, error)
31 // Archive resolves rev and returns the space's addressable document set at
32 // it, with every document's bytes, from one tree walk. It replaced a
33 // ResolveRev/ListDocuments pair here, and the conversion back into git
34 // documents this package used to perform to reach doc.FromDocuments.
35 Archive(ctx context.Context, sp *service.Space, rev string) (*doc.Archive, map[string][]byte, error)
36 }
37
38 // Searcher is the query side of the one global index. *search.Index satisfies
39 // it.
40 type Searcher interface {
41 Search(ctx context.Context, q search.Query) (search.Results, error)
42 }
43
44 // Compile-time assertions that the production types satisfy the interfaces
45 // this package is written against. They are here rather than in a test so that
46 // a signature change in service/ or search/ breaks the build of the package
47 // that depends on them, not of a test somebody may not run.
48 var (
49 _ Reader = (*service.Service)(nil)
50 _ Searcher = (*search.Index)(nil)
51 _ Writer = (*service.Service)(nil)
52 )
53
54 const (
55 // minRevLen and maxRevLen bound a git object name. They are the design's
56 // X-Agent-Base grammar — 7-64 lowercase hex — reused here so that "a
57 // revision an agent may name" has one spelling across every surface.
58 minRevLen = 7
59 maxRevLen = 64
60 )
61
62 // parseSpace resolves the tool's space argument, which is written the way the
63 // design writes a space everywhere else: "~owner/name".
64 40 func parseSpace(s string) (core.SpaceRef, error) {
65 40 trimmed := strings.TrimSpace(s)
66 40 if trimmed == "" {
67 1 return core.SpaceRef{}, errors.New("space must not be empty; call spec_list with no arguments to list spaces")
68 1 }
69 39 ref, err := core.ParseSpaceRef(trimmed)
70 39 if err != nil {
71 1 return core.SpaceRef{}, fmt.Errorf("space %q: %w", trimmed, err)
72 1 }
73 38 return ref, nil
74 }
75
76 // parseRev turns the tool's optional rev argument into a revision string for
77 // service/. An empty argument becomes service.ApprovedRev, which is the read
78 // contract's default: the approved head.
79 //
80 // Anything else must be a git object name. Ref names are refused even though
81 // service/ would happily resolve them, and that refusal is the whole point:
82 // "proposals/42" is a legal revision one layer down, so accepting it here
83 // would make unreviewed proposal content reachable by a plausible-looking
84 // argument. Reading it requires naming a commit sha, which no read tool ever
85 // hands back, so it cannot be reached by accident.
86 26 func parseRev(s string) (string, error) {
87 26 rev := strings.TrimSpace(s)
88 26 if rev == "" {
89 17 return service.ApprovedRev, nil
90 17 }
91 9 if len(rev) < minRevLen || len(rev) > maxRevLen {
92 3 return "", fmt.Errorf("rev %q must be a git object name of %d-%d hex characters; "+
93 3 "omit rev to read the approved head", rev, minRevLen, maxRevLen)
94 3 }
95 162 for i := 0; i < len(rev); i++ {
96 162 c := rev[i]
97 162 if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
98 160 continue
99 }
100 2 return "", fmt.Errorf("rev %q must be a git object name (lowercase hex), not a branch or ref name; "+
101 2 "omit rev to read the approved head", rev)
102 }
103 4 return rev, nil
104 }
105
106 // archiveAt reads a space at a revision and returns its addressable document
107 // set, the bodies keyed by path, and the resolved commit.
108 //
109 // service/ does the work: it resolves the revision first and reads at the
110 // resolved sha, never at the caller's string. That is what makes an unpinned
111 // read pinnable — the rev reported back names the exact bytes returned, so a
112 // merge landing between the resolve and the read cannot make one answer
113 // describe two revisions.
114 //
115 // The whole space is read for one document. At the confirmed volume — tens of
116 // documents a day — that is cheap, and the alternative is worse: doc.Archive is
117 // where the design's addressing rule (id when valid and unique, else path)
118 // actually lives, and it is built from a revision's whole document set. A
119 // path-only fast path would be a second addressing implementation.
120 19 func archiveAt(ctx context.Context, b Backend, sp *service.Space, rev string) (*doc.Archive, map[string][]byte, string, error) {
121 19 arc, bodies, err := b.Docs.Archive(ctx, sp, rev)
122 19 if err != nil {
123 2 return nil, nil, "", err
124 2 }
125 17 return arc, bodies, arc.Rev, nil
126 }
127
128 // resolvePage applies the design's addressing rule to one caller-supplied
129 // token, using the archive's own lookups rather than reimplementing them:
130 //
131 // - a document with a well-formed id that no other document in its space
132 // claims is addressed by that id;
133 // - a document whose id is absent, malformed or duplicated is addressed by
134 // its path — with or without the ".md" extension, since the read plane's
135 // URL grammar carries no extension and an agent copying a path out of a
136 // search hit has one.
137 //
138 // A token naming an id that two documents claim resolves to neither, and says
139 // so. Silently picking one would point an agent at a document its author did
140 // not mean, and the ambiguity would be invisible.
141 15 func resolvePage(arc *doc.Archive, token string) (*doc.Page, error) {
142 15 name := strings.TrimSpace(token)
143 15 if name == "" {
144 1 return nil, errors.New("document must not be empty")
145 1 }
146 14 if p, ok := arc.Page(name); ok {
147 8 return p, nil
148 8 }
149 6 if p, ok := arc.ByPath(name); ok {
150 3 return p, nil
151 3 }
152 3 if p, ok := arc.ByPath(name + ".md"); ok {
153 1 return p, nil
154 1 }
155 2 if dup := duplicateIDPaths(arc, name); len(dup) > 1 {
156 1 return nil, fmt.Errorf("document id %q is claimed by %d documents (%s) and resolves to none of them; "+
157 1 "address one of them by path instead", name, len(dup), strings.Join(dup, ", "))
158 1 }
159 1 return nil, fmt.Errorf("no document %q in %s at %s", name, arc.Space, arc.Rev)
160 }
161
162 // duplicateIDPaths lists the paths of every document claiming id. doc/ excludes
163 // a duplicated id from the archive's lookup, which is the correct resolution
164 // but leaves nothing to report; this walk exists only to turn "not found" into
165 // a message that names the collision.
166 2 func duplicateIDPaths(arc *doc.Archive, id string) []string {
167 2 var paths []string
168 4 for _, p := range arc.All() {
169 4 if p.DocID == id {
170 2 paths = append(paths, p.Path)
171 2 }
172 }
173 2 return paths
174 }