Merged
Reuse Git-backed local project lexical and RAG retrieval (#205) #197
Reuse Git-backed local project lexical and RAG retrieval (#205) #197
Merged
urandonAdd on 23 days ago
Total: 33 files changed+1876-53
@@ -0,0 +1,131 @@
1+package main
2+ 
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "os"
9+ "path/filepath"
10+ "strconv"
11+ "sync/atomic"
12+ "testing"
13+ 
14+ "gitcode-mcp/internal/config"
15+ "gitcode-mcp/internal/localgit"
16+ "gitcode-mcp/internal/service"
17+)
18+ 
19+func TestLocalProjectSearchCLIAndMCPRealGitAndExplicitProvider(t *testing.T) {
20+ base := newTestSource(t)
21+ short, err := os.MkdirTemp(".", "g5-")
22+ if err != nil {
23+ t.Fatal("short runtime fixture unavailable")
24+ }
25+ t.Cleanup(func() { os.RemoveAll(short) })
26+ base.cacheDir = filepath.Join(short, "cache")
27+ base.env[config.EnvMCPConfigPath] = filepath.Join(base.configDir, "local.yaml")
28+ var calls, documentTransfers atomic.Int64
29+ var providerFailure, queryFailure atomic.Bool
30+ provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31+ calls.Add(1)
32+ if providerFailure.Load() {
33+ w.WriteHeader(503)
34+ return
35+ }
36+ w.Header().Set("Content-Type", "application/json")
37+ switch r.URL.Path {
38+ case "/api/tags":
39+ json.NewEncoder(w).Encode(map[string]any{"models": []any{map[string]any{"name": "fixture-embedding", "digest": "fixture-revision"}}})
40+ case "/api/embed":
41+ if queryFailure.Load() {
42+ w.WriteHeader(503)
43+ return
44+ }
45+ var req struct {
46+ Input []string `json:"input"`
47+ }
48+ if json.NewDecoder(r.Body).Decode(&req) != nil {
49+ t.Error("invalid bounded fixture embed request")
50+ w.WriteHeader(400)
51+ return
52+ }
53+ vectors := make([][]float64, len(req.Input))
54+ for i, input := range req.Input {
55+ if bytes.Contains([]byte(input), []byte("GitOnlyTransportFixture")) {
56+ documentTransfers.Add(1)
57+ }
58+ vectors[i] = []float64{1, 0, 0, 0, 0, 0, 0, 0}
59+ }
60+ json.NewEncoder(w).Encode(map[string]any{"model": "fixture-embedding", "embeddings": vectors})
61+ default:
62+ t.Error("local search attempted unexpected provider or GitCode operation")
63+ w.WriteHeader(500)
64+ }
65+ }))
66+ defer provider.Close()
67+ base.env[config.EnvAPIURL] = provider.URL + "/api/v5"
68+ src := localProjectSource{base}
69+ localProjectCLI(t, src, "", "init", "--project", "lab", "--project-id", "lab", "--storage-ref", "data", "--rag-profile", "fixture", "--confirm")
70+ data, err := os.ReadFile(base.env[config.EnvMCPConfigPath])
71+ if err != nil || bytes.Count(data, []byte("rag_profile: fixture")) != 1 {
72+ t.Fatal("local setup fixture invalid")
73+ }
74+ data = append(data, []byte("\nrag:\n providers:\n fixture:\n type: ollama\n data_boundary: local_network\n endpoint: "+strconv.Quote(provider.URL)+"\n profiles:\n fixture:\n provider: fixture\n model: fixture-embedding\n dimensions: 8\n batch_size: 2\n max_input_tokens: 512\n")...)
75+ if os.WriteFile(base.env[config.EnvMCPConfigPath], data, 0600) != nil {
76+ t.Fatal("explicit profile fixture unavailable")
77+ }
78+ _, stop := startLocalProjectDaemon(t, src)
79+ defer stop()
80+ var status service.LocalProjectsStatus
81+ json.Unmarshal(localProjectCLI(t, src, "", "status"), &status)
82+ var issue localgit.MutationResult
83+ json.Unmarshal(localProjectCLI(t, src, "", "mutate", "--project", "lab", "--operation", "create_issue", "--title", "Question", "--body", "oldneedle GitOnlyTransportFixture", "--expected-head", status.Projects[0].Commit, "--write-mode", "local", "--idempotency-key", "question"), &issue)
84+ var cliSearch, mcpSearch service.LocalProjectSearchResult
85+ json.Unmarshal(localProjectCLI(t, src, "", "search", "--project", "lab", "--query", "oldneedle"), &cliSearch)
86+ json.Unmarshal(localProjectMCP(t, src, "tools/call", projectTool("local_project_search", service.LocalProjectSearchRequest{Project: "lab", Query: "oldneedle"}), ""), &mcpSearch)
87+ if calls.Load() != 0 || cliSearch.EffectiveMode != "fulltext" || len(cliSearch.Hits) != 1 || len(mcpSearch.Hits) != 1 || cliSearch.Hits[0].Citation != issue.Entity.Citation || cliSearch.Hits[0].Citation != mcpSearch.Hits[0].Citation {
88+ t.Fatal("default transports contacted provider or lost exact citation")
89+ }
90+ localProjectMCP(t, localReadOnlySource{src}, "tools/call", projectTool("local_project_index", service.LocalProjectIndexRequest{Project: "lab", WriteMode: "local"}), "tool_disabled_by_policy")
91+ if calls.Load() != 0 {
92+ t.Fatal("read policy performed provider work")
93+ }
94+ var indexed service.LocalProjectIndexResult
95+ json.Unmarshal(localProjectCLI(t, src, "", "index", "--project", "lab", "--write-mode", "local", "--max-chunks", "8"), &indexed)
96+ if indexed.State != "ready" || documentTransfers.Load() != 1 {
97+ t.Fatal("explicit bounded index did not embed document once")
98+ }
99+ before := documentTransfers.Load()
100+ json.Unmarshal(localProjectMCP(t, src, "tools/call", projectTool("local_project_search", service.LocalProjectSearchRequest{Project: "lab", Query: "oldneedle", Mode: "hybrid"}), ""), &mcpSearch)
101+ if mcpSearch.EffectiveMode != "hybrid" || mcpSearch.IndexState != "ready" || len(mcpSearch.Hits) != 1 || documentTransfers.Load() != before {
102+ t.Fatal("hybrid query repaired index or sent cached text")
103+ }
104+ queryFailure.Store(true)
105+ json.Unmarshal(localProjectCLI(t, src, "", "search", "--project", "lab", "--query", "oldneedle", "--mode", "hybrid"), &cliSearch)
106+ json.Unmarshal(localProjectMCP(t, src, "tools/call", projectTool("local_project_search", service.LocalProjectSearchRequest{Project: "lab", Query: "oldneedle", Mode: "hybrid"}), ""), &mcpSearch)
107+ for _, result := range []service.LocalProjectSearchResult{cliSearch, mcpSearch} {
108+ if result.EffectiveMode != "fulltext" || result.Coverage.State != "lexical-only" || result.Fallback != "semantic_unavailable" || len(result.Hits) != 1 || result.Hits[0].Citation != issue.Entity.Citation || documentTransfers.Load() != before {
109+ t.Fatal("query embedding failure lost verified lexical fallback")
110+ }
111+ }
112+ queryFailure.Store(false)
113+ update := service.LocalProjectMutation{Project: "lab", Mutation: localgit.Mutation{Operation: "update_issue", ID: "ISSUE-1", Title: "Question", State: "open", Body: "newneedle GitOnlyTransportFixture", ExpectedRevision: issue.Entity.Citation.Blob, WriteMode: "local", IdempotencyKey: "edit"}}
114+ localProjectMCP(t, src, "tools/call", projectTool("local_project_mutate", update), "")
115+ json.Unmarshal(localProjectCLI(t, src, "", "search", "--project", "lab", "--query", "oldneedle", "--mode", "hybrid"), &cliSearch)
116+ if cliSearch.EffectiveMode != "fulltext" || cliSearch.IndexState != "stale" || len(cliSearch.Hits) != 0 || documentTransfers.Load() != before {
117+ t.Fatal("transport used or repaired stale semantic set")
118+ }
119+ json.Unmarshal(localProjectMCP(t, src, "tools/call", projectTool("local_project_index", service.LocalProjectIndexRequest{Project: "lab", WriteMode: "local", MaxChunks: 8}), ""), &indexed)
120+ if indexed.State != "ready" || indexed.EmbeddedChunks != 1 {
121+ t.Fatal("MCP index failed to use shared engine")
122+ }
123+ before = documentTransfers.Load()
124+ providerFailure.Store(true)
125+ json.Unmarshal(localProjectMCP(t, src, "tools/call", projectTool("local_project_search", service.LocalProjectSearchRequest{Project: "lab", Query: "newneedle", Mode: "hybrid"}), ""), &mcpSearch)
126+ if mcpSearch.EffectiveMode != "fulltext" || mcpSearch.Fallback != "semantic_unavailable" || len(mcpSearch.Hits) != 1 || documentTransfers.Load() != before {
127+ t.Fatal("provider failure hid lexical results or repaired documents")
128+ }
129+ localProjectCLI(t, src, "local_git_invalid_argument", "search", "--project", "lab", "--query", "x", "--write-mode", "local")
130+ localProjectMCP(t, src, "tools/call", projectTool("local_project_search", map[string]any{"project": "lab", "query": "x", "cache_path": "private-coordinate"}), "local_git_invalid_argument")
131+}
Mdocs/component-architecture.md+1-1File content is under review, please try again later
Mdocs/local-git-project-format.md+86-6File content is under review, please try again later
Mdocs/mcp-setup.md+7-0File content is under review, please try again later
Minternal/capability/local_projects.go+9-2File content is under review, please try again later
Minternal/cli/local_projects.go+27-2File content is under review, please try again later
Minternal/config/effective.go+4-1File content is under review, please try again later
Minternal/config/local_projects_test.go+27-0File content is under review, please try again later
Minternal/localgit/reader.go+4-0File content is under review, please try again later
Ainternal/localgit/search_corpus.go+45-0File content is under review, please try again later
Minternal/mcp/local_projects.go+18-1File content is under review, please try again later
Minternal/mcp/local_projects_test.go+12-4File content is under review, please try again later
@@ -71,11 +71,7 @@ func ChunkDocumentWithPolicy(objectIdentity string, data []byte, policy Processi
71 if normalized != "" {71 if normalized != "" {
72 rawDigest := digestBytes(raw)72 rawDigest := digestBytes(raw)
73 inputDigest := digestBytes([]byte(normalized))73 inputDigest := digestBytes([]byte(normalized))
74- lineStart := 1 + bytes.Count(data[:start], []byte{'\n'})74+ lineStart, lineEnd := chunkLineRange(data, start, end)
75- lineEnd := lineStart + bytes.Count(raw, []byte{'\n'})
76- if len(raw) > 0 && raw[len(raw)-1] == '\n' && lineEnd > lineStart {
77- lineEnd--
78- }
79 idSum := sha256.Sum256([]byte(strings.Join([]string{objectIdentity, fmt.Sprint(start), fmt.Sprint(end), rawDigest, inputDigest, policyID}, "\x00")))75 idSum := sha256.Sum256([]byte(strings.Join([]string{objectIdentity, fmt.Sprint(start), fmt.Sprint(end), rawDigest, inputDigest, policyID}, "\x00")))
80 chunks = append(chunks, Chunk{ID: "repo-doc-chunk-" + hex.EncodeToString(idSum[:]), ByteStart: start, ByteEnd: end, LineStart: lineStart, LineEnd: lineEnd, RawSliceDigest: rawDigest, EmbeddingInputDigest: inputDigest, Text: normalized})76 chunks = append(chunks, Chunk{ID: "repo-doc-chunk-" + hex.EncodeToString(idSum[:]), ByteStart: start, ByteEnd: end, LineStart: lineStart, LineEnd: lineEnd, RawSliceDigest: rawDigest, EmbeddingInputDigest: inputDigest, Text: normalized})
81 }77 }
@@ -84,6 +80,17 @@ func ChunkDocumentWithPolicy(objectIdentity string, data []byte, policy Processi
84 return chunks, nil80 return chunks, nil
85}81}
86 82 
83+// Call only after validating the byte range. Line locators are always derived
84+// from authoritative bytes, never trusted from disposable index metadata.
85+func chunkLineRange(data []byte, start, end int) (int, int) {
86+ lineStart := 1 + bytes.Count(data[:start], []byte{'\n'})
87+ lineEnd := lineStart + bytes.Count(data[start:end], []byte{'\n'})
88+ if end > start && data[end-1] == '\n' && lineEnd > lineStart {
89+ lineEnd--
90+ }
91+ return lineStart, lineEnd
92+}
93+ 
87func normalizeEmbeddingInput(value string) string {94func normalizeEmbeddingInput(value string) string {
88 lines := strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n")95 lines := strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n")
89 for idx := range lines {96 for idx := range lines {
@@ -136,6 +136,7 @@ func (e *IndexSnapshotStaleError) Unwrap() error { return ErrIndexSnapshotStale
136func (e *IndexSnapshotStaleError) DiagnosticCode() string { return "repository_docs_snapshot_stale" }136func (e *IndexSnapshotStaleError) DiagnosticCode() string { return "repository_docs_snapshot_stale" }
137 137 
138type Repository struct {138type Repository struct {
139+ pinned *pinnedCorpus
139 root string140 root string
140 commonDir string141 commonDir string
141 GitStoreRef string `json:"git_store_ref"`142 GitStoreRef string `json:"git_store_ref"`
@@ -162,7 +163,7 @@ func (r *Repository) unavailableGitObjectError(ctx context.Context, object, reas
162}163}
163 164 
164func (r *Repository) gitObjectAvailability(ctx context.Context, revisionObject bool) GitObjectAvailability {165func (r *Repository) gitObjectAvailability(ctx context.Context, revisionObject bool) GitObjectAvailability {
165- if r == nil || strings.TrimSpace(r.root) == "" {166+ if r == nil || r.pinned != nil || strings.TrimSpace(r.root) == "" {
166 return GitObjectUnavailable167 return GitObjectUnavailable
167 }168 }
168 isShallow := func() bool {169 isShallow := func() bool {
@@ -394,6 +395,9 @@ func (r *Repository) ResolveRevision(ctx context.Context, revision string) (stri
394 if r == nil {395 if r == nil {
395 return "", fmt.Errorf("repository docs: Git repository is required")396 return "", fmt.Errorf("repository docs: Git repository is required")
396 }397 }
398+ if r.pinned != nil {
399+ return r.pinned.resolve(ctx, revision)
400+ }
397 if strings.TrimSpace(revision) == "" {401 if strings.TrimSpace(revision) == "" {
398 revision = "HEAD"402 revision = "HEAD"
399 }403 }
@@ -423,6 +427,9 @@ func (r *Repository) ListTree(ctx context.Context, commitOID string) ([]TreeEntr
423// WalkTree visits the committed tree in Git's deterministic output order427// WalkTree visits the committed tree in Git's deterministic output order
424// without buffering the complete ls-tree response in memory.428// without buffering the complete ls-tree response in memory.
425func (r *Repository) WalkTree(ctx context.Context, commitOID string, visit func(TreeEntry) error) error {429func (r *Repository) WalkTree(ctx context.Context, commitOID string, visit func(TreeEntry) error) error {
430+ if r.pinned != nil {
431+ return r.pinned.walk(ctx, commitOID, visit)
432+ }
426 if !validOID(commitOID, r.ObjectFormat) {433 if !validOID(commitOID, r.ObjectFormat) {
427 return invalidGitObjectError(commitOID, "invalid commit object id")434 return invalidGitObjectError(commitOID, "invalid commit object id")
428 }435 }
@@ -514,6 +521,16 @@ func parseTreeRecord(record []byte) (TreeEntry, error) {
514}521}
515 522 
516func (r *Repository) OpenBlob(ctx context.Context, oid string) (io.ReadCloser, error) {523func (r *Repository) OpenBlob(ctx context.Context, oid string) (io.ReadCloser, error) {
524+ if r.pinned != nil {
525+ if err := ctx.Err(); err != nil {
526+ return nil, err
527+ }
528+ data, ok := r.pinned.blobs[oid]
529+ if !ok {
530+ return nil, ErrGitObjectUnavailable
531+ }
532+ return io.NopCloser(bytes.NewReader(data)), nil
533+ }
517 if !validOID(oid, r.ObjectFormat) {534 if !validOID(oid, r.ObjectFormat) {
518 return nil, invalidGitObjectError(oid, "invalid blob object id")535 return nil, invalidGitObjectError(oid, "invalid blob object id")
519 }536 }
@@ -574,6 +591,9 @@ func readBoundedFile(path string, maxBytes int64) ([]byte, error) {
574}591}
575 592 
576func (r *Repository) ReadFileAtCommit(ctx context.Context, commitOID, repoPath string, maxBytes int64) ([]byte, bool, error) {593func (r *Repository) ReadFileAtCommit(ctx context.Context, commitOID, repoPath string, maxBytes int64) ([]byte, bool, error) {
594+ if r.pinned != nil {
595+ return r.pinned.file(ctx, commitOID, repoPath, maxBytes)
596+ }
577 if !validOID(commitOID, r.ObjectFormat) {597 if !validOID(commitOID, r.ObjectFormat) {
578 return nil, false, invalidGitObjectError(commitOID, "invalid commit object id")598 return nil, false, invalidGitObjectError(commitOID, "invalid commit object id")
579 }599 }
@@ -627,6 +647,9 @@ func (r *Repository) TrackedChangesAt(ctx context.Context, commitOID string, max
627// relevant to the caller's corpus. Git still computes one local name-status647// relevant to the caller's corpus. Git still computes one local name-status
628// diff, but an unrelated large tracked file cannot fail documentation work.648// diff, but an unrelated large tracked file cannot fail documentation work.
629func (r *Repository) TrackedChangesAtFiltered(ctx context.Context, commitOID string, maxBytes int64, include func(string) bool) ([]WorktreeChange, error) {649func (r *Repository) TrackedChangesAtFiltered(ctx context.Context, commitOID string, maxBytes int64, include func(string) bool) ([]WorktreeChange, error) {
650+ if r.pinned != nil {
651+ return nil, ErrWorktreeUnavailable
652+ }
630 if !validOID(commitOID, r.ObjectFormat) {653 if !validOID(commitOID, r.ObjectFormat) {
631 return nil, invalidGitObjectError(commitOID, "invalid overlay base commit")654 return nil, invalidGitObjectError(commitOID, "invalid overlay base commit")
632 }655 }
@@ -733,6 +756,9 @@ func stateForWorktreeChange(states map[string]byte, change WorktreeChange) byte
733}756}
734 757 
735func (r *Repository) ReadTrackedWorktreeFile(ctx context.Context, repoPath string, maxBytes int64) ([]byte, error) {758func (r *Repository) ReadTrackedWorktreeFile(ctx context.Context, repoPath string, maxBytes int64) ([]byte, error) {
759+ if r.pinned != nil {
760+ return nil, ErrWorktreeUnavailable
761+ }
736 if filepath.IsAbs(repoPath) || filepath.VolumeName(repoPath) != "" {762 if filepath.IsAbs(repoPath) || filepath.VolumeName(repoPath) != "" {
737 return nil, &WorktreeError{Reason: "unsafe repository-relative path"}763 return nil, &WorktreeError{Reason: "unsafe repository-relative path"}
738 }764 }
@@ -761,6 +787,12 @@ func ResolvePolicy(ctx context.Context, repo *Repository, revision string, inclu
761 if err != nil {787 if err != nil {
762 return "", PolicyResolution{}, err788 return "", PolicyResolution{}, err
763 }789 }
790+ if repo.pinned != nil {
791+ if includeWorktree {
792+ return "", PolicyResolution{}, ErrWorktreeUnavailable
793+ }
794+ return commitOID, pinnedProjectPolicy(), nil
795+ }
764 data, found, err := repo.ReadFileAtCommit(ctx, commitOID, PolicyConfigPath, DefaultMaxDocumentBytes)796 data, found, err := repo.ReadFileAtCommit(ctx, commitOID, PolicyConfigPath, DefaultMaxDocumentBytes)
765 if err != nil {797 if err != nil {
766 return "", PolicyResolution{}, err798 return "", PolicyResolution{}, err
@@ -222,13 +222,19 @@ func (i *Indexer) Run(ctx context.Context, req IndexRequest) (IndexResult, error
222 inputs[idx] = pending[idx].text222 inputs[idx] = pending[idx].text
223 }223 }
224 response, embedErr := i.provider.Embed(ctx, rag.EmbedRequest{Inputs: inputs})224 response, embedErr := i.provider.Embed(ctx, rag.EmbedRequest{Inputs: inputs})
225+ if embedErr == nil && !embeddingResponseMatchesNamespace(response, namespaceIdentity) {
226+ return SemanticProviderContractError{code: "semantic_provider_namespace_changed"}
227+ }
225 if embedErr != nil || len(response.Embeddings) != len(pending) {228 if embedErr != nil || len(response.Embeddings) != len(pending) {
226 for _, item := range pending {229 for _, item := range pending {
227 if err := ctx.Err(); err != nil {230 if err := ctx.Err(); err != nil {
228 return err231 return err
229 }232 }
230 one, oneErr := i.provider.Embed(ctx, rag.EmbedRequest{Inputs: []string{item.text}})233 one, oneErr := i.provider.Embed(ctx, rag.EmbedRequest{Inputs: []string{item.text}})
231- if oneErr != nil || len(one.Embeddings) != 1 {234+ if oneErr == nil && !embeddingResponseMatchesNamespace(one, namespaceIdentity) {
235+ return SemanticProviderContractError{code: "semantic_provider_namespace_changed"}
236+ }
237+ if oneErr != nil || len(one.Embeddings) != 1 || one.Dimensions != namespace.Dimensions {
232 result.FailedChunks++238 result.FailedChunks++
233 continue239 continue
234 }240 }
@@ -243,6 +249,10 @@ func (i *Indexer) Run(ctx context.Context, req IndexRequest) (IndexResult, error
243 return nil249 return nil
244 }250 }
245 for idx, vector := range response.Embeddings {251 for idx, vector := range response.Embeddings {
252+ if response.Dimensions != namespace.Dimensions {
253+ result.FailedChunks++
254+ continue
255+ }
246 if err := i.persistProviderVector(ctx, req.RepoID, namespace.ID, pending[idx].meta.ID, namespace.Dimensions, vector); err != nil {256 if err := i.persistProviderVector(ctx, req.RepoID, namespace.ID, pending[idx].meta.ID, namespace.Dimensions, vector); err != nil {
247 result.FailedChunks++257 result.FailedChunks++
248 continue258 continue
Ainternal/repositorydocs/pinned_corpus.go+118-0File content is under review, please try again later
Ainternal/repositorydocs/pinned_corpus_test.go+59-0File content is under review, please try again later
@@ -91,25 +91,28 @@ func (e SemanticProviderContractError) DiagnosticCode() string {
91}91}
92 92 
93type SearchResult struct {93type SearchResult struct {
94- RepoID string `json:"repo_id"`94+ // RequestedNamespaceID supports internal coverage classification. It is not
95- CorpusKind string `json:"corpus_kind"`95+ // public evidence of a selected/ready semantic namespace.
96- Query string `json:"query"`96+ RequestedNamespaceID string `json:"-"`
97- RequestedRevision string `json:"requested_revision"`97+ RepoID string `json:"repo_id"`
98- EffectiveRevision string `json:"effective_revision"`98+ CorpusKind string `json:"corpus_kind"`
99- Mode string `json:"mode"`99+ Query string `json:"query"`
100- RequestedMode string `json:"requested_mode"`100+ RequestedRevision string `json:"requested_revision"`
101- EffectiveMode string `json:"effective_mode"`101+ EffectiveRevision string `json:"effective_revision"`
102- Authority string `json:"authority"`102+ Mode string `json:"mode"`
103- OverlayDigest string `json:"overlay_digest,omitempty"`103+ RequestedMode string `json:"requested_mode"`
104- RevisionSetID string `json:"revision_set_id,omitempty"`104+ EffectiveMode string `json:"effective_mode"`
105- PolicyHash string `json:"policy_hash"`105+ Authority string `json:"authority"`
106- PolicySource string `json:"policy_source"`106+ OverlayDigest string `json:"overlay_digest,omitempty"`
107- NamespaceID string `json:"namespace_id,omitempty"`107+ RevisionSetID string `json:"revision_set_id,omitempty"`
108- Coverage SearchCoverage `json:"coverage"`108+ PolicyHash string `json:"policy_hash"`
109- Hits []SearchHit `json:"hits"`109+ PolicySource string `json:"policy_source"`
110- Warnings []string `json:"warnings,omitempty"`110+ NamespaceID string `json:"namespace_id,omitempty"`
111- WarningDetails []SearchWarning `json:"warning_details,omitempty"`111+ Coverage SearchCoverage `json:"coverage"`
112- Fallback string `json:"fallback,omitempty"`112+ Hits []SearchHit `json:"hits"`
113+ Warnings []string `json:"warnings,omitempty"`
114+ WarningDetails []SearchWarning `json:"warning_details,omitempty"`
115+ Fallback string `json:"fallback,omitempty"`
113}116}
114 117 
115func (r *SearchResult) addWarning(code, message string) {118func (r *SearchResult) addWarning(code, message string) {
@@ -132,7 +135,7 @@ func NewRetriever(store SearchStore, provider rag.EmbeddingProvider) *Retriever
132}135}
133 136 
134func (r *Retriever) Search(ctx context.Context, req SearchRequest) (SearchResult, error) {137func (r *Retriever) Search(ctx context.Context, req SearchRequest) (SearchResult, error) {
135- if r == nil || r.store == nil || req.Repository == nil || strings.TrimSpace(req.RepoID) == "" || strings.TrimSpace(req.Query) == "" {138+ if r == nil || req.Repository == nil || strings.TrimSpace(req.RepoID) == "" || strings.TrimSpace(req.Query) == "" {
136 return SearchResult{}, fmt.Errorf("repository docs: search requires repo id, Git repository, query, and cache")139 return SearchResult{}, fmt.Errorf("repository docs: search requires repo id, Git repository, query, and cache")
137 }140 }
138 if req.Mode == "" {141 if req.Mode == "" {
@@ -168,8 +171,9 @@ func (r *Retriever) Search(ctx context.Context, req SearchRequest) (SearchResult
168 result.Authority = "worktree_overlay"171 result.Authority = "worktree_overlay"
169 result.OverlayDigest = overlayDigest172 result.OverlayDigest = overlayDigest
170 }173 }
171- semanticAvailable := req.Mode == SearchModeHybrid && r.provider != nil174+ semanticAvailable := req.Mode == SearchModeHybrid && r.provider != nil && r.store != nil
172 expectedNamespaceID := ""175 expectedNamespaceID := ""
176+ var expectedNamespace cache.EmbeddingNamespaceIdentity
173 if semanticAvailable {177 if semanticAvailable {
174 identity, identityErr := r.provider.NamespaceIdentity(ctx, rag.NamespaceRequest{RepoID: req.RepoID, ChunkPolicyID: chunkPolicyID, LanguagePolicyID: rag.DefaultLanguagePolicyID, DocumentInstructionID: "repo-doc-v1", QueryInstructionID: "repo-doc-query-v1"})178 identity, identityErr := r.provider.NamespaceIdentity(ctx, rag.NamespaceRequest{RepoID: req.RepoID, ChunkPolicyID: chunkPolicyID, LanguagePolicyID: rag.DefaultLanguagePolicyID, DocumentInstructionID: "repo-doc-v1", QueryInstructionID: "repo-doc-query-v1"})
175 if identityErr != nil {179 if identityErr != nil {
@@ -179,6 +183,8 @@ func (r *Retriever) Search(ctx context.Context, req SearchRequest) (SearchResult
179 result.addWarning("embedding_provider_identity_unavailable", "embedding provider identity unavailable; returning verified lexical results")183 result.addWarning("embedding_provider_identity_unavailable", "embedding provider identity unavailable; returning verified lexical results")
180 } else {184 } else {
181 expectedNamespaceID = cache.EmbeddingNamespaceID(identity)185 expectedNamespaceID = cache.EmbeddingNamespaceID(identity)
186+ expectedNamespace = identity
187+ result.RequestedNamespaceID = expectedNamespaceID
182 }188 }
183 }189 }
184 var selected cache.RepositoryDocRevisionSet190 var selected cache.RepositoryDocRevisionSet
@@ -229,7 +235,7 @@ func (r *Retriever) Search(ctx context.Context, req SearchRequest) (SearchResult
229 }235 }
230 semantic := map[string]rankedHit{}236 semantic := map[string]rankedHit{}
231 if req.Mode == SearchModeHybrid && selected.ID != "" && semanticAvailable {237 if req.Mode == SearchModeHybrid && selected.ID != "" && semanticAvailable {
232- semantic, err = r.semantic(ctx, req, selected)238+ semantic, err = r.semantic(ctx, req, selected, expectedNamespace)
233 if err != nil {239 if err != nil {
234 code := "semantic_retrieval_unavailable"240 code := "semantic_retrieval_unavailable"
235 message := "semantic retrieval unavailable; returning verified lexical results"241 message := "semantic retrieval unavailable; returning verified lexical results"
@@ -242,6 +248,7 @@ func (r *Retriever) Search(ctx context.Context, req SearchRequest) (SearchResult
242 result.EffectiveMode = SearchModeFullText248 result.EffectiveMode = SearchModeFullText
243 result.addWarning(code, message)249 result.addWarning(code, message)
244 semantic = map[string]rankedHit{}250 semantic = map[string]rankedHit{}
251+ result.Coverage = lexicalCoverage
245 }252 }
246 } else if req.Mode == SearchModeHybrid && !semanticAvailable && result.Fallback == "" {253 } else if req.Mode == SearchModeHybrid && !semanticAvailable && result.Fallback == "" {
247 result.Fallback = "provider_unavailable"254 result.Fallback = "provider_unavailable"
@@ -422,7 +429,7 @@ func trimRankedHits(values map[string]rankedHit, limit int) map[string]rankedHit
422 return trimmed429 return trimmed
423}430}
424 431 
425-func (r *Retriever) semantic(ctx context.Context, req SearchRequest, set cache.RepositoryDocRevisionSet) (map[string]rankedHit, error) {432+func (r *Retriever) semantic(ctx context.Context, req SearchRequest, set cache.RepositoryDocRevisionSet, namespace cache.EmbeddingNamespaceIdentity) (map[string]rankedHit, error) {
426 response, err := r.provider.Embed(ctx, rag.EmbedRequest{Inputs: []string{req.Query}})433 response, err := r.provider.Embed(ctx, rag.EmbedRequest{Inputs: []string{req.Query}})
427 if err != nil || len(response.Embeddings) != 1 {434 if err != nil || len(response.Embeddings) != 1 {
428 if err == nil {435 if err == nil {
@@ -431,31 +438,52 @@ func (r *Retriever) semantic(ctx context.Context, req SearchRequest, set cache.R
431 return nil, err438 return nil, err
432 }439 }
433 query := response.Embeddings[0]440 query := response.Embeddings[0]
434- if response.Dimensions <= 0 || len(query) != response.Dimensions {441+ if response.Dimensions <= 0 || len(query) != response.Dimensions || response.Dimensions != namespace.Dimensions {
435 return nil, SemanticProviderContractError{code: "query_embedding_dimension_mismatch"}442 return nil, SemanticProviderContractError{code: "query_embedding_dimension_mismatch"}
436 }443 }
444+ if !embeddingResponseMatchesNamespace(response, namespace) {
445+ return nil, SemanticProviderContractError{code: "semantic_provider_namespace_changed"}
446+ }
447+ query, err = rag.NormalizeFloat32Vector(query)
448+ if err != nil {
449+ return nil, SemanticProviderContractError{code: "query_embedding_vector_invalid"}
450+ }
437 snapshot, err := r.store.LoadRepositoryDocSearchSnapshot(ctx, req.RepoID, set.ID, set.NamespaceID)451 snapshot, err := r.store.LoadRepositoryDocSearchSnapshot(ctx, req.RepoID, set.ID, set.NamespaceID)
438 if err != nil {452 if err != nil {
439 return nil, err453 return nil, err
440 }454 }
455+ if len(snapshot.Candidates) != set.EligibleChunks {
456+ return nil, SemanticProviderContractError{code: "semantic_vector_contract_invalid"}
457+ }
441 results := map[string]rankedHit{}458 results := map[string]rankedHit{}
442 for _, candidate := range snapshot.Candidates {459 for _, candidate := range snapshot.Candidates {
443 if len(candidate.Vector) == 0 {460 if len(candidate.Vector) == 0 {
444- continue461+ return nil, SemanticProviderContractError{code: "semantic_vector_contract_invalid"}
445 }462 }
446 if candidate.Dimensions != len(query) {463 if candidate.Dimensions != len(query) {
447 return nil, SemanticProviderContractError{code: "query_embedding_dimension_mismatch"}464 return nil, SemanticProviderContractError{code: "query_embedding_dimension_mismatch"}
448 }465 }
466+ if candidate.DType != rag.DefaultEmbeddingDType || candidate.DType != namespace.DType || candidate.VectorHash != digestBytes(candidate.Vector) {
467+ return nil, SemanticProviderContractError{code: "semantic_vector_contract_invalid"}
468+ }
449 vector, err := rag.DecodeFloat32Vector(candidate.Vector, candidate.Dimensions)469 vector, err := rag.DecodeFloat32Vector(candidate.Vector, candidate.Dimensions)
450 if err != nil || len(vector) != len(query) {470 if err != nil || len(vector) != len(query) {
451 return nil, SemanticProviderContractError{code: "semantic_vector_contract_invalid"}471 return nil, SemanticProviderContractError{code: "semantic_vector_contract_invalid"}
452 }472 }
473+ vector, err = rag.NormalizeFloat32Vector(vector)
474+ if err != nil {
475+ return nil, SemanticProviderContractError{code: "semantic_vector_contract_invalid"}
476+ }
453 score := cosine(query, vector)477 score := cosine(query, vector)
454 results[candidateRankKey(candidate)] = rankedHit{candidate: candidate, score: score, semantic: score}478 results[candidateRankKey(candidate)] = rankedHit{candidate: candidate, score: score, semantic: score}
455 }479 }
456 return results, nil480 return results, nil
457}481}
458 482 
483+func embeddingResponseMatchesNamespace(response rag.EmbedResponse, namespace cache.EmbeddingNamespaceIdentity) bool {
484+ return response.Model == namespace.ModelID && response.Revision == namespace.ModelRevision
485+}
486+ 
459func fuseRanks(lexical, semantic map[string]rankedHit) []rankedHit {487func fuseRanks(lexical, semantic map[string]rankedHit) []rankedHit {
460 lex := sortedRanks(lexical)488 lex := sortedRanks(lexical)
461 sem := sortedRanks(semantic)489 sem := sortedRanks(semantic)
@@ -518,6 +546,7 @@ func (r *Retriever) hydrate(ctx context.Context, repo *Repository, commitOID str
518 return SearchHit{}, false, SearchWarning{Code: "worktree_overlay_stale", Message: "worktree_overlay_stale: tracked content changed; reindex the overlay"}546 return SearchHit{}, false, SearchWarning{Code: "worktree_overlay_stale", Message: "worktree_overlay_stale: tracked content changed; reindex the overlay"}
519 }547 }
520 raw := data[candidate.ByteStart:candidate.ByteEnd]548 raw := data[candidate.ByteStart:candidate.ByteEnd]
549+ candidate.LineStart, candidate.LineEnd = chunkLineRange(data, candidate.ByteStart, candidate.ByteEnd)
521 snippet := truncateUTF8Bytes(strings.TrimSpace(string(raw)), 800)550 snippet := truncateUTF8Bytes(strings.TrimSpace(string(raw)), 800)
522 return SearchHit{ChunkID: candidate.ChunkID, Snippet: snippet, Score: item.score, Lexical: item.lexical, Semantic: item.semantic, Citation: Citation{Authority: "worktree", CommitOID: commitOID, Path: candidate.Path, LineStart: candidate.LineStart, LineEnd: candidate.LineEnd, RawSliceDigest: candidate.RawSliceDigest}}, true, SearchWarning{}551 return SearchHit{ChunkID: candidate.ChunkID, Snippet: snippet, Score: item.score, Lexical: item.lexical, Semantic: item.semantic, Citation: Citation{Authority: "worktree", CommitOID: commitOID, Path: candidate.Path, LineStart: candidate.LineStart, LineEnd: candidate.LineEnd, RawSliceDigest: candidate.RawSliceDigest}}, true, SearchWarning{}
523 }552 }
@@ -529,6 +558,7 @@ func (r *Retriever) hydrate(ctx context.Context, repo *Repository, commitOID str
529 return SearchHit{}, false, SearchWarning{Code: "citation_locator_invalid", Message: "citation omitted: stored chunk locator is invalid"}558 return SearchHit{}, false, SearchWarning{Code: "citation_locator_invalid", Message: "citation omitted: stored chunk locator is invalid"}
530 }559 }
531 raw := data[candidate.ByteStart:candidate.ByteEnd]560 raw := data[candidate.ByteStart:candidate.ByteEnd]
561+ candidate.LineStart, candidate.LineEnd = chunkLineRange(data, candidate.ByteStart, candidate.ByteEnd)
532 if digestBytes(raw) != candidate.RawSliceDigest {562 if digestBytes(raw) != candidate.RawSliceDigest {
533 return SearchHit{}, false, SearchWarning{Code: "citation_digest_mismatch", Message: "citation omitted: Git content failed digest verification"}563 return SearchHit{}, false, SearchWarning{Code: "citation_digest_mismatch", Message: "citation omitted: Git content failed digest verification"}
534 }564 }
Ainternal/service/local_project_model_review_test.go+81-0File content is under review, please try again later
Ainternal/service/local_project_provider_test.go+52-0File content is under review, please try again later
Ainternal/service/local_project_query_vector_review_test.go+63-0File content is under review, please try again later
Ainternal/service/local_project_search.go+309-0File content is under review, please try again later
Ainternal/service/local_project_search_test.go+301-0File content is under review, please try again later
Minternal/service/local_project_setup.go+6-1File content is under review, please try again later
Minternal/service/local_projects.go+4-2File content is under review, please try again later
Ainternal/servicectl/local_project_checkpoint_review_test.go+55-0File content is under review, please try again later
Ainternal/servicectl/local_project_search_ipc.go+102-0File content is under review, please try again later
Ainternal/servicectl/local_project_search_ipc_test.go+73-0File content is under review, please try again later
Ainternal/servicectl/local_project_search_review_test.go+104-0File content is under review, please try again later
Ainternal/servicectl/local_project_vector_review_test.go+48-0File content is under review, please try again later
Minternal/servicectl/local_projects.go+1-0File content is under review, please try again later
Minternal/servicectl/local_projects_ipc.go+14-0File content is under review, please try again later
Minternal/servicectl/local_projects_test.go+4-1File content is under review, please try again later