~bigbes/sr-ht-spec

ref: 7f779fef12194d49b9ce97ad4e2a80af1c3d6358 sr-ht-spec/doc/scan.go -rw-r--r-- 7.1 KiB
7f779fef — Eugene Blikh feat(web): review queue — inbox + policy-merged digest (Phase 4) 26 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
package doc

import (
	"bytes"
	"context"
	"path"
	"sort"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/gitx"
)

// DocumentSource is the read side of a space: everything this package needs
// from git. *gitx.Repo satisfies it.
//
// It is an interface rather than a *gitx.Repo so that the archive can be built
// over any document set — a test fixture, a cached tree — without pretending to
// be a repository. It is deliberately one method: an Archive is a whole
// revision, so there is nothing to read lazily.
type DocumentSource interface {
	WalkDocuments(ctx context.Context, rev string, fn func(gitx.Document) error) error
}

// Scan builds the Archive of a space at a revision. It is what warren's
// Scan(root string) became: the same result, walking a git tree instead of a
// directory.
//
// rev is anything gitx resolves — the approved branch, a proposal branch, or a
// pinned commit sha — which is what makes the read plane, the review UI and a
// `?rev=` request one code path. Pass a resolved sha when the archive must stay
// pinned to what the caller already saw.
//
// Attachments are not enumerated: gitx's walk yields documents only, so an
// archive built this way resolves `![[diagram.png]]` to a visibly missing link
// rather than to an attachment it cannot see. Use FromPages when a caller has
// an attachment index to supply.
func Scan(ctx context.Context, src DocumentSource, sp core.SpaceRef, rev string) (*Archive, error) {
	var docs []gitx.Document
	if err := src.WalkDocuments(ctx, rev, func(d gitx.Document) error {
		docs = append(docs, d)
		return nil
	}); err != nil {
		return nil, err
	}
	return FromDocuments(sp, rev, docs), nil
}

// FromDocuments builds an Archive out of already-read documents. It performs no
// I/O, so it is also the seam a caller with its own source of blobs uses.
func FromDocuments(sp core.SpaceRef, rev string, docs []gitx.Document) *Archive {
	sorted := make([]gitx.Document, len(docs))
	copy(sorted, docs)
	sort.Slice(sorted, func(i, j int) bool { return sorted[i].Path < sorted[j].Path })

	a := newArchive(sp, rev)
	fronts := make([]Front, len(sorted))

	// First pass: parse every header, so the id contest below is decided over
	// the whole revision rather than in scan order.
	docIDs := make(map[string]int, len(sorted))
	for i, d := range sorted {
		front, body := ParseFront(d.Data)
		fronts[i] = front

		p := &Page{
			Kind:     pageKind(front, d.Path),
			Title:    pageTitle(front, body, d.Path),
			Path:     d.Path,
			Blob:     d.Blob.String(),
			Status:   front.Status,
			Summary:  front.Summary,
			Tags:     front.Tags,
			Section:  topSection(d.Path),
			ParentID: LinkTarget(front.Parent), // resolved to an ID by linkHierarchy
		}
		if err := core.ValidateDocID(front.ID); err == nil {
			p.DocID = front.ID
			docIDs[front.ID]++
		}
		a.Pages = append(a.Pages, p)
	}

	// A document id claimed by two documents is not resolved to either of them.
	// The design says exactly this about the approved branch: a duplicate id is
	// tolerated so one typo cannot block a space, the paths stay occupied, and
	// the id is refused only where something actually needs to resolve it.
	for _, p := range a.Pages {
		if p.DocID != "" && docIDs[p.DocID] == 1 {
			p.ID = p.DocID
			continue
		}
		p.ID = strings.TrimSuffix(p.Path, core.DocExt)
	}
	for _, p := range a.Pages {
		a.register(p)
	}

	for i, p := range a.Pages {
		for _, alias := range fronts[i].Aliases {
			key := normalizeName(alias)
			if key == "" {
				continue
			}
			if _, taken := a.byID[key]; taken {
				continue // a real document owns this name; never shadow it
			}
			a.aliases[key] = p.ID
		}
	}

	a.linkHierarchy()
	return a
}

// pageTitle applies the title fallback chain: frontmatter `title:`, then the
// first `# H1`, then the file name. A document whose header failed to parse has
// no title of its own and must still get one — that is the whole point of the
// chain here, rather than reporting an untitled document.
func pageTitle(front Front, body []byte, p string) string {
	if t := strings.TrimSpace(front.Title); t != "" {
		return t
	}
	if h1 := firstH1(body); h1 != "" {
		return h1
	}
	return Stem(p)
}

// firstH1 returns the text of the first ATX level-1 heading in the body, or ""
// if there is none. Headings inside a fenced block are not headings.
func firstH1(body []byte) string {
	inFence := false
	for _, line := range bytes.Split(body, []byte("\n")) {
		t := bytes.TrimSpace(line)
		if bytes.HasPrefix(t, []byte("```")) || bytes.HasPrefix(t, []byte("~~~")) {
			inFence = !inFence
			continue
		}
		if inFence {
			continue
		}
		if rest, ok := bytes.CutPrefix(t, []byte("# ")); ok {
			return strings.TrimSpace(string(bytes.TrimRight(rest, " #")))
		}
	}
	return ""
}

// pageKind classifies the two structurally unusual kinds. The explicit
// frontmatter marker wins; the file-name rule is the fallback, kept from warren
// because the same corpus conventions produce the same index.md and log.md.
func pageKind(front Front, p string) PageKind {
	switch strings.ToLower(strings.TrimSpace(front.Type)) {
	case "catalog":
		return KindCatalog
	case "log":
		return KindLog
	}
	switch Stem(p) {
	case "index":
		return KindCatalog
	case "log":
		return KindLog
	}
	return KindMarkdown
}

// maxCrumbDepth bounds a parent chain. `parent:` is a wikilink and nothing
// stops it forming a cycle, so the walk is both cycle-guarded and depth-capped.
const maxCrumbDepth = 32

// linkHierarchy resolves each document's raw `parent:` target to an ID and
// walks the chain upward to build its crumbs. A cycle, a self-parent, or a
// parent that resolves to nothing leaves the document at the top level rather
// than failing the scan: a broken `parent:` is a defect in one document, not a
// reason to serve none.
func (a *Archive) linkHierarchy() {
	for _, p := range a.Pages {
		if p.ParentID == "" {
			continue
		}
		parent := a.lookupPage(path.Dir(p.Path), p.ParentID)
		if parent == nil || parent.ID == p.ID {
			p.ParentID = ""
			continue
		}
		p.ParentID = parent.ID
	}
	// Crumbs are computed for every document before any is detached: a cycle
	// must be seen as a cycle by each document in it, and detaching one mid-loop
	// would make the next document's walk terminate at the break and look sound.
	chains := make([][]string, len(a.Pages))
	for i, p := range a.Pages {
		chains[i] = a.crumbs(p)
	}
	for i, p := range a.Pages {
		p.Crumbs = chains[i]
		if len(chains[i]) == 0 {
			p.ParentID = "" // cyclic or dangling chain; detach rather than loop
		}
	}
}

// crumbs walks a document's ancestors root-most first, stopping on a repeat or
// at maxCrumbDepth. It returns nil when the chain is cyclic.
func (a *Archive) crumbs(p *Page) []string {
	if p.ParentID == "" {
		return nil
	}
	seen := map[string]bool{p.ID: true}
	var chain []string
	for id := p.ParentID; id != ""; {
		if seen[id] || len(chain) >= maxCrumbDepth {
			return nil
		}
		seen[id] = true
		chain = append(chain, id)
		parent, ok := a.byID[id]
		if !ok {
			break
		}
		id = parent.ParentID
	}
	// Reverse: crumbs run root -> immediate parent.
	for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 {
		chain[i], chain[j] = chain[j], chain[i]
	}
	return chain
}