Files
zzet--gortex/internal/indexer/spec_launch_acceptance_test.go
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

426 lines
15 KiB
Go

package indexer
// Workspace-isolation acceptance suite. The five criteria below are
// the alpha-launch exit signal: all five must pass on a fresh local
// setup with two unrelated workspaces (`tuck` and `personal`), each
// defining `POST /api/auth/login`.
//
// The suite uses the same MultiIndexer + ConfigManager wiring real
// users go through, populated with fixture repos under `t.TempDir()`.
// No cloud, no daemon — purely local; all five criteria are
// mechanically testable on a single laptop.
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/contracts"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
"github.com/zzet/gortex/internal/search"
)
// writeWorkspaceYAML drops a `.gortex.yaml` declaring workspace and
// optional project. Callers pass project="" for the
// "single-project repo, project = workspace" default.
func writeWorkspaceYAML(t *testing.T, dir, workspace, project string) {
t.Helper()
body := "workspace: " + workspace + "\n"
if project != "" {
body += "project: " + project + "\n"
}
require.NoError(t, os.WriteFile(filepath.Join(dir, ".gortex.yaml"), []byte(body), 0o644))
}
// setupHTTPLoginProvider writes a Go file with a Gin handler for
// POST /api/auth/login.
func setupHTTPLoginProvider(t *testing.T, name string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), name)
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"),
[]byte("module example.com/"+name+"\n\ngo 1.21\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte(`package main
import "github.com/gin-gonic/gin"
func setupRoutes(r *gin.Engine) {
r.POST("/api/auth/login", login)
}
func login() {}
`), 0o644))
return dir
}
// runMatcher returns the merged-registry MatchResult — the same shape
// `gortex contracts check` produces for the user.
func runMatcher(mi *MultiIndexer) contracts.MatchResult {
return contracts.Match(mi.MergedContractRegistry())
}
// TestSpecLaunch_4_5_OrphansAreReportedPerWorkspace covers criterion 1:
// "gortex contracts check for tuck reports an orphan provider in tuck
// (consumer not present), and similarly for personal."
//
// Both workspaces ship a provider but no consumer; the matcher must
// report each workspace's provider as orphaned, and the orphan's
// WorkspaceID must be set so a `--workspace tuck` filter can isolate
// the right list.
func TestSpecLaunch_4_5_OrphansAreReportedPerWorkspace(t *testing.T) {
tuckRoot := setupHTTPLoginProvider(t, "tuck-api")
personalRoot := setupHTTPLoginProvider(t, "personal-app")
writeWorkspaceYAML(t, tuckRoot, "tuck", "")
writeWorkspaceYAML(t, personalRoot, "personal", "")
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: tuckRoot, Name: "tuck-api"},
{Path: personalRoot, Name: "personal-app"},
},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
for _, entry := range cm.Global().Repos {
_, err := mi.TrackRepoCtx(context.Background(), entry)
require.NoError(t, err, "track %s", entry.Name)
}
result := runMatcher(mi)
// No matches — there are no consumers anywhere.
assert.Empty(t, result.Matched, "no consumers exist; matcher must produce zero matches")
// Both providers must be orphaned, each carrying its own
// workspace slug so a UI / CLI filter can isolate them.
tuckSeen, personalSeen := false, false
for _, op := range result.OrphanProviders {
switch op.WorkspaceID {
case "tuck":
tuckSeen = true
case "personal":
personalSeen = true
}
}
assert.True(t, tuckSeen, "tuck provider must appear as an orphan with WorkspaceID=tuck")
assert.True(t, personalSeen, "personal provider must appear as an orphan with WorkspaceID=personal")
}
// TestSpecLaunch_4_5_NoCrossWorkspacePairs covers criterion 2:
// "the two services do not appear paired with each other in either
// contracts list or contracts check. Cross-workspace traversal on
// the matcher is impossible by construction."
//
// Both workspaces have BOTH a provider and a consumer for
// POST /api/auth/login. Without workspace bucketing the matcher
// would have produced 4 cross-workspace pairs. With boundary
// enforcement we expect exactly 2 pairs, one per workspace, neither
// crossing the workspace line.
func TestSpecLaunch_4_5_NoCrossWorkspacePairs(t *testing.T) {
tuckRoot := setupHTTPLoginProvider(t, "tuck-api")
tuckConsumerRoot := setupHTTPLoginConsumer(t, "tuck-app")
personalRoot := setupHTTPLoginProvider(t, "personal-app")
personalConsumerRoot := setupHTTPLoginConsumer(t, "personal-cli")
for _, dir := range []string{tuckRoot, tuckConsumerRoot} {
writeWorkspaceYAML(t, dir, "tuck", "tuck")
}
for _, dir := range []string{personalRoot, personalConsumerRoot} {
writeWorkspaceYAML(t, dir, "personal", "personal")
}
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: tuckRoot, Name: "tuck-api"},
{Path: tuckConsumerRoot, Name: "tuck-app"},
{Path: personalRoot, Name: "personal-app"},
{Path: personalConsumerRoot, Name: "personal-cli"},
},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
for _, entry := range cm.Global().Repos {
_, err := mi.TrackRepoCtx(context.Background(), entry)
require.NoError(t, err, "track %s", entry.Name)
}
result := runMatcher(mi)
require.Len(t, result.Matched, 2,
"expected exactly 2 matches (tuck↔tuck, personal↔personal); got %d", len(result.Matched))
for _, m := range result.Matched {
assert.Equal(t, m.Provider.WorkspaceID, m.Consumer.WorkspaceID,
"matched pair must share a workspace; got provider=%s consumer=%s",
m.Provider.WorkspaceID, m.Consumer.WorkspaceID)
}
}
// TestSpecLaunch_4_5_FindUsagesScopedToWorkspace covers criterion 3:
// "find_usages on a tuck symbol returns hits only from tuck."
//
// Setup: tuck has a function `tuckSharedHelper` and a caller in the
// same workspace. We run FindUsagesScoped with WorkspaceID="tuck"
// and require every returned node belong to that workspace.
func TestSpecLaunch_4_5_FindUsagesScopedToWorkspace(t *testing.T) {
tuckRoot := setupGoCallerCallee(t, "tuck-api")
personalRoot := setupGoCallerCallee(t, "personal-app")
writeWorkspaceYAML(t, tuckRoot, "tuck", "")
writeWorkspaceYAML(t, personalRoot, "personal", "")
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: tuckRoot, Name: "tuck-api"},
{Path: personalRoot, Name: "personal-app"},
},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
for _, entry := range cm.Global().Repos {
_, err := mi.TrackRepoCtx(context.Background(), entry)
require.NoError(t, err, "track %s", entry.Name)
}
eng := query.NewEngine(g)
// Look up the tuck-side callee. Both repos use the same name —
// criterion 3's whole point is that the lookup stays in the
// tuck workspace despite the personal repo defining the same
// symbol.
tuckHelperID := "tuck-api/main.go::sharedHelper"
require.NotNil(t, g.GetNode(tuckHelperID), "fixture: expected node %s", tuckHelperID)
scoped := eng.FindUsagesScoped(tuckHelperID, query.QueryOptions{WorkspaceID: "tuck"})
require.NotNil(t, scoped)
for _, n := range scoped.Nodes {
ws := n.WorkspaceID
if ws == "" {
ws = n.RepoPrefix
}
assert.Equal(t, "tuck", ws,
"FindUsagesScoped(WorkspaceID=tuck) returned node %s in workspace %q",
n.ID, ws)
}
}
// TestSpecLaunch_4_5_CrossWorkspaceHappyPath covers criterion 4:
// "a workspace declaring cross_workspace_deps against gortex
// correctly resolves stubs into gortex when the user passes
// scope: workspace:gortex."
//
// We declare workspace `client-ws` with a cross_workspace_deps entry
// pointing at workspace `lib-ws`, then verify the resolver does NOT
// flag the cross-workspace edge as unresolved when the dep is
// declared. This is the inverse of TestCrossWorkspaceContractMatching:
// that test confirms the boundary blocks; this one confirms the
// declared exception lets the reference through.
//
// The full happy-path acceptance ("scope: workspace:gortex" resolves
// stubs into gortex) is covered by the resolver-level test in
// internal/resolver/cross_repo_test.go — this test pins the wire-up
// at the indexer level so a regression in the lookup plumbing
// surfaces here.
func TestSpecLaunch_4_5_CrossWorkspaceHappyPath(t *testing.T) {
libRoot := setupGoLibraryRepo(t, "lib-svc", "lib-mod")
clientRoot := setupGoLibraryConsumer(t, "client-svc", "lib-mod")
writeWorkspaceYAML(t, libRoot, "lib-ws", "")
require.NoError(t, os.WriteFile(filepath.Join(clientRoot, ".gortex.yaml"), []byte(
"workspace: client-ws\n"+
"cross_workspace_deps:\n"+
" - workspace: lib-ws\n"+
" modules: [\"example.com/lib-mod\"]\n"+
" mode: read-only\n",
), 0o644))
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: libRoot, Name: "lib-svc"},
{Path: clientRoot, Name: "client-svc"},
},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newMultiLangRegistry(), search.NewBM25(), cm, zap.NewNop())
for _, entry := range cm.Global().Repos {
_, err := mi.TrackRepoCtx(context.Background(), entry)
require.NoError(t, err, "track %s", entry.Name)
}
// The wire-up itself is what we care about. The
// crossWorkspaceLookup MultiIndexer method should return a
// non-empty rule list for `client-ws`, mirroring the
// `cross_workspace_deps` declaration in client-svc/.gortex.yaml.
rules := mi.crossWorkspaceLookup()("client-ws")
require.NotEmpty(t, rules,
"client-ws declared cross_workspace_deps but the indexer's lookup returned no rules; "+
"verify LoadWorkspaceConfig reads the file and CrossWorkspaceDeps survives Config.Validate")
require.Equal(t, "lib-ws", rules[0].Workspace, "rule must target lib-ws")
assert.Contains(t, rules[0].Modules, "example.com/lib-mod")
// The same lookup with a non-declaring source must be empty.
assert.Empty(t, mi.crossWorkspaceLookup()("lib-ws"),
"lib-ws didn't declare any cross_workspace_deps; lookup must be empty")
}
// TestSpecLaunch_4_5_MonorepoProjectBoundary covers criterion 5:
// "in a monorepo declaring services/api and services/worker as
// separate projects, contracts in api and worker are extracted
// independently and not paired unless explicitly declared."
//
// Iteration 1 simplification: we model the monorepo as two repos in
// one shared workspace with distinct projects (since per-file
// projects[]-glob mapping lands later). The matcher's project
// boundary should still hold: api's provider and worker's consumer
// of the same identifier do NOT pair.
func TestSpecLaunch_4_5_MonorepoProjectBoundary(t *testing.T) {
apiRoot := setupHTTPLoginProvider(t, "monorepo-api")
workerRoot := setupHTTPLoginConsumer(t, "monorepo-worker")
writeWorkspaceYAML(t, apiRoot, "monorepo", "api")
writeWorkspaceYAML(t, workerRoot, "monorepo", "worker")
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: apiRoot, Name: "monorepo-api"},
{Path: workerRoot, Name: "monorepo-worker"},
},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
for _, entry := range cm.Global().Repos {
_, err := mi.TrackRepoCtx(context.Background(), entry)
require.NoError(t, err, "track %s", entry.Name)
}
result := runMatcher(mi)
// Same workspace ("monorepo") but different projects ("api" /
// "worker") — Match must NOT pair. Both sides become orphans.
for _, m := range result.Matched {
// If there's any pair, both ends must share a project. The
// fixture only has cross-project pairs, so any match would
// be a violation.
assert.Equal(t, m.Provider.ProjectID, m.Consumer.ProjectID,
"cross-project pair must not be matched: provider.project=%s consumer.project=%s",
m.Provider.ProjectID, m.Consumer.ProjectID)
}
// Both provider and consumer should appear as orphans because
// they're in different projects.
assert.NotEmpty(t, result.OrphanProviders, "api provider must be orphaned (worker is in a different project)")
assert.NotEmpty(t, result.OrphanConsumers, "worker consumer must be orphaned (api is in a different project)")
}
// setupHTTPLoginConsumer writes a Go file with an http.Post to the
// login endpoint.
func setupHTTPLoginConsumer(t *testing.T, name string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), name)
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"),
[]byte("module example.com/"+name+"\n\ngo 1.21\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "client.go"), []byte(`package main
import (
"net/http"
"strings"
)
func doLogin() {
http.Post("http://api.example.com/api/auth/login", "application/json", strings.NewReader(""))
}
`), 0o644))
return dir
}
// setupGoCallerCallee writes a Go file with `caller` -> `sharedHelper`.
// Both functions live in the same file under the repo's root.
func setupGoCallerCallee(t *testing.T, name string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), name)
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"),
[]byte("module example.com/"+name+"\n\ngo 1.21\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte(`package main
func sharedHelper() string { return "ok" }
func caller() string { return sharedHelper() }
`), 0o644))
return dir
}
// setupGoLibraryRepo writes a Go module that exports a function the
// consumer will call. The module path is example.com/<modName>.
func setupGoLibraryRepo(t *testing.T, name, modName string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), name)
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"),
[]byte("module example.com/"+modName+"\n\ngo 1.21\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "lib.go"), []byte(`package lib
func Greet(name string) string { return "hello " + name }
`), 0o644))
return dir
}
// setupGoLibraryConsumer writes a Go file that imports the library
// module and calls Greet. The import path is example.com/<modName>.
func setupGoLibraryConsumer(t *testing.T, name, modName string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), name)
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"),
[]byte("module example.com/"+name+"\n\ngo 1.21\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte(`package main
import "example.com/`+modName+`"
func main() {
_ = lib.Greet("world")
}
`), 0o644))
return dir
}