diff --git a/core/errors.go b/core/errors.go
index ab8b6daedd56d564f3fbda08569da36c6e08cf0b..253750f5b99ebaf0f1791ca87a26120d3c8dc51e 100644
--- a/core/errors.go
+++ b/core/errors.go
@@ -1,7 +1,9 @@
// 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.
+// frontmatter schema contract, `.spec.yml` space policy, the proposal state
+// machine and branch naming, and [SpaceFilter] — what a project resolves to and
+// what a search is scoped by, which is here rather than in either of them
+// because it is the one thing they must agree about.
//
// 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
diff --git a/core/spacefilter.go b/core/spacefilter.go
new file mode 100644
index 0000000000000000000000000000000000000000..d75ba920acbeb080f8a1483d352fef87012024e4
--- /dev/null
+++ b/core/spacefilter.go
@@ -0,0 +1,160 @@
+package core
+
+import "strings"
+
+// spaceScope is a SpaceFilter's polarity. It is unexported, and so is every
+// field of the filter, because the whole point of the type is that "which
+// spaces" cannot be expressed as a bare slice whose empty case means whatever
+// the reader assumes.
+type spaceScope uint8
+
+const (
+ // scopeUnset is the zero value: nobody has said which spaces. It is not one
+ // of the two legal answers, and a caller that reaches a query with it is
+ // refused rather than defaulted — a filter nobody set is either an error
+ // return that was ignored or a field somebody forgot, and both of the
+ // plausible defaults ("everything", "nothing") are wrong for one of them.
+ scopeUnset spaceScope = iota
+ // scopeAll excludes nothing: the meta-project.
+ scopeAll
+ // scopeNamed selects exactly the spaces named, and nothing when none are.
+ scopeNamed
+)
+
+// SpaceFilter is which spaces an operation applies to: every space on the
+// instance, or a named set — which may be empty.
+//
+// It exists because those two are not distinguishable in a []SpaceRef, and the
+// two conventions for the empty slice both look reasonable in isolation:
+//
+// - "empty means every space" is what a query filter wants: no terms, no
+// restriction. That is what search.Query used to mean by an empty Spaces.
+// - "empty means no space" is what a project's membership means: a project is
+// a saved filter, and a filter with no terms selects nothing. A freshly
+// created project has no members.
+//
+// Hand a project's membership to a query built on the first convention and an
+// empty project silently becomes the whole corpus — the exact opposite of what
+// its author asked for, invisible when it happens, and passing every test
+// written with a non-empty project. The type makes the inversion inexpressible:
+// there is no slice to pass, only a filter that carries its own polarity.
+//
+// The zero value is neither answer; see scopeUnset. Build one with
+// [EverythingFilter] or [SpacesFilter].
+type SpaceFilter struct {
+ scope spaceScope
+ refs []SpaceRef
+ // ids are the storage row ids of refs, index-aligned, and optional: the
+ // index filters by space reference while document_id, proposal and
+ // index_stamp key off the row id, so whoever resolved the filter supplies
+ // whichever it holds.
+ ids []int
+}
+
+// EverythingFilter is the degenerate filter: it excludes nothing.
+//
+// This is the meta-project — "merge all my doc work into one searchable thing".
+// It is deliberately not an enumeration of every space: enumerating would freeze
+// the corpus as of the moment of the resolve, so a space created a second later
+// would be missing from "everything" until somebody re-resolved. That is the
+// sync job the design says the meta-project does not have, relocated into the
+// query path.
+func EverythingFilter() SpaceFilter { return SpaceFilter{scope: scopeAll} }
+
+// SpacesFilter selects exactly the spaces named, and nothing at all when none
+// are named — which is what an empty project is, and it must keep meaning that.
+//
+// ids, when supplied, are the storage row ids of refs and index-aligned with
+// them. A caller that holds only one of the two passes nil for the other.
+func SpacesFilter(refs []SpaceRef, ids []int) SpaceFilter {
+ f := SpaceFilter{scope: scopeNamed}
+ if len(refs) > 0 {
+ f.refs = append(f.refs, refs...)
+ }
+ if len(ids) > 0 {
+ f.ids = append(f.ids, ids...)
+ }
+ return f
+}
+
+// IsZero reports whether nobody has said which spaces this is: the zero value.
+// It is not "no spaces" — that is SpacesFilter with an empty membership — and a
+// caller holding one has a bug rather than a scope.
+func (f SpaceFilter) IsZero() bool { return f.scope == scopeUnset }
+
+// Everything reports whether the filter excludes nothing.
+func (f SpaceFilter) Everything() bool { return f.scope == scopeAll }
+
+// MatchesNothing reports whether the filter selects no space at all — an empty
+// project, or the zero value. Worth asking before running a query: it is the
+// one case where the answer is known without touching any index.
+func (f SpaceFilter) MatchesNothing() bool {
+ return !f.Everything() && len(f.refs) == 0 && len(f.ids) == 0
+}
+
+// Matches reports whether a space is within the filter.
+func (f SpaceFilter) Matches(ref SpaceRef) bool {
+ if f.Everything() {
+ return true
+ }
+ for _, r := range f.refs {
+ if r == ref {
+ return true
+ }
+ }
+ return false
+}
+
+// MatchesID reports whether a space row id is within the filter.
+func (f SpaceFilter) MatchesID(id int) bool {
+ if f.Everything() {
+ return true
+ }
+ for _, got := range f.ids {
+ if got == id {
+ return true
+ }
+ }
+ return false
+}
+
+// Refs returns the spaces named by the filter, or nil for the meta-project —
+// which is unenumerated on purpose, so a caller must ask Everything first
+// rather than reading nil as "none".
+func (f SpaceFilter) Refs() []SpaceRef {
+ if len(f.refs) == 0 {
+ return nil
+ }
+ out := make([]SpaceRef, len(f.refs))
+ copy(out, f.refs)
+ return out
+}
+
+// IDs returns the storage row ids of the spaces named by the filter, when the
+// resolver supplied them. Same caveat as Refs.
+func (f SpaceFilter) IDs() []int {
+ if len(f.ids) == 0 {
+ return nil
+ }
+ out := make([]int, len(f.ids))
+ copy(out, f.ids)
+ return out
+}
+
+// String renders the filter for logs and error messages, spelling out the
+// polarity the type exists to keep visible.
+func (f SpaceFilter) String() string {
+ switch {
+ case f.Everything():
+ return "every space"
+ case f.IsZero():
+ return "no space scope"
+ case len(f.refs) == 0:
+ return "no space"
+ }
+ names := make([]string, 0, len(f.refs))
+ for _, r := range f.refs {
+ names = append(names, r.String())
+ }
+ return strings.Join(names, ", ")
+}
diff --git a/core/spacefilter_test.go b/core/spacefilter_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..e5b61b1f74e7684ab9c7b0578009a8bbeb9a7bed
--- /dev/null
+++ b/core/spacefilter_test.go
@@ -0,0 +1,94 @@
+package core
+
+import "testing"
+
+var (
+ fxRfcs = SpaceRef{Owner: "bigbes", Name: "rfcs"}
+ fxNotes = SpaceRef{Owner: "bigbes", Name: "notes"}
+)
+
+// The three states, and the property the type exists for: none of them can be
+// mistaken for another, because none of them is a slice.
+func TestSpaceFilterStates(t *testing.T) {
+ every := EverythingFilter()
+ if !every.Everything() || every.MatchesNothing() || every.IsZero() {
+ t.Fatalf("EverythingFilter = %s", every)
+ }
+ if !every.Matches(fxRfcs) || !every.MatchesID(99) {
+ t.Error("the meta filter matches every space")
+ }
+ // Unenumerated on purpose: a frozen list would omit every space created
+ // after the resolve.
+ if len(every.Refs()) != 0 || len(every.IDs()) != 0 {
+ t.Errorf("the meta filter enumerated %v", every.Refs())
+ }
+
+ // A project nobody has added a space to yet. This is the case that used to
+ // widen into the whole corpus when it was carried as an empty slice.
+ none := SpacesFilter(nil, nil)
+ if none.Everything() || none.IsZero() || !none.MatchesNothing() {
+ t.Fatalf("SpacesFilter(nil, nil) = %s", none)
+ }
+ if none.Matches(fxRfcs) || none.MatchesID(1) {
+ t.Error("a filter over no spaces matches nothing")
+ }
+
+ named := SpacesFilter([]SpaceRef{fxRfcs}, []int{7})
+ if named.Everything() || named.IsZero() || named.MatchesNothing() {
+ t.Fatalf("SpacesFilter(one space) = %s", named)
+ }
+ if !named.Matches(fxRfcs) || !named.MatchesID(7) {
+ t.Error("a member space must match")
+ }
+ if named.Matches(fxNotes) || named.MatchesID(8) {
+ t.Error("a space outside the filter must not match")
+ }
+
+ // The zero value is neither answer, so a caller that never set a scope is
+ // distinguishable from both — which is what lets a query refuse it instead
+ // of guessing.
+ var unset SpaceFilter
+ if !unset.IsZero() || unset.Everything() || unset.Matches(fxRfcs) {
+ t.Fatalf("the zero filter = %s", unset)
+ }
+}
+
+// The accessors hand back copies: a caller that appends to the refs it was
+// given must not be able to widen somebody else's filter.
+func TestSpaceFilterAccessorsCopy(t *testing.T) {
+ refs := []SpaceRef{fxRfcs}
+ f := SpacesFilter(refs, []int{7})
+
+ refs[0] = fxNotes
+ if !f.Matches(fxRfcs) || f.Matches(fxNotes) {
+ t.Error("the filter aliased the slice it was built from")
+ }
+
+ got := f.Refs()
+ got[0] = fxNotes
+ if !f.Matches(fxRfcs) || f.Matches(fxNotes) {
+ t.Error("Refs handed out the filter's own slice")
+ }
+ ids := f.IDs()
+ ids[0] = 8
+ if !f.MatchesID(7) || f.MatchesID(8) {
+ t.Error("IDs handed out the filter's own slice")
+ }
+}
+
+func TestSpaceFilterString(t *testing.T) {
+ var unset SpaceFilter
+ for _, tc := range []struct {
+ f SpaceFilter
+ want string
+ }{
+ {EverythingFilter(), "every space"},
+ {SpacesFilter(nil, nil), "no space"},
+ {unset, "no space scope"},
+ {SpacesFilter([]SpaceRef{fxRfcs, fxNotes}, nil), "~bigbes/rfcs, ~bigbes/notes"},
+ } {
+ if got := tc.f.String(); got != tc.want {
+ t.Errorf("String() = %q, want %q", got, tc.want)
+ }
+ }
+}
diff --git a/mcpsrv/mcpsrv_test.go b/mcpsrv/mcpsrv_test.go
index 422fccc3fd9e7b44bda5e2c36919bd4ec0f9a9a1..2c9cab7b8de6579268dda7cc453c33da7930b426 100644
--- a/mcpsrv/mcpsrv_test.go
+++ b/mcpsrv/mcpsrv_test.go
@@ -409,11 +409,15 @@ })
require.Equal(t, []core.SpaceRef{
{Owner: "bigbes", Name: "rfcs"},
{Owner: "bigbes", Name: "notes"},
- }, s.last.Spaces)
+ }, s.last.Spaces.Refs())
+ require.False(t, s.last.Spaces.Everything(), "naming spaces restricts the search")
- // Omitting it is the meta-project: a filter that excludes nothing.
+ // Omitting it is the meta-project: a filter that excludes nothing. It
+ // reaches the index saying so, rather than as an empty list that the index
+ // would have to interpret.
call(t, session, "spec_search", map[string]any{"query": "storage"})
- require.Nil(t, s.last.Spaces)
+ require.True(t, s.last.Spaces.Everything())
+ require.False(t, s.last.Spaces.MatchesNothing())
// Sections pass through the same way.
call(t, session, "spec_search", map[string]any{"query": "storage", "sections": []string{"specs"}})
diff --git a/mcpsrv/search.go b/mcpsrv/search.go
index 428a261d4140ec0efffacac1010ffd89ebed16b2..4e6e73bf82ada9cf3e71c7d867b00cb9dd08b87e 100644
--- a/mcpsrv/search.go
+++ b/mcpsrv/search.go
@@ -100,19 +100,24 @@ // parseSpaceFilter validates the project filter. An unparseable space is an
// error rather than a dropped filter term: dropping one would silently widen
// the search past the set the caller asked for, and a wider answer than
// requested is indistinguishable from a correct one.
-func parseSpaceFilter(in []string) ([]core.SpaceRef, error) {
+//
+// An omitted argument is every space — which the tool schema promises — and it
+// is returned as the filter that says so. The distinction matters one layer
+// down: "the agent named no spaces" is not the same as "the project the agent
+// named holds no spaces", and only a filter can tell them apart.
+func parseSpaceFilter(in []string) (core.SpaceFilter, error) {
if len(in) == 0 {
- return nil, nil
+ return core.EverythingFilter(), nil
}
- out := make([]core.SpaceRef, 0, len(in))
+ refs := make([]core.SpaceRef, 0, len(in))
for _, s := range in {
ref, err := parseSpace(s)
if err != nil {
- return nil, err
+ return core.SpaceFilter{}, err
}
- out = append(out, ref)
+ refs = append(refs, ref)
}
- return out, nil
+ return core.SpacesFilter(refs, nil), nil
}
// trimAll trims each element and refuses an empty one. search/ rejects an empty
diff --git a/search/index_test.go b/search/index_test.go
index 6001acd92df11b49435ea34be41854a13fa191f9..4e073a3b050e4e1522b80dfd055c54dc254a848d 100644
--- a/search/index_test.go
+++ b/search/index_test.go
@@ -23,7 +23,7 @@ idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "rev1").
add("specs/storage.md", "---\ntitle: Needle handbook\n---\n\nreference material\n"))
for _, q := range []string{"", " \t\n "} {
- res, err := idx.Search(context.Background(), Query{Text: q})
+ res, err := idx.Search(context.Background(), Query{Text: q, Spaces: core.EverythingFilter()})
require.NoError(t, err)
require.Empty(t, res.Hits)
require.Zero(t, res.Total)
@@ -37,7 +37,7 @@ idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "rev1").
add("specs/handbook.md", "---\nid: SPEC-0001\ntitle: Needle handbook\n---\n\nreference material\n").
add("specs/other.md", "---\nid: SPEC-0002\ntitle: Other document\n---\n\na needle appears in the body\n"))
- require.Equal(t, []string{"SPEC-0001", "SPEC-0002"}, hitIDs(t, idx, Query{Text: "needle"}))
+ require.Equal(t, []string{"SPEC-0001", "SPEC-0002"}, hitIDs(t, idx, Query{Text: "needle", Spaces: core.EverythingFilter()}))
}
// A project is a saved filter over the one global index. This is the whole of
@@ -52,16 +52,16 @@ add("specs/x.md", "---\nid: SPEC-0009\ntitle: Elsewhere\n---\n\nA third shared vocabulary term.\n")
idx := indexCorpus(t, rfcs, ops, other)
// The meta-project: a filter that excludes nothing.
- all := hitIDs(t, idx, Query{Text: "vocabulary"})
+ all := hitIDs(t, idx, Query{Text: "vocabulary", Spaces: core.EverythingFilter()})
require.ElementsMatch(t, []string{"SPEC-0001", "NOTE-0001", "SPEC-0009"}, all)
// A project over two of the three spaces.
- project := hitIDs(t, idx, Query{Text: "vocabulary", Spaces: []core.SpaceRef{rfcs.Space, ops.Space}})
+ project := hitIDs(t, idx, Query{Text: "vocabulary", Spaces: core.SpacesFilter([]core.SpaceRef{rfcs.Space, ops.Space}, nil)})
require.ElementsMatch(t, []string{"SPEC-0001", "NOTE-0001"}, project)
// One space.
require.Equal(t, []string{"SPEC-0009"},
- hitIDs(t, idx, Query{Text: "vocabulary", Spaces: []core.SpaceRef{other.Space}}))
+ hitIDs(t, idx, Query{Text: "vocabulary", Spaces: core.SpacesFilter([]core.SpaceRef{other.Space}, nil)}))
}
// Space names are matched whole. Filtering through an analyzed field — which is
@@ -75,14 +75,52 @@ add("notes/b.md", "---\nid: NOTE-0002\ntitle: B\n---\n\nshared vocabulary\n")
idx := indexCorpus(t, ops, home)
require.Equal(t, []string{"NOTE-0002"},
- hitIDs(t, idx, Query{Text: "vocabulary", Spaces: []core.SpaceRef{home.Space}}))
+ hitIDs(t, idx, Query{Text: "vocabulary", Spaces: core.SpacesFilter([]core.SpaceRef{home.Space}, nil)}))
+}
+
+// The whole reason Query.Spaces is a filter and not a []core.SpaceRef: an
+// empty project selects nothing, and "nothing" must never widen to "the entire
+// corpus" on the way into a query. With a slice it did — no terms read as no
+// restriction — and every test written with a non-empty project passed anyway.
+func TestEmptyProjectFindsNothingRatherThanEverything(t *testing.T) {
+ rfcs := newCorpus(t, "~bigbes/rfcs", "rev1").
+ add("specs/storage.md", "---\nid: SPEC-0001\ntitle: Storage model\n---\n\nA shared vocabulary term.\n")
+ ops := newCorpus(t, "~bigbes/home-ops", "rev1").
+ add("notes/hosts.md", "---\nid: NOTE-0001\ntitle: Hosts\n---\n\nAnother shared vocabulary term.\n")
+ idx := indexCorpus(t, rfcs, ops)
+
+ // What service.ResolveProject returns for a project nobody has added a
+ // space to yet.
+ empty := core.SpacesFilter(nil, nil)
+ require.True(t, empty.MatchesNothing())
+ require.False(t, empty.Everything())
+
+ res, err := idx.Search(context.Background(), Query{Text: "vocabulary", Spaces: empty})
+ require.NoError(t, err)
+ require.Empty(t, res.Hits, "an empty project must return no hits, not the corpus")
+ require.Zero(t, res.Total)
+
+ // The same query over the meta-project, to show the corpus was there to be
+ // returned and the filter is what withheld it.
+ require.Len(t, hitIDs(t, idx, Query{Text: "vocabulary", Spaces: core.EverythingFilter()}), 2)
+}
+
+// A scope nobody set is neither answer, and is refused rather than defaulted:
+// both plausible defaults are wrong for one of the two callers that could
+// produce it.
+func TestSearchRefusesAQueryWithNoScope(t *testing.T) {
+ idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "rev1").
+ add("a.md", "---\ntitle: A\n---\n\nbody text here\n"))
+
+ _, err := idx.Search(context.Background(), Query{Text: "body"})
+ require.ErrorContains(t, err, "no space scope")
}
func TestSearchRejectsAnEmptySpaceFilter(t *testing.T) {
idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "rev1").
add("a.md", "---\ntitle: A\n---\n\nbody text here\n"))
- _, err := idx.Search(context.Background(), Query{Text: "body", Spaces: []core.SpaceRef{{}}})
+ _, err := idx.Search(context.Background(), Query{Text: "body", Spaces: core.SpacesFilter([]core.SpaceRef{{}}, nil)})
require.ErrorContains(t, err, "empty space")
}
@@ -93,13 +131,13 @@ idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "rev1").
add("specs/storage.md", "---\nid: SPEC-0001\ntitle: Storage model\n---\n\nThe write path resolves a tree.\n").
add("log.md", "# Log\n\n## [2026-05-31] update | Storage model\nRewrote the write path section.\n"))
- require.Equal(t, []string{"SPEC-0001"}, hitIDs(t, idx, Query{Text: "write path"}))
+ require.Equal(t, []string{"SPEC-0001"}, hitIDs(t, idx, Query{Text: "write path", Spaces: core.EverythingFilter()}))
// Naming the section is the way back in. The log document itself carries no
// body, so only its entry matches the text.
require.Equal(t, []string{"log#2026-05-31-1"},
- hitIDs(t, idx, Query{Text: "write path", Sections: []string{doc.LogSection}}))
+ hitIDs(t, idx, Query{Text: "write path", Sections: []string{doc.LogSection}, Spaces: core.EverythingFilter()}))
require.Equal(t, []string{"log"},
- hitIDs(t, idx, Query{Text: "Log", Sections: []string{doc.LogSection}}),
+ hitIDs(t, idx, Query{Text: "Log", Sections: []string{doc.LogSection}, Spaces: core.EverythingFilter()}),
"the log document stays findable by name")
}
@@ -108,16 +146,16 @@ idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "rev1").
add("specs/storage.md", "---\nid: SPEC-0001\ntitle: Storage\n---\n\nshared vocabulary term\n").
add("notes/scratch.md", "---\nid: NOTE-0001\ntitle: Scratch\n---\n\nshared vocabulary term\n"))
- require.Equal(t, []string{"SPEC-0001"}, hitIDs(t, idx, Query{Text: "vocabulary", Sections: []string{"specs"}}))
- require.Equal(t, []string{"NOTE-0001"}, hitIDs(t, idx, Query{Text: "vocabulary", Sections: []string{"notes"}}))
- require.Len(t, hitIDs(t, idx, Query{Text: "vocabulary", Sections: []string{"specs", "notes"}}), 2)
+ require.Equal(t, []string{"SPEC-0001"}, hitIDs(t, idx, Query{Text: "vocabulary", Sections: []string{"specs"}, Spaces: core.EverythingFilter()}))
+ require.Equal(t, []string{"NOTE-0001"}, hitIDs(t, idx, Query{Text: "vocabulary", Sections: []string{"notes"}, Spaces: core.EverythingFilter()}))
+ require.Len(t, hitIDs(t, idx, Query{Text: "vocabulary", Sections: []string{"specs", "notes"}, Spaces: core.EverythingFilter()}), 2)
}
func TestSearchRejectsAnEmptySection(t *testing.T) {
idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "rev1").
add("a.md", "---\ntitle: A\n---\n\nbody text here\n"))
- _, err := idx.Search(context.Background(), Query{Text: "body", Sections: []string{""}})
+ _, err := idx.Search(context.Background(), Query{Text: "body", Sections: []string{""}, Spaces: core.EverythingFilter()})
require.ErrorContains(t, err, "empty section")
}
@@ -125,7 +163,7 @@ func TestHitCarriesAPinnedAddress(t *testing.T) {
idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "8f14e45fceea167a").
add("specs/storage.md", "---\nid: SPEC-0001\ntitle: Storage model\n---\n\nThe write path resolves a tree.\n"))
- res, err := idx.Search(context.Background(), Query{Text: "resolves"})
+ res, err := idx.Search(context.Background(), Query{Text: "resolves", Spaces: core.EverythingFilter()})
require.NoError(t, err)
require.Len(t, res.Hits, 1)
h := res.Hits[0]
@@ -147,7 +185,7 @@ idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "rev1").
add("specs/x.md", "---\nid: SPEC-0001\ntitle: Escaping\n---\n\n"+
"A needle inside `` and more prose after it.\n"))
- res, err := idx.Search(context.Background(), Query{Text: "needle"})
+ res, err := idx.Search(context.Background(), Query{Text: "needle", Spaces: core.EverythingFilter()})
require.NoError(t, err)
require.Len(t, res.Hits, 1)
require.Contains(t, res.Hits[0].Snippet, "<script>")
@@ -164,7 +202,7 @@ ops := newCorpus(t, "~bigbes/home-ops", "rev1").
add("notes/hosts.md", "---\nid: NOTE-0001\ntitle: Hosts\n---\n\nshared vocabulary term\n")
idx := indexCorpus(t, rfcs, ops)
require.ElementsMatch(t, []string{"SPEC-0001", "SPEC-0002", "NOTE-0001"},
- hitIDs(t, idx, Query{Text: "vocabulary"}))
+ hitIDs(t, idx, Query{Text: "vocabulary", Spaces: core.EverythingFilter()}))
next := newCorpus(t, "~bigbes/rfcs", "rev2").
add("specs/keep.md", "---\nid: SPEC-0001\ntitle: Kept\n---\n\nshared vocabulary term, reworded\n").
@@ -176,9 +214,9 @@ require.Equal(t, 1, st.Deleted, "SPEC-0002 is gone at rev2")
require.Positive(t, st.Took)
require.ElementsMatch(t, []string{"SPEC-0001", "SPEC-0003", "NOTE-0001"},
- hitIDs(t, idx, Query{Text: "vocabulary"}))
+ hitIDs(t, idx, Query{Text: "vocabulary", Spaces: core.EverythingFilter()}))
- res, err := idx.Search(context.Background(), Query{Text: "reworded"})
+ res, err := idx.Search(context.Background(), Query{Text: "reworded", Spaces: core.EverythingFilter()})
require.NoError(t, err)
require.Len(t, res.Hits, 1)
require.Equal(t, "rev2", res.Hits[0].Rev, "a surviving document is re-indexed at the new revision")
@@ -208,7 +246,7 @@
st, err := idx.DeleteSpace(context.Background(), rfcs.Space)
require.NoError(t, err)
require.Equal(t, 1, st.Deleted)
- require.Equal(t, []string{"NOTE-0001"}, hitIDs(t, idx, Query{Text: "vocabulary"}))
+ require.Equal(t, []string{"NOTE-0001"}, hitIDs(t, idx, Query{Text: "vocabulary", Spaces: core.EverythingFilter()}))
n, err := idx.Count()
require.NoError(t, err)
@@ -232,7 +270,7 @@ require.Equal(t, 2, st.Spaces)
require.Equal(t, 2, st.Indexed)
require.Positive(t, st.Took)
- require.ElementsMatch(t, []string{"SPEC-0002", "NOTE-0001"}, hitIDs(t, idx, Query{Text: "vocabulary"}))
+ require.ElementsMatch(t, []string{"SPEC-0002", "NOTE-0001"}, hitIDs(t, idx, Query{Text: "vocabulary", Spaces: core.EverythingFilter()}))
// Neither working directory survives a successful rebuild.
for _, p := range tmpPaths(idx.Path()) {
@@ -269,7 +307,7 @@
reopened, err := Open(path)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, reopened.Close()) })
- require.Equal(t, []string{"SPEC-0001"}, hitIDs(t, reopened, Query{Text: "vocabulary"}))
+ require.Equal(t, []string{"SPEC-0001"}, hitIDs(t, reopened, Query{Text: "vocabulary", Spaces: core.EverythingFilter()}))
}
func TestOpenNeedsAPath(t *testing.T) {
@@ -283,7 +321,7 @@ require.NoError(t, err)
require.NoError(t, idx.Close())
require.NoError(t, idx.Close(), "closing twice is not an error")
- _, err = idx.Search(context.Background(), Query{Text: "anything"})
+ _, err = idx.Search(context.Background(), Query{Text: "anything", Spaces: core.EverythingFilter()})
require.ErrorContains(t, err, "index is closed")
_, err = idx.Count()
require.ErrorContains(t, err, "index is closed")
@@ -299,17 +337,17 @@ fmt.Sprintf("---\nid: SPEC-000%d\ntitle: Document %d\n---\n\nshared vocabulary term\n", i, i))
}
idx := indexCorpus(t, c)
- res, err := idx.Search(context.Background(), Query{Text: "vocabulary", Limit: 2})
+ res, err := idx.Search(context.Background(), Query{Text: "vocabulary", Limit: 2, Spaces: core.EverythingFilter()})
require.NoError(t, err)
require.Len(t, res.Hits, 2)
require.Equal(t, uint64(5), res.Total, "Total counts matches, not returned hits")
- page2, err := idx.Search(context.Background(), Query{Text: "vocabulary", Limit: 2, Offset: 2})
+ page2, err := idx.Search(context.Background(), Query{Text: "vocabulary", Limit: 2, Offset: 2, Spaces: core.EverythingFilter()})
require.NoError(t, err)
require.Len(t, page2.Hits, 2)
require.NotEqual(t, res.Hits[0].ID, page2.Hits[0].ID)
- _, err = idx.Search(context.Background(), Query{Text: "vocabulary", Offset: -1})
+ _, err = idx.Search(context.Background(), Query{Text: "vocabulary", Offset: -1, Spaces: core.EverythingFilter()})
require.ErrorContains(t, err, "negative offset")
}
@@ -346,7 +384,7 @@ b := newCorpus(t, "~bigbes/home-ops", "rev1").
add("specs/storage.md", "# Storage\n\nshared vocabulary term\n")
idx := indexCorpus(t, a, b)
- res, err := idx.Search(context.Background(), Query{Text: "vocabulary"})
+ res, err := idx.Search(context.Background(), Query{Text: "vocabulary", Spaces: core.EverythingFilter()})
require.NoError(t, err)
require.Len(t, res.Hits, 2)
require.ElementsMatch(t,
@@ -387,7 +425,7 @@ require.NoError(t, err)
t.Logf("RebuildSpace over %d documents: %s", spaceStats.Indexed, spaceStats)
for _, q := range []string{"ревизия", "proposal", "предложение"} {
- res, err := idx.Search(context.Background(), Query{Text: q, Limit: 10})
+ res, err := idx.Search(context.Background(), Query{Text: q, Limit: 10, Spaces: core.EverythingFilter()})
require.NoError(t, err)
t.Logf("query %q over %d documents: %d matches in %s", q, st.Indexed, res.Total, res.Took)
require.Positive(t, res.Total)
@@ -422,7 +460,7 @@ cancel()
_, err := idx.RebuildAll(ctx, next.extract(t))
require.ErrorIs(t, err, context.Canceled)
- require.Equal(t, []string{"SPEC-0001"}, hitIDs(t, idx, Query{Text: "vocabulary"}),
+ require.Equal(t, []string{"SPEC-0001"}, hitIDs(t, idx, Query{Text: "vocabulary", Spaces: core.EverythingFilter()}),
"the old index is still open and still answering")
for _, p := range tmpPaths(idx.Path()) {
_, err := os.Stat(p)
@@ -447,7 +485,7 @@ wg.Add(1)
go func() {
defer wg.Done()
for range 20 {
- _, err := idx.Search(context.Background(), Query{Text: "vocabulary"})
+ _, err := idx.Search(context.Background(), Query{Text: "vocabulary", Spaces: core.EverythingFilter()})
assert.NoError(t, err)
}
}()
@@ -462,7 +500,7 @@ }()
}
wg.Wait()
- require.Len(t, hitIDs(t, idx, Query{Text: "vocabulary", Limit: 100}), 50)
+ require.Len(t, hitIDs(t, idx, Query{Text: "vocabulary", Limit: 100, Spaces: core.EverythingFilter()}), 50)
}
// A document at the space root has no section. Excluding the log section must
@@ -472,5 +510,5 @@ idx := indexCorpus(t, newCorpus(t, "~bigbes/rfcs", "rev1").
add("README.md", "---\nid: SPEC-0001\ntitle: Read me\n---\n\nshared vocabulary term\n").
add("specs/a.md", "---\nid: SPEC-0002\ntitle: A\n---\n\nshared vocabulary term\n"))
- require.ElementsMatch(t, []string{"SPEC-0001", "SPEC-0002"}, hitIDs(t, idx, Query{Text: "vocabulary"}))
+ require.ElementsMatch(t, []string{"SPEC-0001", "SPEC-0002"}, hitIDs(t, idx, Query{Text: "vocabulary", Spaces: core.EverythingFilter()}))
}
diff --git a/search/mixed_test.go b/search/mixed_test.go
index c83aaab3e2fec645cc73ae1e23523f396aa53e80..8a6fb4057cbc8af7d7b3dbee5c361b45a95a62fd 100644
--- a/search/mixed_test.go
+++ b/search/mixed_test.go
@@ -10,6 +10,8 @@ "github.com/blevesearch/bleve/v2/analysis/lang/en"
"github.com/blevesearch/bleve/v2/analysis/lang/ru"
"github.com/blevesearch/bleve/v2/registry"
"github.com/stretchr/testify/require"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
)
// The design leaves "mixed Russian/English search" open, with warren's
@@ -117,15 +119,15 @@ add("specs/attachments.md", "---\nid: SPEC-0003\ntitle: Вложения и двоичные файлы\nstatus: draft\n---\n\n"+mixedSpecBody)
idx := indexCorpus(t, c)
// Singular English query, plural in the text: only the stemmer bridges it.
- require.Equal(t, []string{"SPEC-0003"}, hitIDs(t, idx, Query{Text: "attachment"}),
+ require.Equal(t, []string{"SPEC-0003"}, hitIDs(t, idx, Query{Text: "attachment", Spaces: core.EverythingFilter()}),
"the English half of the mixed document is stemmed")
// Singular Russian query, plural in the text.
- require.Equal(t, []string{"SPEC-0003"}, hitIDs(t, idx, Query{Text: "вложение"}),
+ require.Equal(t, []string{"SPEC-0003"}, hitIDs(t, idx, Query{Text: "вложение", Spaces: core.EverythingFilter()}),
"the Russian half of the mixed document is stemmed")
// The single-language documents are unaffected.
- require.Equal(t, []string{"SPEC-0001"}, hitIDs(t, idx, Query{Text: "proposal"}))
- require.Equal(t, []string{"SPEC-0002"}, hitIDs(t, idx, Query{Text: "предложение"}))
+ require.Equal(t, []string{"SPEC-0001"}, hitIDs(t, idx, Query{Text: "proposal", Spaces: core.EverythingFilter()}))
+ require.Equal(t, []string{"SPEC-0002"}, hitIDs(t, idx, Query{Text: "предложение", Spaces: core.EverythingFilter()}))
}
// TestMixedDocumentIsLabelledByItsDominantLanguage checks the label a hit
@@ -137,14 +139,14 @@ add("specs/storage.md", "---\nid: SPEC-0001\ntitle: Storage model\n---\n\n"+enSpecBody).
add("specs/attachments.md", "---\nid: SPEC-0003\ntitle: Вложения и двоичные файлы\n---\n\n"+mixedSpecBody)
idx := indexCorpus(t, c)
- res, err := idx.Search(context.Background(), Query{Text: "attachment"})
+ res, err := idx.Search(context.Background(), Query{Text: "attachment", Spaces: core.EverythingFilter()})
require.NoError(t, err)
require.Len(t, res.Hits, 1)
require.Equal(t, LangRU, res.Hits[0].Lang)
require.Contains(t, res.Hits[0].Snippet, "attachments",
"the snippet comes from the half that matched, even though it is not the document's language")
- res, err = idx.Search(context.Background(), Query{Text: "proposal"})
+ res, err = idx.Search(context.Background(), Query{Text: "proposal", Spaces: core.EverythingFilter()})
require.NoError(t, err)
require.Len(t, res.Hits, 1)
require.Equal(t, LangEN, res.Hits[0].Lang)
@@ -160,6 +162,6 @@ add("specs/mention.md", "---\nid: SPEC-0004\ntitle: Прочее\n---\n\n"+
"Здесь упоминается модель хранения данных, но документ совсем о другом предмете.\n")
idx := indexCorpus(t, c)
- require.Equal(t, []string{"SPEC-0002", "SPEC-0004"}, hitIDs(t, idx, Query{Text: "модель хранения"}),
+ require.Equal(t, []string{"SPEC-0002", "SPEC-0004"}, hitIDs(t, idx, Query{Text: "модель хранения", Spaces: core.EverythingFilter()}),
"the document named by the query outranks the one that mentions it")
}
diff --git a/search/search.go b/search/search.go
index 04b7c84a22af4acb7666ac4bf6659960942ef413..1f2009548cc1c33672c5c7d2f035be15ade29db1 100644
--- a/search/search.go
+++ b/search/search.go
@@ -67,9 +67,18 @@ // were indexed with, per field.
Text string
// Spaces restricts results to a set of spaces. This is what a project is:
// the design's "a project is a saved filter over one global index, not a
- // container" is this field and nothing else. Empty means every space, which
- // is the meta-project.
- Spaces []core.SpaceRef
+ // container" is this field and nothing else — a project resolves to a
+ // core.SpaceFilter and it is handed over whole.
+ //
+ // It is a filter rather than a []core.SpaceRef because the empty slice had
+ // two defensible meanings and the two are opposites: here it read as "no
+ // restriction", while a project's empty membership means "no space". An
+ // empty project passed into a query therefore used to return the whole
+ // corpus. The filter carries its own polarity, and the zero value is
+ // neither answer — Search refuses it rather than guessing, since a scope
+ // nobody set is a caller bug and both defaults are wrong for one of them.
+ // core.EverythingFilter() is how a caller says "every space".
+ Spaces core.SpaceFilter
// Sections restricts results to top-level sections ("specs", "notes",
// "reports"). Empty means every section except the activity log — see
// doc.LogSection: log entries summarise other documents, so left in they
@@ -121,6 +130,16 @@ }
if q.Offset < 0 {
return Results{}, fmt.Errorf("search: negative offset %d", q.Offset)
}
+ if q.Spaces.IsZero() {
+ return Results{}, errors.New("search: query names no space scope; " +
+ "pass core.EverythingFilter() to search every space, or a project's filter to restrict it")
+ }
+ // A filter that selects no space — an empty project — has a known answer,
+ // and it is not "everything". Asking the index would be asking a question
+ // with no terms in it.
+ if q.Spaces.MatchesNothing() {
+ return Results{}, nil
+ }
bq, err := buildQuery(text, q)
if err != nil {
return Results{}, err
@@ -174,9 +193,11 @@ match(fieldBodyEN, 1),
match(fieldBodyRU, 1),
))
- if len(q.Spaces) > 0 {
- want := make([]query.Query, 0, len(q.Spaces))
- for _, sp := range q.Spaces {
+ // The meta-project adds no term at all: a filter that excludes nothing is
+ // the absence of a restriction, not the enumeration of every space.
+ if refs := q.Spaces.Refs(); !q.Spaces.Everything() {
+ want := make([]query.Query, 0, len(refs))
+ for _, sp := range refs {
if sp.Owner == "" || sp.Name == "" {
return nil, errors.New("search: query carries an empty space")
}
diff --git a/service/project.go b/service/project.go
index 7a57c54d46f8abd0d8227119b2b8a23bac90e2ec..5b6ad15cb613bb81c67d5b385331502872a2972a 100644
--- a/service/project.go
+++ b/service/project.go
@@ -25,37 +25,16 @@ Created time.Time
}
// SpaceFilter is what a project resolves to: the set of spaces a query is
-// restricted to. It is the entirety of what a project *does*.
-//
-// All is not "IDs and Refs happen to be empty", and the distinction is
-// load-bearing in both directions:
-//
-// - All true is the meta-project — a filter that excludes nothing. IDs and
-// Refs are deliberately left empty rather than enumerated: enumerating
-// would freeze the corpus as of the moment of the resolve, so a space
-// created a second later would be missing from "everything" until somebody
-// re-resolved. That is the sync job the design says the meta-project does
-// not have, relocated into the query path.
+// restricted to. It is the entirety of what a project *does*, and it is
+// core.SpaceFilter under this package's name.
//
-// - All false with an empty membership is a project that selects *nothing*,
-// and it must keep meaning that. Collapsing it into "everything" would
-// turn a freshly created, not-yet-populated project into the whole corpus —
-// the exact opposite of what its author asked for, and invisible when it
-// happens.
-//
-// Callers translating this into a downstream filter must therefore branch on
-// All, not on len(Refs). search.Query.Spaces in particular follows the opposite
-// convention (empty means every space), so handing it Refs unconditionally
-// turns "selects nothing" into "selects everything".
-//
-// IDs and Refs are index-aligned: IDs[i] is the row id of Refs[i]. Both are
-// present because both are needed — the index filters by space reference, while
-// document_id, proposal and index_stamp key off the row id.
-type SpaceFilter struct {
- All bool
- IDs []int
- Refs []core.SpaceRef
-}
+// It lives in core because search.Query takes the same type. The alternative —
+// this package's struct translated into a query's own space list at each
+// caller — is where the polarity trap lived: an empty project's membership is
+// an empty slice, "no terms" reads as "no restriction" to a query filter, and a
+// freshly created project silently became the whole corpus. There is no slice
+// to hand over any more; a filter carries its own polarity all the way down.
+type SpaceFilter = core.SpaceFilter
// EverythingFilter is the degenerate filter: the meta-project, "merge all my
// doc work into one searchable thing".
@@ -67,38 +46,7 @@ // silently omits a space — and it could be renamed or deleted, which the
// meta-project must not be. As a filter that excludes nothing it needs no
// storage, no migration data and no maintenance, and adding a space to the
// service adds it to the meta-project by construction.
-func EverythingFilter() SpaceFilter { return SpaceFilter{All: true} }
-
-// MatchesNothing reports whether the filter selects no space at all — an empty
-// project. Worth asking explicitly before running a query, since it is the one
-// case where the answer is known without touching the index.
-func (f SpaceFilter) MatchesNothing() bool { return !f.All && len(f.IDs) == 0 }
-
-// Matches reports whether a space is within the filter.
-func (f SpaceFilter) Matches(ref core.SpaceRef) bool {
- if f.All {
- return true
- }
- for _, r := range f.Refs {
- if r == ref {
- return true
- }
- }
- return false
-}
-
-// MatchesID reports whether a space row id is within the filter.
-func (f SpaceFilter) MatchesID(id int) bool {
- if f.All {
- return true
- }
- for _, got := range f.IDs {
- if got == id {
- return true
- }
- }
- return false
-}
+func EverythingFilter() SpaceFilter { return core.EverythingFilter() }
// ResolveProject resolves a project reference to the space filter its queries
// run under. This is the read the whole feature exists for.
@@ -121,15 +69,15 @@ spaces, err := s.store.ProjectSpaces(ctx, row.ID)
if err != nil {
return SpaceFilter{}, fmt.Errorf("service: resolve project %s: %w", ref, err)
}
- f := SpaceFilter{
- IDs: make([]int, 0, len(spaces)),
- Refs: make([]core.SpaceRef, 0, len(spaces)),
- }
+ refs := make([]core.SpaceRef, 0, len(spaces))
+ ids := make([]int, 0, len(spaces))
for _, sp := range spaces {
- f.IDs = append(f.IDs, sp.ID)
- f.Refs = append(f.Refs, sp.Ref)
+ refs = append(refs, sp.Ref)
+ ids = append(ids, sp.ID)
}
- return f, nil
+ // Named, not "all": a project with no members selects nothing, and the
+ // filter says so wherever it is carried.
+ return core.SpacesFilter(refs, ids), nil
}
// CreateProject creates an empty project.
diff --git a/service/project_test.go b/service/project_test.go
index 416ef8c55c624f551b5315ba40f6601bfdd314dc..d18d6b94a19ad8e94ba2705801c04d91b94bff64 100644
--- a/service/project_test.go
+++ b/service/project_test.go
@@ -23,13 +23,13 @@ f, err := svc.ResolveProject(context.Background(), metaRef())
if err != nil {
t.Fatalf("ResolveProject(meta): %v", err)
}
- if !f.All {
+ if !f.Everything() {
t.Fatal("the meta-project must exclude nothing")
}
// Deliberately unenumerated: a frozen list would omit every space created
// after the resolve.
- if len(f.Refs) != 0 || len(f.IDs) != 0 {
- t.Fatalf("the meta filter enumerated %d spaces; it must not", len(f.Refs))
+ if len(f.Refs()) != 0 || len(f.IDs()) != 0 {
+ t.Fatalf("the meta filter enumerated %d spaces; it must not", len(f.Refs()))
}
if f.MatchesNothing() {
t.Fatal("the meta filter matches everything, not nothing")
@@ -41,9 +41,9 @@ }
// The trap this type exists to prevent: an empty project is not the corpus.
func TestEmptyFilterIsNotEverything(t *testing.T) {
- var empty SpaceFilter
- if empty.All {
- t.Fatal("the zero filter must not be the meta-project")
+ empty := core.SpacesFilter(nil, nil)
+ if empty.Everything() {
+ t.Fatal("a filter over no spaces must not be the meta-project")
}
if !empty.MatchesNothing() {
t.Fatal("a filter with no terms selects nothing")
@@ -51,11 +51,19 @@ }
if empty.Matches(fxSpace) || empty.MatchesID(1) {
t.Fatal("a filter with no terms must match no space")
}
+
+ // And the state that is neither answer: nobody said. It is not silently
+ // one of the two — a query refuses it, which is what makes "I forgot to
+ // set the scope" a failure instead of a wrong-scoped answer.
+ var unset SpaceFilter
+ if !unset.IsZero() || unset.Everything() {
+ t.Fatal("the zero filter must be neither everything nor a set scope")
+ }
}
func TestSpaceFilterMatches(t *testing.T) {
other := core.SpaceRef{Owner: "bigbes", Name: "notes"}
- f := SpaceFilter{IDs: []int{7}, Refs: []core.SpaceRef{fxSpace}}
+ f := core.SpacesFilter([]core.SpaceRef{fxSpace}, []int{7})
if !f.Matches(fxSpace) || !f.MatchesID(7) {
t.Error("a member space must match")
}
@@ -132,8 +140,11 @@ f, err := svc.ResolveProject(ctx, fxProject)
if err != nil {
t.Fatalf("ResolveProject: %v", err)
}
- if f.All {
+ if f.Everything() {
t.Fatal("an empty project must not resolve to the meta-project")
+ }
+ if f.IsZero() {
+ t.Fatal("a resolved project must carry a scope, not the zero filter")
}
if !f.MatchesNothing() {
t.Fatalf("an empty project resolved to %+v", f)
@@ -151,11 +162,11 @@ f, err = svc.ResolveProject(ctx, fxProject)
if err != nil {
t.Fatalf("ResolveProject: %v", err)
}
- if len(f.Refs) != 1 || f.Refs[0] != fxSpace {
- t.Fatalf("filter refs = %+v", f.Refs)
+ if len(f.Refs()) != 1 || f.Refs()[0] != fxSpace {
+ t.Fatalf("filter refs = %+v", f.Refs())
}
- if len(f.IDs) != 1 || f.IDs[0] != rfcs.ID {
- t.Fatalf("filter ids = %+v, want [%d]", f.IDs, rfcs.ID)
+ if len(f.IDs()) != 1 || f.IDs()[0] != rfcs.ID {
+ t.Fatalf("filter ids = %+v, want [%d]", f.IDs(), rfcs.ID)
}
if !f.Matches(fxSpace) || !f.MatchesID(rfcs.ID) {
t.Error("the member space must match")
diff --git a/web/handlers.go b/web/handlers.go
index 8f1769c3061dea20ba4d9a0bac9bea29e0e1f4fd..0635c56433b7739875f611acf6cfc9fcae73cf23 100644
--- a/web/handlers.go
+++ b/web/handlers.go
@@ -531,14 +531,17 @@ for _, ref := range refs {
data.Spaces = append(data.Spaces, spaceLink{Ref: ref.String(), Href: "/" + ref.String()})
}
- query := search.Query{Text: q, Limit: searchLimit}
+ // No space parameter is a search of every space, said in as many words:
+ // the filter carries its polarity, so "the viewer named no space" and "the
+ // viewer named a project with no spaces" cannot collapse into each other.
+ query := search.Query{Text: q, Limit: searchLimit, Spaces: core.EverythingFilter()}
if spaceParam != "" {
ref, err := core.ParseSpaceRef(spaceParam)
if err != nil {
s.fail(w, r, err)
return
}
- query.Spaces = []core.SpaceRef{ref}
+ query.Spaces = core.SpacesFilter([]core.SpaceRef{ref}, nil)
}
if q != "" {
diff --git a/web/web_test.go b/web/web_test.go
index b188bdef1fc9e4663e10a8e251171ae7a39cae2c..b41c2dee70bf2c0be58fccfd563a263702f646af 100644
--- a/web/web_test.go
+++ b/web/web_test.go
@@ -645,8 +645,11 @@ }
if searcher.last.Text != "authoritative" {
t.Fatalf("query text = %q", searcher.last.Text)
}
- if len(searcher.last.Spaces) != 1 || searcher.last.Spaces[0] != demoSpace {
- t.Fatalf("space filter = %+v", searcher.last.Spaces)
+ if refs := searcher.last.Spaces.Refs(); len(refs) != 1 || refs[0] != demoSpace {
+ t.Fatalf("space filter = %s", searcher.last.Spaces)
+ }
+ if searcher.last.Spaces.Everything() {
+ t.Fatal("naming a space must restrict the search, not widen it")
}
body := rec.Body.String()
if !strings.Contains(body, "authoritative") {