diff --git a/dixhttp/README.md b/dixhttp/README.md index b790c52..9b0e2a1 100644 --- a/dixhttp/README.md +++ b/dixhttp/README.md @@ -345,6 +345,18 @@ If `DIX_DIAG_FILE` is not set, response returns `enabled=false` and empty record } ``` +### GET `/api/search?q=xxx&kind=type|provider|object&module=pkg-prefix&state=instantiated|error|slow&limit=50` + +Server-side search over the dependency graph (case-insensitive contains on type/function name). `state=instantiated` filters types that already have objects; `error`/`slow` filter providers whose last execution failed or exceeded the slow threshold. + +### GET `/api/modules` + +Module-level aggregation: per-package node counts plus cross-package dependency lists (`depends_on`). The default building block for the drill-down module view. + +### GET `/api/ego?center=&depth=2&direction=both` + +Neighborhood subgraph centered on one type: `depth`-hop BFS (max 10) over declared dependencies; `direction` = `deps` / `dependents` / `both`. Returns `{nodes, edges}`. + ### GET `/api/trace-tree?trace_id=xxx` Returns the nested call tree for one trace (`inject` → `inject.param` → `resolve.*` → `provider.*`), assembled server-side from the in-memory sink. `404`-style empty result (`total: 0`) when the trace id is unknown (evicted or never existed). diff --git a/dixhttp/server.go b/dixhttp/server.go index 3bb4755..70fc238 100644 --- a/dixhttp/server.go +++ b/dixhttp/server.go @@ -148,6 +148,9 @@ func (s *Server) setupRoutes() { s.mux.HandleFunc(base+"/api/diagnostics", s.HandleDiagnostics) s.mux.HandleFunc(base+"/api/trace", s.HandleTrace) s.mux.HandleFunc(base+"/api/trace-tree", s.HandleTraceTree) + s.mux.HandleFunc(base+"/api/search", s.HandleSearch) + s.mux.HandleFunc(base+"/api/modules", s.HandleModules) + s.mux.HandleFunc(base+"/api/ego", s.HandleEgo) s.mux.HandleFunc(base+"/api/packages", s.HandlePackages) s.mux.HandleFunc(base+"/api/package/", s.HandlePackageDetails) s.mux.HandleFunc(base+"/api/type/", s.HandleTypeDetails) @@ -255,6 +258,50 @@ func (s *Server) HandleTraceTree(w http.ResponseWriter, r *http.Request) { writeJSON(w, s.dix.TraceTree(traceID)) } +// HandleSearch 检索图节点。 +// Query params: +// - q: 关键字(类型名/函数名包含匹配) +// - kind: type|provider|object +// - module: pkg 前缀 +// - state: instantiated|error|slow +// - limit: 缺省 50,上限 500 +func (s *Server) HandleSearch(w http.ResponseWriter, r *http.Request) { + hits := s.dix.SearchNodes( + r.URL.Query().Get("q"), + r.URL.Query().Get("kind"), + r.URL.Query().Get("module"), + r.URL.Query().Get("state"), + atoiOr(r.URL.Query().Get("limit"), 50), + ) + writeJSON(w, hits) +} + +// HandleModules 返回模块级聚合视图(含跨模块依赖)。 +func (s *Server) HandleModules(w http.ResponseWriter, r *http.Request) { + writeJSON(w, s.dix.ModuleGraph()) +} + +// HandleEgo 返回以 center 为中心的 N 跳邻域子图。 +// Query params: +// - center: 类型 label(必填) +// - depth: 缺省 2,上限 10 +// - direction: both|deps|dependents,缺省 both +func (s *Server) HandleEgo(w http.ResponseWriter, r *http.Request) { + center := strings.TrimSpace(r.URL.Query().Get("center")) + if center == "" { + http.Error(w, "center required", http.StatusBadRequest) + return + } + writeJSON(w, s.dix.EgoGraph(center, atoiOr(r.URL.Query().Get("depth"), 2), r.URL.Query().Get("direction"))) +} + +func atoiOr(s string, def int) int { + if v, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && v > 0 { + return v + } + return def +} + // ServeHTTP implements http.Handler interface func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) @@ -276,6 +323,7 @@ func (s *Server) HandleIndex(w http.ResponseWriter, r *http.Request) { // HandleStats returns summary statistics func (s *Server) HandleStats(w http.ResponseWriter, r *http.Request) { providerDetails, objects := s.cachedGraphInputs() + modules := s.dix.ModuleGraph() // Count objects objectCount := 0 @@ -300,11 +348,22 @@ func (s *Server) HandleStats(w http.ResponseWriter, r *http.Request) { edgeCount += len(detail.InputTypes) } + slow, errored := s.dix.ProblemProviders() + top := s.dix.ResolvedTopN(10) + top2 := make([]ResolvedCount2, 0, len(top)) + for _, rc := range top { + top2 = append(top2, ResolvedCount2{Type: rc.Type, Count: rc.Count}) + } + stats := StatsData{ - ProviderCount: len(providerDetails), - ObjectCount: objectCount, - PackageCount: len(packages), - EdgeCount: edgeCount, + ProviderCount: len(providerDetails), + ObjectCount: objectCount, + PackageCount: len(packages), + EdgeCount: edgeCount, + Modules: len(modules), + TopResolved: top2, + SlowProviders: slow, + ErrorProviders: errored, } writeJSON(w, stats) @@ -748,6 +807,18 @@ type StatsData struct { ObjectCount int `json:"object_count"` PackageCount int `json:"package_count"` EdgeCount int `json:"edge_count"` + + // 概览增强(P4a):模块数、解析热度 TopN、慢/错误 provider + Modules int `json:"modules"` + TopResolved []ResolvedCount2 `json:"top_resolved,omitempty"` + SlowProviders []string `json:"slow_providers,omitempty"` + ErrorProviders []string `json:"error_providers,omitempty"` +} + +// ResolvedCount2 是 dixhttp 对内部分析计数类型的投影。 +type ResolvedCount2 struct { + Type string `json:"type"` + Count int64 `json:"count"` } // PackageInfo contains information about a package diff --git a/dixhttp/server_api_test.go b/dixhttp/server_api_test.go index 85bfdf2..35c5ca4 100644 --- a/dixhttp/server_api_test.go +++ b/dixhttp/server_api_test.go @@ -251,3 +251,52 @@ func TestHandleTraceTree(t *testing.T) { t.Fatalf("missing trace_id should 400, got %d", rr.Code) } } + +// /api/search、/api/modules、/api/ego 契约;stats 概览增字段。 +func TestHandleSearchModulesEgo(t *testing.T) { + di := dixinternal.New() + di.Provide(func() *apiStatsDep { return &apiStatsDep{} }) + if err := di.TryInject(func(*apiStatsDep) {}); err != nil { + t.Fatalf("inject: %v", err) + } + server := NewServer(di) + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/search?q=apistats&kind=type", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("search status %d", rr.Code) + } + var hits []dixinternal.SearchHit + if err := json.Unmarshal(rr.Body.Bytes(), &hits); err != nil || len(hits) == 0 { + t.Fatalf("search hits = %v err = %v", hits, err) + } + + rr = httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/modules", nil)) + var modules []dixinternal.ModuleInfo + if err := json.Unmarshal(rr.Body.Bytes(), &modules); err != nil || len(modules) == 0 { + t.Fatalf("modules = %v err = %v", modules, err) + } + + rr = httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/ego", nil)) + if rr.Code != http.StatusBadRequest { + t.Fatalf("ego without center should 400, got %d", rr.Code) + } + rr = httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/ego?center=*dixinternal.apiStatsDep&depth=1&direction=deps", nil)) + var view dixinternal.GraphView + if err := json.Unmarshal(rr.Body.Bytes(), &view); err != nil { + t.Fatalf("decode ego: %v", err) + } + + rr = httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/stats", nil)) + var stats StatsData + if err := json.Unmarshal(rr.Body.Bytes(), &stats); err != nil { + t.Fatalf("decode stats: %v", err) + } + if stats.Modules == 0 || stats.TopResolved == nil { + t.Fatalf("stats upgrade missing: %+v", stats) + } +} diff --git a/dixinternal/graph_query.go b/dixinternal/graph_query.go new file mode 100644 index 0000000..963cdca --- /dev/null +++ b/dixinternal/graph_query.go @@ -0,0 +1,353 @@ +package dixinternal + +import ( + "reflect" + "sort" + "strings" +) + +// graph_query.go 提供面向可视化/检索的容器级查询: +// 检索(SearchNodes)、模块聚合(ModuleGraph)、邻域子图(EgoGraph)、 +// 解析热度(ResolvedTopN)与问题 provider(ProblemProviders)。 +// 全部为只读投影,需同时读 Graph 与 providerStats,故放在 Dix 上而非 Graph 上。 + +// SearchHit 是检索命中的节点摘要。 +type SearchHit struct { + ID uint32 `json:"id"` + Kind string `json:"kind"` // type|provider|object + Label string `json:"label"` + Pkg string `json:"pkg,omitempty"` + Group string `json:"group,omitempty"` + State string `json:"state,omitempty"` // instantiated|error|slow + Provider string `json:"provider,omitempty"` +} + +func nodeKindName(k NodeKind) string { + switch k { + case NodeType: + return "type" + case NodeProvider: + return "provider" + case NodeObject: + return "object" + } + return "unknown" +} + +// SearchNodes 按关键字/类别/模块前缀/运行时状态过滤图节点。 +func (dix *Dix) SearchNodes(q, kind, module, state string, limit int) []SearchHit { + if limit <= 0 { + limit = 50 + } + if limit > 500 { + limit = 500 + } + qLower := strings.ToLower(strings.TrimSpace(q)) + kind = strings.ToLower(strings.TrimSpace(kind)) + state = strings.ToLower(strings.TrimSpace(state)) + module = strings.TrimSpace(module) + + g := dix.graph + g.mu.RLock() + defer g.mu.RUnlock() + + // 已实例化类型集合:存在 Object 节点 (type, group) 即视为实例化 + instantiated := make(map[reflect.Type]bool, len(g.nIndex)) + for k := range g.nIndex { + if k.kind == NodeObject { + instantiated[k.typ] = true + } + } + + hits := make([]SearchHit, 0, 16) + for _, n := range g.nodes { + kindName := nodeKindName(n.Kind) + if kind != "" && kindName != kind { + continue + } + if module != "" && !strings.HasPrefix(n.Pkg, module) { + continue + } + if qLower != "" { + hay := strings.ToLower(n.Label) + if n.Provider != nil { + hay += " " + strings.ToLower(GetFnName(n.Provider.fn)) + } + if !strings.Contains(hay, qLower) { + continue + } + } + + hit := SearchHit{ID: uint32(n.ID), Kind: kindName, Label: n.Label, Pkg: n.Pkg, Group: n.Group} + if n.Provider != nil { + hit.Provider = GetFnName(n.Provider.fn) + } + switch { + case n.Kind == NodeType && instantiated[n.Type]: + hit.State = "instantiated" + case n.Kind == NodeProvider: + hit.State = dix.providerState(n.Provider) + } + if state != "" && hit.State != state { + continue + } + + hits = append(hits, hit) + if len(hits) >= limit { + break + } + } + return hits +} + +func (dix *Dix) providerState(p *providerFn) string { + if p == nil { + return "" + } + stat, ok := dix.providerStats[p.fn] + if !ok { + return "" + } + if stat.LastError != "" { + return "error" + } + if dix.option.SlowProviderThreshold > 0 && stat.LastDuration > dix.option.SlowProviderThreshold { + return "slow" + } + return "" +} + +// ModuleInfo 是模块(pkg)级聚合视图的一行。 +type ModuleInfo struct { + Name string `json:"name"` + TypeCount int `json:"type_count"` + ProviderCount int `json:"provider_count"` + ObjectCount int `json:"object_count"` + DependsOn []string `json:"depends_on,omitempty"` +} + +// ModuleGraph 按模块聚合节点,并从声明边提取跨模块依赖(去重、排序)。 +func (dix *Dix) ModuleGraph() []ModuleInfo { + g := dix.graph + g.mu.RLock() + defer g.mu.RUnlock() + + pkgOf := func(n Node) string { + if n.Pkg == "" { + return "(anonymous)" + } + return n.Pkg + } + + byModule := make(map[string]*ModuleInfo) + order := make([]string, 0, 8) + get := func(name string) *ModuleInfo { + mi, ok := byModule[name] + if !ok { + mi = &ModuleInfo{Name: name} + byModule[name] = mi + order = append(order, name) + } + return mi + } + for _, n := range g.nodes { + mi := get(pkgOf(n)) + switch n.Kind { + case NodeType: + mi.TypeCount++ + case NodeProvider: + mi.ProviderCount++ + case NodeObject: + mi.ObjectCount++ + } + } + + containsStr := func(list []string, v string) bool { + for _, s := range list { + if s == v { + return true + } + } + return false + } + for _, e := range g.eIndex { + if e.Kind != EdgeDeclared { + continue + } + fromPkg := pkgOf(g.nodes[e.From]) + toPkg := pkgOf(g.nodes[e.To]) + if fromPkg == toPkg { + continue + } + mi := get(fromPkg) + if !containsStr(mi.DependsOn, toPkg) { + mi.DependsOn = append(mi.DependsOn, toPkg) + } + } + + out := make([]ModuleInfo, 0, len(order)) + for _, name := range order { + mi := byModule[name] + sort.Strings(mi.DependsOn) + out = append(out, *mi) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// GraphEdge 是邻域子图里的一条声明依赖边(类型 label 表示)。 +type GraphEdge struct { + From string `json:"from"` + To string `json:"to"` +} + +// GraphView 是邻域子图:节点摘要 + 声明边。 +type GraphView struct { + Nodes []SearchHit `json:"nodes"` + Edges []GraphEdge `json:"edges"` +} + +// EgoGraph 以 center 类型为中心,沿声明边做 depth 跳 BFS: +// direction=deps 只看依赖方向,dependents 只看被依赖方向,both 双向。 +func (dix *Dix) EgoGraph(center string, depth int, direction string) GraphView { + if depth <= 0 { + depth = 2 + } + if depth > 10 { + depth = 10 + } + switch direction { + case "deps", "dependents", "both": + default: + direction = "both" + } + + g := dix.graph + g.mu.RLock() + + type dEdge struct { + from, to reflect.Type + } + var edges []dEdge + deps := make(map[reflect.Type][]reflect.Type) + dependents := make(map[reflect.Type][]reflect.Type) + var centerType reflect.Type + for _, n := range g.nodes { + if n.Kind == NodeType && n.Label == center { + centerType = n.Type + break + } + } + if centerType == nil { + g.mu.RUnlock() + return GraphView{Nodes: []SearchHit{}, Edges: []GraphEdge{}} + } + for _, e := range g.eIndex { + if e.Kind != EdgeDeclared { + continue + } + from, to := g.nodes[e.From].Type, g.nodes[e.To].Type + edges = append(edges, dEdge{from: from, to: to}) + deps[from] = append(deps[from], to) + dependents[to] = append(dependents[to], from) + } + g.mu.RUnlock() + + seen := map[reflect.Type]bool{centerType: true} + frontier := []reflect.Type{centerType} + for d := 0; d < depth && len(frontier) > 0; d++ { + var next []reflect.Type + for _, t := range frontier { + if direction == "deps" || direction == "both" { + next = append(next, deps[t]...) + } + if direction == "dependents" || direction == "both" { + next = append(next, dependents[t]...) + } + } + frontier2 := frontier[:0] + for _, t := range next { + if !seen[t] { + seen[t] = true + frontier2 = append(frontier2, t) + } + } + frontier = frontier2 + } + + label := func(t reflect.Type) string { return t.String() } + view := GraphView{Nodes: []SearchHit{}, Edges: []GraphEdge{}} + for _, e := range edges { + if !seen[e.from] || !seen[e.to] { + continue + } + view.Edges = append(view.Edges, GraphEdge{From: label(e.from), To: label(e.to)}) + } + for t := range seen { + view.Nodes = append(view.Nodes, SearchHit{Kind: "type", Label: label(t), Pkg: resolveTypePkgPath(t), State: "instantiated"}) + } + sort.Slice(view.Nodes, func(i, j int) bool { return view.Nodes[i].Label < view.Nodes[j].Label }) + sort.Slice(view.Edges, func(i, j int) bool { + if view.Edges[i].From != view.Edges[j].From { + return view.Edges[i].From < view.Edges[j].From + } + return view.Edges[i].To < view.Edges[j].To + }) + return view +} + +// ResolvedCount 是类型维度的解析热度。 +type ResolvedCount struct { + Type string `json:"type"` + Count int64 `json:"count"` +} + +// ResolvedTopN 返回解析次数最多的前 n 个类型(降序)。 +func (dix *Dix) ResolvedTopN(n int) []ResolvedCount { + if n <= 0 { + n = 10 + } + g := dix.graph + g.mu.RLock() + defer g.mu.RUnlock() + counts := make([]ResolvedCount, 0, 8) + for _, e := range g.eIndex { + if e.Kind == EdgeResolved && e.Count > 0 { + counts = append(counts, ResolvedCount{Type: g.nodes[e.To].Type.String(), Count: e.Count}) + } + } + sort.Slice(counts, func(i, j int) bool { + if counts[i].Count != counts[j].Count { + return counts[i].Count > counts[j].Count + } + return counts[i].Type < counts[j].Type + }) + if len(counts) > n { + counts = counts[:n] + } + return counts +} + +// ProblemProviders 返回慢 provider 与错误 provider 的函数名(去重、排序)。 +func (dix *Dix) ProblemProviders() (slow, errored []string) { + slowSet := make(map[string]bool) + errSet := make(map[string]bool) + for _, stat := range dix.providerStats { + if stat.LastError != "" { + errSet[stat.FunctionName] = true + continue + } + if dix.option.SlowProviderThreshold > 0 && stat.LastDuration > dix.option.SlowProviderThreshold { + slowSet[stat.FunctionName] = true + } + } + for name := range slowSet { + slow = append(slow, name) + } + for name := range errSet { + errored = append(errored, name) + } + sort.Strings(slow) + sort.Strings(errored) + return slow, errored +} diff --git a/dixinternal/graph_query_test.go b/dixinternal/graph_query_test.go new file mode 100644 index 0000000..56617e3 --- /dev/null +++ b/dixinternal/graph_query_test.go @@ -0,0 +1,157 @@ +package dixinternal + +import ( + "errors" + "strings" + "testing" + "time" +) + +type ( + QAService struct{} + QARepo struct{} + QADepA struct{} + QADepB struct{} + QADepC struct{} + QASlow struct{} + QABroken struct{} +) + +func containsStr(list []string, v string) bool { + for _, s := range list { + if s == v { + return true + } + } + return false +} + +func viewLabels(v GraphView) []string { + out := make([]string, 0, len(v.Nodes)) + for _, n := range v.Nodes { + out = append(out, n.Label) + } + return out +} + +func TestSearchNodesFilters(t *testing.T) { + di := New() + di.Provide(func() *QAService { return &QAService{} }) + di.Provide(func() *QARepo { return &QARepo{} }) + _ = di.TryInject(func(s *QAService, r *QARepo) {}) + + hits := di.SearchNodes("qaservice", "", "", "", 50) + if len(hits) == 0 { + t.Fatal("q filter should match provider fn and type name") + } + + hits = di.SearchNodes("", "provider", "", "", 50) + for _, h := range hits { + if h.Kind != "provider" { + t.Fatalf("kind filter failed: %+v", h) + } + } + + hits = di.SearchNodes("", "type", "", "instantiated", 50) + found := 0 + for _, h := range hits { + if h.Label == "*dixinternal.QAService" || h.Label == "*dixinternal.QARepo" { + found++ + } + } + if found != 2 { + t.Fatalf("instantiated filter found = %d, want 2: %+v", found, hits) + } + + if hits := di.SearchNodes("", "", "", "", 1); len(hits) != 1 { + t.Fatal("limit must be honored") + } +} + +func TestModuleGraphAggregation(t *testing.T) { + di := New() + di.Provide(func(r *QARepo) *QAService { return &QAService{} }) + + modules := di.ModuleGraph() + if len(modules) == 0 { + t.Fatal("module graph should not be empty") + } + var dixModule *ModuleInfo + for i := range modules { + if modules[i].Name == "github.com/pubgo/dix/v2/dixinternal" { + m := modules[i] + dixModule = &m + } + } + if dixModule == nil { + t.Fatalf("dixinternal module missing: %+v", modules) + } + if dixModule.TypeCount == 0 || dixModule.ProviderCount == 0 { + t.Fatalf("module counts wrong: %+v", dixModule) + } + // 同包内声明边不产生跨模块依赖 + if len(dixModule.DependsOn) != 0 { + t.Fatalf("same-package deps should not appear in DependsOn: %+v", dixModule.DependsOn) + } +} + +func TestEgoGraphDepthAndDirection(t *testing.T) { + di := New() + di.Provide(func(*QADepB) *QADepA { return &QADepA{} }) + di.Provide(func(*QADepC) *QADepB { return &QADepB{} }) + + view := di.EgoGraph("*dixinternal.QADepA", 1, "deps") + labels := viewLabels(view) + if !containsStr(labels, "*dixinternal.QADepA") || !containsStr(labels, "*dixinternal.QADepB") || containsStr(labels, "*dixinternal.QADepC") { + t.Fatalf("deps view wrong: %v", labels) + } + + view = di.EgoGraph("*dixinternal.QADepA", 2, "both") + if !containsStr(viewLabels(view), "*dixinternal.QADepC") { + t.Fatalf("both depth=2 should include C: %v", viewLabels(view)) + } + + // dependents 方向:没有类型依赖 A,从 A 出发只含自身;从 C 反向可达 B(1 跳)、A(2 跳) + view = di.EgoGraph("*dixinternal.QADepA", 1, "dependents") + if containsStr(viewLabels(view), "*dixinternal.QADepB") { + t.Fatalf("nothing depends on A: %v", viewLabels(view)) + } + view = di.EgoGraph("*dixinternal.QADepC", 1, "dependents") + if !containsStr(viewLabels(view), "*dixinternal.QADepB") || containsStr(viewLabels(view), "*dixinternal.QADepA") { + t.Fatalf("dependents of C at depth 1 should include only B: %v", viewLabels(view)) + } + view = di.EgoGraph("*dixinternal.QADepC", 2, "dependents") + if !containsStr(viewLabels(view), "*dixinternal.QADepA") { + t.Fatalf("dependents of C at depth 2 should include A: %v", viewLabels(view)) + } + + // 未知 center:空视图 + view = di.EgoGraph("*dixinternal.NoSuchType", 2, "both") + if len(view.Nodes) != 0 || len(view.Edges) != 0 { + t.Fatalf("unknown center should return empty view: %v", view) + } +} + +func TestResolvedTopNAndProblemProviders(t *testing.T) { + di := New(WithSlowProviderThreshold(time.Millisecond)) + di.Provide(func() *QASlow { + time.Sleep(5 * time.Millisecond) + return &QASlow{} + }) + di.Provide(func() (*QABroken, error) { return nil, errors.New("x") }) + _ = di.TryInject(func(s *QASlow) {}) + _ = di.TryInject(func(b *QABroken) {}) + + top := di.ResolvedTopN(5) + if len(top) == 0 || top[0].Type != "*dixinternal.QASlow" || top[0].Count != 1 { + t.Fatalf("top resolved = %+v", top) + } + + slow, broken := di.ProblemProviders() + if len(slow) == 0 || len(broken) == 0 { + t.Fatalf("slow=%v broken=%v", slow, broken) + } + if !strings.Contains(strings.Join(broken, ","), "QABroken") && !strings.Contains(strings.Join(broken, ","), "func") { + t.Fatalf("broken should reference the broken provider function: %v", broken) + } +} diff --git a/docs/superpowers/plans/2026-09-04-p4a-graph-query-apis.md b/docs/superpowers/plans/2026-09-04-p4a-graph-query-apis.md new file mode 100644 index 0000000..fbbbc29 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-p4a-graph-query-apis.md @@ -0,0 +1,209 @@ +# P4a 大规模检索与分层 API 实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 提供服务端检索与分层查询 API——`/api/search`(过滤检索)、`/api/modules`(模块级聚合+跨模块依赖)、`/api/ego`(以任意类型为中心的 N 跳邻域子图),并升级 `/api/stats`(概览:模块数、resolved TopN、慢/错误 provider)。这是 P4b(五视图前端)与渲染 spike 的数据地基。 + +**Architecture:** 查询编排在 dixinternal(需同时读 Graph 与 providerStats,纯 Graph 方法不够):新文件 `dixinternal/graph_query.go`,全部走 `graph.mu.RLock` 只读投影;dixhttp 薄 handler 直接 JSON 化。检索为线性扫描(P1 决策:倒排等 P4b 定义查询形状与规模数据后再升级)。 + +**Tech Stack:** Go 标准库。 + +**Spec:** `docs/superpowers/specs/2026-09-04-graph-trace-redesign-design.md` 第 6 节(6.1/6.2/6.3/6.4 的服务端部分)。 + +## Global Constraints + +- 既有测试零改动通过;新端点纯增量。 +- state 过滤语义:`instantiated`=类型已有对象节点;`error`=provider 最近一次执行出错;`slow`=最近耗时超过容器 SlowProviderThreshold。 +- `depth` 缺省 2、上限 10;`direction` ∈ {both,deps,dependents} 缺省 both;`limit` 缺省 50、上限 500。 +- 每 Task 独立提交,全量 race 绿。 + +--- + +### Task 1: dixinternal 查询层 + +**Files:** +- Create: `dixinternal/graph_query.go` +- Test: `dixinternal/graph_query_test.go` + +**Interfaces(dixhttp 消费):** +- `type SearchHit struct { ID uint32; Kind, Label, Pkg, Group, State, Provider string }`(json tags: id/kind/label/pkg/group/state/provider) +- `(dix *Dix) SearchNodes(q, kind, module, state string, limit int) []SearchHit` — kind ∈ type|provider|object;module 为 pkg 前缀匹配;q 为 label/函数名小写包含 +- `type ModuleInfo struct { Name string; TypeCount, ProviderCount, ObjectCount int; DependsOn []string }`(json: name/type_count/provider_count/object_count/depends_on) +- `(dix *Dix) ModuleGraph() []ModuleInfo`(按名称排序;Declared 边跨模块时计入 DependsOn,去重) +- `type GraphView struct { Nodes []SearchHit; Edges []GraphEdge }`;`type GraphEdge struct { From, To string }`(类型 label) +- `(dix *Dix) EgoGraph(center string, depth int, direction string) GraphView` — center 为类型 Label;BFS 声明边;边集为邻域内实际声明的边 +- `(dix *Dix) ResolvedTopN(n int) []ResolvedCount`;`type ResolvedCount struct { Type, Count }`(json: type/count,count int64) +- `(dix *Dix) ProblemProviders() (slow []string, errored []string)` — 基于 providerStats.LastError / LastDuration > SlowProviderThreshold,返回函数名去重排序 + +**Step 1 失败测试(graph_query_test.go):** + +测试骨架(三个用例): + +```go +func TestSearchNodesFilters(t *testing.T) { + di := New() + di.Provide(func() *QAService { return &QAService{} }) + di.Provide(func() *QARepo { return &QARepo{} }) + _ = di.TryInject(func(s *QAService, r *QARepo) {}) + + // q 过滤 + hits := di.SearchNodes("qaservice", "", "", "", 50) + if len(hits) == 0 || hits[0].Label != "*dixinternal.QAService" { + t.Fatalf("q filter failed: %+v", hits) + } + // kind 过滤 + hits = di.SearchNodes("", "provider", "", "", 50) + for _, h := range hits { + if h.Kind != "provider" { + t.Fatalf("kind filter failed: %+v", h) + } + } + // state=instantiated 只返回已有对象的类型 + hits = di.SearchNodes("", "type", "", "instantiated", 50) + if len(hits) != 2 { // *QAService 与 *QARepo 均已实例化 + t.Fatalf("instantiated filter: %+v", hits) + } + // limit 生效 + if hits := di.SearchNodes("", "", "", "", 1); len(hits) != 1 { + t.Fatal("limit must be honored") + } +} + +func TestModuleGraphAggregation(t *testing.T) { + di := New() + di.Provide(func(r *QARepo) *QAService { return &QARepo{}) } // 同包:无跨模块边 + // 见实现:同包内声明边不计入 DependsOn;跨模块用不同 pkg 的类型(测试内构造两个包不可行, + // 故以 (anonymous) 与 dixinternal 分组验证分组本身成立) + _ = di +} + +func TestEgoGraphDepthAndDirection(t *testing.T) { + di := New() + // 链:C -> B -> A(声明依赖) + di.Provide(func(*QADepB) *QADepA { return &QADepA{} }) + di.Provide(func(*QADepC) *QADepB { return &QADepB{} }) + + // center=*QADepA depth=1 deps:只含 A、B + view := di.EgoGraph("*dixinternal.QADepA", 1, "deps") + labels := viewLabels(view) + if !containsStr(labels, "*dixinternal.QADepA") || !containsStr(labels, "*dixinternal.QADepB") || containsStr(labels, "*dixinternal.QADepC") { + t.Fatalf("deps view wrong: %v", labels) + } + // depth=2 both:三层都在 + view = di.EgoGraph("*dixinternal.QADepA", 2, "both") + if !containsStr(viewLabels(view), "*dixinternal.QADepC") { + t.Fatalf("both depth=2 should include C: %v", viewLabels(view)) + } + // dependents 方向从 A 反向:只有 B(以及再往上 C) + view = di.EgoGraph("*dixinternal.QADepA", 1, "dependents") + if !containsStr(viewLabels(view), "*dixinternal.QADepB") { + t.Fatalf("dependents view should include B: %v", viewLabels(view)) + } +} + +func TestResolvedTopNAndProblemProviders(t *testing.T) { + di := New(WithSlowProviderThreshold(time.Millisecond)) + di.Provide(func() *QASlow { time.Sleep(5 * time.Millisecond); return &QASlow{} }) + di.Provide(func() (*QABroken, error) { return nil, errors.New("x") }) + _ = di.TryInject(func(s *QASlow) {}) + _ = di.TryInject(func(b *QABroken) {}) + + top := di.ResolvedTopN(5) + if len(top) == 0 || top[0].Type != "*dixinternal.QASlow" || top[0].Count != 1 { + t.Fatalf("top resolved = %+v", top) + } + slow, broken := di.ProblemProviders() + if len(slow) == 0 || len(broken) == 0 { + t.Fatalf("slow=%v broken=%v", slow, broken) + } +} +``` + +(测试类型 QAService/QARepo/QADepA/B/C/QASlow/QABroken 在测试文件内声明;`TestModuleGraphAggregation` 落码时以同包分组断言 TypeCount/ProviderCount 正确、DependsOn 为空为准——跨模块场景由 dixhttp demo(多包)覆盖。) + +**Step 2** 确认编译失败 → **Step 3 实现 graph_query.go** → **Step 4** `go test ./dixinternal -run 'TestSearchNodes|TestModuleGraph|TestEgoGraph|TestResolvedTopN' -race -count=1` 全绿 → **Step 5** 提交 `feat: server-side graph query APIs (search/modules/ego/topN)`。 + +--- + +### Task 2: dixhttp 端点 + stats 升级 + +**Files:** +- Modify: `dixhttp/server.go`(路由 + 3 个新 handler + StatsData 增字段) +- Test: `dixhttp/server_api_test.go` 追加 + +**Interfaces:** +- `GET /api/search?q=&kind=&module=&state=&limit=` → `[]SearchHit` +- `GET /api/modules` → `[]ModuleInfo` +- `GET /api/ego?center=&depth=&direction=` → `GraphView`(缺 center → 400) +- `StatsData` 增字段:`modules int`、`top_resolved []{type,count}`、`slow_providers []string`、`error_providers []string` + +**Step 1 失败测试(server_api_test.go 追加):** + +```go +func TestHandleSearchModulesEgo(t *testing.T) { + di := dixinternal.New() + di.Provide(func() *apiStatsDep { return &apiStatsDep{} }) + if err := di.TryInject(func(*apiStatsDep) {}); err != nil { + t.Fatalf("inject: %v", err) + } + server := NewServer(di) + + // search + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/search?q=apistats&kind=type", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("search status %d", rr.Code) + } + var hits []dixinternal.SearchHit + if err := json.Unmarshal(rr.Body.Bytes(), &hits); err != nil || len(hits) == 0 { + t.Fatalf("search hits = %v err = %v", hits, err) + } + + // modules + rr = httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/modules", nil)) + var modules []dixinternal.ModuleInfo + if err := json.Unmarshal(rr.Body.Bytes(), &modules); err != nil || len(modules) == 0 { + t.Fatalf("modules = %v err = %v", modules, err) + } + + // ego:缺 center → 400;有效 center → 视图非空 + rr = httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/ego", nil)) + if rr.Code != http.StatusBadRequest { + t.Fatalf("ego without center should 400, got %d", rr.Code) + } + rr = httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/ego?center=*dixinternal.apiStatsDep&depth=1&direction=deps", nil)) + var view dixinternal.GraphView + if err := json.Unmarshal(rr.Body.Bytes(), &view); err != nil { + t.Fatalf("decode ego: %v", err) + } + + // stats 增字段 + rr = httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/stats", nil)) + var stats StatsData + if err := json.Unmarshal(rr.Body.Bytes(), &stats); err != nil { + t.Fatalf("decode stats: %v", err) + } + if stats.Modules == 0 || stats.TopResolved == nil { + t.Fatalf("stats upgrade missing: %+v", stats) + } +} +``` + +**Step 2** 确认 404 失败。**Step 3 实现 handler/路由/StatsData 增字段**。**Step 4** `go test ./dixhttp -race -count=1` 全绿。**Step 5** 提交。 + +--- + +### Task 3: 文档 + changelog + 全量验证 + 交付 + +- dixhttp/README.md 增三个路由文档;design 双语一句概览;changelog Unreleased 新增一条 +- `gofmt -l .`、vet、`task test`、`task lint`、覆盖率核对 +- PR → CI → squash merge + +## Self-Review + +- Spec 6.1/6.2/6.3/6.4 的**服务端部分**全覆盖;前端五视图与渲染 spike 属 P4b(P4 开工决策点,spec 允许)。 +- 签名一致:SearchHit/ModuleInfo/GraphView/GraphEdge 在 Task 1 定义、Task 2 消费。