Files
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

375 lines
12 KiB
Go

package indexer
import (
"path/filepath"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/contracts"
"github.com/zzet/gortex/internal/pathkey"
"github.com/zzet/gortex/internal/resolver"
)
// resolveWorkspaceID returns the workspace slug for an indexer
// emitting nodes for the repo with the given fallback prefix.
//
// Resolution order (highest priority first):
//
// 1. RepoEntry.Workspace — user-level override in
// `~/.gortex/config.yaml`. Lets users pin OSS / read-only
// repos to a workspace without leaving a `.gortex.yaml` artifact
// in the repo itself, and lets users override a workspace the
// repo author chose (the OSS author's slug shouldn't pollute the
// user's local graph identity).
// 2. `.gortex.yaml::workspace:` — source of truth when the user is
// the same person who owns the repo (typical for first-party /
// monorepo setups).
// 3. Falls back to `fallbackPrefix` (the repo prefix). This is the
// "files without a workspace declaration get workspace =
// repo-name" default.
//
// Empty `fallbackPrefix` returns "" — single-repo callers that don't
// pass a prefix accept that the workspace slug stays empty, in which
// case the boundary checks treat the node as "no workspace declared"
// and degrade to the legacy RepoPrefix-keyed comparison.
func resolveWorkspaceID(entry *config.RepoEntry, cfg *config.Config, fallbackPrefix string) string {
if entry != nil && entry.Workspace != "" {
return entry.Workspace
}
if cfg != nil && cfg.Workspace != "" {
return cfg.Workspace
}
return fallbackPrefix
}
// resolveProjectID returns the project slug for an indexer emitting
// nodes for the repo with the given fallback prefix.
//
// Resolution order mirrors resolveWorkspaceID:
//
// 1. RepoEntry.Project — global config override.
// 2. `.gortex.yaml::project:`.
// 3. Per-file `projects[]` paths-glob lookup is the monorepo case.
// The current implementation stamps a single project slug per
// repo; the per-file refinement is a follow-up.
// 4. Falls back to `fallbackPrefix` (the repo prefix). Same
// "missing → repo-name" rule as workspace.
func resolveProjectID(entry *config.RepoEntry, cfg *config.Config, fallbackPrefix string) string {
if entry != nil && entry.Project != "" {
return entry.Project
}
if cfg != nil && cfg.Project != "" {
return cfg.Project
}
return fallbackPrefix
}
// ScopeForCWD resolves a working directory to the (workspace, project)
// scope of the tracked repo that physically contains it. The longest
// matching repo root wins so a repo nested inside another resolves to
// the inner one.
//
// ok is false when cwd lies outside every tracked repo — callers MUST
// fail closed (return no cross-workspace data) rather than widening to
// the global graph.
//
// A repo whose effective WorkspaceID is empty (no `.gortex.yaml::
// workspace:` and no global-config override) is treated as its own
// singleton workspace keyed on the repo prefix — the same fallback
// rule resolveWorkspaceID and QueryOptions.scopeAllows use, so the
// session boundary stays consistent with the node stamps.
func (mi *MultiIndexer) ScopeForCWD(cwd string) (workspaceID, projectID, repoPrefix string, ok bool) {
if mi == nil || cwd == "" {
return "", "", "", false
}
cwd = filepath.Clean(cwd)
mi.mu.RLock()
defer mi.mu.RUnlock()
var bestRoot, bestPrefix string
for prefix, meta := range mi.repos {
if meta == nil {
continue
}
root := filepath.Clean(meta.RootPath)
if root == "" || root == "." {
continue
}
if pathkey.HasPathPrefix(cwd, root) {
if len(root) > len(bestRoot) {
bestRoot, bestPrefix = root, prefix
}
}
}
if bestPrefix == "" {
return "", "", "", false
}
ws, proj := bestPrefix, bestPrefix
if idx := mi.indexers[bestPrefix]; idx != nil {
if v := idx.WorkspaceID(); v != "" {
ws = v
}
if v := idx.ProjectID(); v != "" {
proj = v
}
}
return ws, proj, bestPrefix, true
}
// ReposInWorkspace returns the set of repo prefixes whose effective
// workspace slug equals workspaceID. It is the complete list of repos
// a workspace-scoped session is permitted to see — used to bound the
// query surface for handlers that filter by repo prefix rather than
// routing through the engine's WorkspaceID-aware traversal.
//
// The effective slug follows the same singleton fallback as
// ScopeForCWD: a repo with no declared workspace is its own workspace
// keyed on the repo prefix.
func (mi *MultiIndexer) ReposInWorkspace(workspaceID string) map[string]bool {
out := make(map[string]bool)
if mi == nil || workspaceID == "" {
return out
}
mi.mu.RLock()
defer mi.mu.RUnlock()
for prefix := range mi.repos {
ws := prefix
if idx := mi.indexers[prefix]; idx != nil {
if v := idx.WorkspaceID(); v != "" {
ws = v
}
}
if ws == workspaceID {
out[prefix] = true
}
}
return out
}
// BackfillWorkspaceSlugs walks every node and contract attached to
// the MultiIndexer's tracked repos and stamps WorkspaceID / ProjectID
// from the per-repo `.gortex.yaml` whenever those fields are empty.
//
// This closes the upgrade gap: a snapshot written by a daemon
// before WorkspaceID existed has WorkspaceID="" everywhere; gob
// decodes additive fields as zero. Without backfill, EffectiveWorkspace
// falls back to RepoPrefix and explicit shared-workspace setups
// (multiple repos declaring `workspace: shared`) silently lose
// identity until every file is touched. This pass re-stamps them
// once at warmup.
//
// Returns the count of nodes and contracts updated for telemetry.
// Idempotent: re-running on an already-stamped graph is a no-op.
func (mi *MultiIndexer) BackfillWorkspaceSlugs() (nodesStamped, contractsStamped int) {
if mi == nil || mi.graph == nil || mi.configMgr == nil {
return 0, 0
}
mi.mu.RLock()
repoMeta := make(map[string]string, len(mi.repos))
for prefix, meta := range mi.repos {
if meta != nil {
repoMeta[prefix] = meta.RootPath
}
}
mi.mu.RUnlock()
type slugs struct{ ws, proj string }
bySlug := make(map[string]slugs, len(repoMeta))
// Map each repo prefix to its global-config RepoEntry so we honour
// the user-level workspace/project override even on backfill.
entryByPrefix := make(map[string]config.RepoEntry, len(repoMeta))
if mi.configMgr.Global() != nil {
for _, e := range mi.configMgr.Global().Repos {
p := config.ResolvePrefix(e)
if p == "" || p == "." {
continue
}
entryByPrefix[p] = e
}
}
for prefix, root := range repoMeta {
// Make sure the per-repo `.gortex.yaml` is loaded — at warmup
// time TrackRepoCtx/ReconcileRepoCtx already calls this, but
// run defensively in case BackfillWorkspaceSlugs is invoked
// on a path that didn't.
mi.configMgr.LoadWorkspaceConfig(prefix, root)
cfg := mi.configMgr.GetRepoConfig(prefix)
var entryPtr *config.RepoEntry
if e, ok := entryByPrefix[prefix]; ok {
entryCopy := e
entryPtr = &entryCopy
}
bySlug[prefix] = slugs{
ws: resolveWorkspaceID(entryPtr, cfg, prefix),
proj: resolveProjectID(entryPtr, cfg, prefix),
}
}
for _, n := range mi.graph.AllNodes() {
s, ok := bySlug[n.RepoPrefix]
if !ok {
continue
}
stamped := false
if n.WorkspaceID == "" && s.ws != "" {
n.WorkspaceID = s.ws
stamped = true
}
if n.ProjectID == "" && s.proj != "" {
n.ProjectID = s.proj
stamped = true
}
if stamped {
nodesStamped++
}
}
// Per-repo contract registries: rehydrated from snapshot they
// carry RepoPrefix but no Workspace/Project slugs.
mi.mu.RLock()
indexers := make(map[string]*Indexer, len(mi.indexers))
for k, v := range mi.indexers {
indexers[k] = v
}
mi.mu.RUnlock()
for prefix, idx := range indexers {
s, ok := bySlug[prefix]
if !ok {
continue
}
reg := idx.ContractRegistry()
if reg == nil {
continue
}
all := reg.All()
fresh := make([]contracts.Contract, 0, len(all))
dirty := false
for _, c := range all {
if (c.WorkspaceID == "" && s.ws != "") || (c.ProjectID == "" && s.proj != "") {
if c.WorkspaceID == "" {
c.WorkspaceID = s.ws
}
if c.ProjectID == "" {
c.ProjectID = s.proj
}
dirty = true
contractsStamped++
}
fresh = append(fresh, c)
}
if dirty {
reg.Clear()
for _, c := range fresh {
reg.Add(c)
}
}
}
return nodesStamped, contractsStamped
}
// RunGlobalResolve runs a cross-repo + cross-workspace resolution
// pass over the merged graph, then reconciles contract bridge edges.
// Used post-warmup (after BackfillWorkspaceSlugs) and any other time
// the daemon needs to refresh cross-repo edges without re-indexing
// every file. Idempotent — safe to call repeatedly.
func (mi *MultiIndexer) RunGlobalResolve() {
mi.runCrossRepoResolve(true)
}
// runCrossRepoResolve runs the cross-repo resolver over the shared graph,
// lifting references that span repo boundaries. When reconcileContracts is
// true it also reconciles contract-bridge edges; the pre-enrichment resolve
// stage (RunPreEnrichResolve) passes false because the contract pass has not
// committed its nodes yet.
//
// The pass is deliberately whole-graph. Unlike the same-repo master resolve
// (which the warm restart scopes to the changed repos), the cross-repo pass
// cannot be scoped to the changed repos' own out-edges: an INBOUND reference
// from an unchanged repo into a symbol a changed repo just added is owned by
// the unchanged repo, so a changed-repo-scoped edge set never contains it, and
// a bare unresolved name gives no hint which repo it will bind into. Resolving
// only the changed repos' out-edges here left those inbound edges unbound until
// the post-enrichment global resolve, so cross-repo queries returned incomplete
// results for the whole enrichment window even though the daemon reported ready.
func (mi *MultiIndexer) runCrossRepoResolve(reconcileContracts bool) {
if mi == nil || mi.graph == nil {
return
}
cr := resolver.NewCrossRepo(mi.graph)
cr.SetLogger(mi.logger)
cr.SetCrossWorkspaceDepLookup(mi.crossWorkspaceLookup())
cr.SetNpmAliasResolver(mi.npmAliasResolver())
cr.SetPathAliasResolver(mi.pathAliasResolver())
cr.SetWorkspaceMembership(mi.workspaceMembershipResolver())
mi.applyRemoteStitch(cr)
cr.ResolveAll()
if reconcileContracts {
mi.ReconcileContractEdges()
}
}
// crossWorkspaceLookup builds a resolver.CrossWorkspaceDepLookup from
// the MultiIndexer's per-repo configs. The closure captures `mi` so a
// post-construction config reload (`Reload` on the ConfigManager) is
// picked up automatically — each call walks the current per-repo
// configs and aggregates declarations whose source workspace matches.
//
// Why iterate per-repo: the schema places `cross_workspace_deps`
// inside a repo's `.gortex.yaml`, keyed implicitly to that repo's
// `workspace`. Two repos can both declare workspace = "tuck" with
// overlapping but possibly extended dep lists; the union forms the
// effective rule set for source workspace "tuck".
func (mi *MultiIndexer) crossWorkspaceLookup() resolver.CrossWorkspaceDepLookup {
return func(sourceWS string) []resolver.CrossWorkspaceDepRule {
if mi == nil || mi.configMgr == nil {
return nil
}
var rules []resolver.CrossWorkspaceDepRule
mi.mu.RLock()
repoPrefixes := make([]string, 0, len(mi.repos))
for prefix := range mi.repos {
repoPrefixes = append(repoPrefixes, prefix)
}
mi.mu.RUnlock()
// Per-repo crossWorkspaceLookup needs the same precedence as the
// stamp path: a global-config Workspace override changes which
// repos count as belonging to `sourceWS`.
entryByPrefix := make(map[string]config.RepoEntry)
if mi.configMgr.Global() != nil {
for _, e := range mi.configMgr.Global().Repos {
p := config.ResolvePrefix(e)
if p == "" || p == "." {
continue
}
entryByPrefix[p] = e
}
}
for _, prefix := range repoPrefixes {
cfg := mi.configMgr.GetRepoConfig(prefix)
if cfg == nil {
continue
}
var entryPtr *config.RepoEntry
if e, ok := entryByPrefix[prefix]; ok {
entryCopy := e
entryPtr = &entryCopy
}
repoWS := resolveWorkspaceID(entryPtr, cfg, prefix)
if repoWS != sourceWS {
continue
}
for _, dep := range cfg.CrossWorkspaceDeps {
if dep.Workspace == "" {
continue
}
rules = append(rules, resolver.CrossWorkspaceDepRule{
Workspace: dep.Workspace,
Modules: append([]string(nil), dep.Modules...),
})
}
}
return rules
}
}