diff --git a/.gitignore b/.gitignore index 1ac273e..285399b 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ pyproject.toml /container-artifacts/ search-retrieval.md _workspace/ +/artifacts/ diff --git a/CLAUDE.md b/CLAUDE.md index 6280818..df3652c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,11 +5,11 @@ Follow the global prompt rules first. This file adds project-specific skill routing for a project that uses the `agent-team` CLI (github.com/tae2089/agent-team) as its durable work ledger. ## MCP 서버 -`.mcp.json`에 등록된 ccg MCP 서버가 17개 도구를 제공합니다: +`.mcp.json`에 등록된 ccg MCP 서버가 18개 도구를 제공합니다: - `parse_project`, `build_or_update_graph`, `run_postprocess` - `get_node`, `search`, `query_graph`, `list_graph_stats`, `list_namespaces`, `get_minimal_context` -- `get_impact_radius`, `trace_flow` +- `get_impact_radius`, `trace_flow`, `list_cross_refs` - `detect_changes`, `get_affected_flows`, `list_flows` - `get_annotation` - `get_doc_content`, `search_docs` @@ -45,7 +45,7 @@ Graceful shutdown: SIGINT/SIGTERM 시 진행 중인 clone/build에 context cance 상세 문서는 `guide/` 디렉토리를 참조하세요: - [CLI Reference](guide/cli-reference.md) — 전체 명령어, 플래그, 설정 파일 -- [MCP Tools](guide/mcp-tools.md) — 17개 MCP 도구, Skills, AI-Driven Annotation +- [MCP Tools](guide/mcp-tools.md) — 18개 MCP 도구, Skills, AI-Driven Annotation - [Annotations](guide/annotations.md) — 어노테이션 태그, 예시, 검색 - [Webhook](guide/webhook.md) — Webhook sync, 브랜치 필터링, HMAC, graceful shutdown - [Docker](guide/docker.md) — Docker 빌드, MCP 서버, PostgreSQL 배포 diff --git a/docker-compose.integration.yml b/docker-compose.integration.yml index 0d69255..c7bbd84 100644 --- a/docker-compose.integration.yml +++ b/docker-compose.integration.yml @@ -67,7 +67,7 @@ services: - --http-bearer-token=test-mcp-token - --db-driver=postgres - --db-dsn=host=postgres user=ccg password=ccg dbname=ccg_integration port=5432 sslmode=disable - - --allow-repo=*/* + - --allow-repo=testadmin/* - --webhook-secret=test-webhook-secret - --repo-clone-base-url=http://gitea:3000 - --repo-root=/repos diff --git a/guide/README.md b/guide/README.md index e21ddf4..aee5e99 100644 --- a/guide/README.md +++ b/guide/README.md @@ -20,7 +20,7 @@ explore graph edges. |----------|-------------| | [CLI Reference](cli-reference.md) | Full CLI commands, options, and configuration file (`.ccg.yaml`) | | [Lint](lint.md) | Detailed `ccg lint` category reference, interpretation guide, and CI usage | -| [MCP Tools](mcp-tools.md) | 17 MCP tools, agent skills, evidence-first routing, AI-driven annotation | +| [MCP Tools](mcp-tools.md) | 18 MCP tools, agent skills, evidence-first routing, AI-driven annotation | | [Annotations](annotations.md) | Custom annotation system — tags, examples, and search quality | | [Webhook](webhook.md) | GitHub / Gitea webhook sync, branch filtering, graceful shutdown | | [Docker](docker.md) | Docker image build, MCP server setup, Wiki UI deployment, PostgreSQL integration | diff --git a/guide/annotations.md b/guide/annotations.md index 2c17abf..de9ecf7 100644 --- a/guide/annotations.md +++ b/guide/annotations.md @@ -62,6 +62,8 @@ Use `ccg://{namespace}/{path}#{symbol}` in `@see` when a behavior depends on cod The path and symbol are optional, so `ccg://auth-svc/internal/auth` can point at a package/path scope and `ccg://auth-svc/` can point at a whole namespace. +Every `@see ccg://` tag is materialized into the `cross_refs` table after each build or update: the target is resolved against the referenced namespace's current nodes and the row is marked `resolved` or `dead`. When either side rebuilds, refs are re-resolved automatically, so a `dead` ref recovers as soon as the target symbol appears. Materialized refs power `list_cross_refs` (repository dependency map) and the `cross_namespace: true` mode of `get_impact_radius`/`trace_flow`. Namespace-scope refs (`ccg://auth-svc/`) resolve without a node target and never appear in traversal, only in listings. + ## Retrieval Quality Annotations are retrieval features. `search_docs` and generated docs rank diff --git a/guide/architecture.md b/guide/architecture.md index 0dcdb27..fc60cc4 100644 --- a/guide/architecture.md +++ b/guide/architecture.md @@ -62,7 +62,7 @@ closure with deterministic `go list -json` checks. ## Runtime and transports Both `ccg serve` (stdio) and `ccg-server` (Streamable HTTP) use the same five -grouped MCP dependency surfaces and expose exactly 17 tools plus four prompts. +grouped MCP dependency surfaces and expose exactly 18 tools plus four prompts. The local binary does not link remote HTTP, Wiki, webhook, or remote runtime packages. See [Runtime Layout](runtime-layout.md). @@ -74,6 +74,16 @@ filters. SQLite and PostgreSQL share GORM-owned graph operations and backend- specific full-text search adapters. Application and inbound packages never issue SQL; backend-specific SQL remains encapsulated in outbound adapters and migration code. +The `cross_refs` table is the one deliberate bridge across namespaces: each row +materializes an `@see ccg://` annotation with a symbolic target +(`to_namespace`, `to_path`, `to_symbol`) plus derived state +(`resolved_node_id`, `status`). Rows are rebuilt from annotations after every +build/update of the source namespace and re-resolved when the target namespace +rebuilds, because replace-style syncs regenerate node ids. Cross-namespace +analysis reads go through a dedicated namespace-agnostic reader that merges +resolved refs into traversal as synthetic `cross_ref` edges; regular +single-namespace query paths remain strictly namespace-filtered. + ## Reliability and security - Repository sync verifies HMAC before trust, applies ordered allow rules, bounds diff --git a/guide/development.md b/guide/development.md index 33d9551..07431a8 100644 --- a/guide/development.md +++ b/guide/development.md @@ -103,7 +103,7 @@ internal/ ctx/ — Request-context values (namespace isolation) docs/ — Documentation generation mcpruntime/ — Shared MCP runtime assembly, stdio runner, cache, telemetry - mcp/ — MCP server (17 tools) + mcp/ — MCP server (18 tools) wikiserver/ — ccg-server Wiki static serving and viewer API wikiindex/ — Wiki presentation index builder (`wiki-index.json`) model/ — DB models diff --git a/guide/ko/README.md b/guide/ko/README.md index 3c722bf..a5fb4fb 100644 --- a/guide/ko/README.md +++ b/guide/ko/README.md @@ -23,7 +23,7 @@ DB-backed graph/annotation evidence를 사용하며, 시각적 Graph 탭은 |------|------| | [CLI 레퍼런스](cli-reference.md) | 모든 CLI 명령어, 옵션 및 설정 파일(`.ccg.yaml`) 안내 | | [Lint](lint.md) | `ccg lint` 카테고리 상세 레퍼런스, 결과 해석 및 CI 활용법 | -| [MCP 도구](mcp-tools.md) | 17개의 MCP 도구, 에이전트 스킬, evidence-first 라우팅, AI 기반 어노테이션 | +| [MCP 도구](mcp-tools.md) | 18개의 MCP 도구, 에이전트 스킬, evidence-first 라우팅, AI 기반 어노테이션 | | [어노테이션](annotations.md) | 커스텀 어노테이션 시스템 — 태그, 예시, 검색 품질 | | [웹훅(Webhook)](webhook.md) | GitHub / Gitea 웹훅 동기화, 브랜치 필터링, Graceful Shutdown | | [Docker](docker.md) | Docker 이미지 빌드, MCP 서버 설정, Wiki UI 배포, PostgreSQL 연동 | diff --git a/guide/ko/architecture.md b/guide/ko/architecture.md index 71b0a4e..908c80c 100644 --- a/guide/ko/architecture.md +++ b/guide/ko/architecture.md @@ -60,7 +60,7 @@ CLI / MCP / HTTP / Wiki / webhook -> application capability -> outbound port ## 런타임과 전송 계층 `ccg serve`(stdio)와 `ccg-server`(Streamable HTTP)는 동일한 다섯 MCP 의존성 -그룹을 사용하며 정확히 17개 tool과 4개 prompt를 노출합니다. 로컬 바이너리는 +그룹을 사용하며 정확히 18개 tool과 4개 prompt를 노출합니다. 로컬 바이너리는 원격 HTTP, Wiki, webhook, remote runtime 패키지를 링크하지 않습니다. 자세한 내용은 [런타임 레이아웃](runtime-layout.md)을 참고하세요. diff --git a/guide/ko/development.md b/guide/ko/development.md index 09a7e0b..8dc983b 100644 --- a/guide/ko/development.md +++ b/guide/ko/development.md @@ -105,7 +105,7 @@ internal/ ctx/ — 요청 컨텍스트 값 (namespace 격리) docs/ — 문서 생성 로직 mcpruntime/ — 공용 MCP runtime assembly, stdio runner, cache, telemetry - mcp/ — MCP 서버 (17개 도구) + mcp/ — MCP 서버 (18개 도구) wikiserver/ — ccg-server Wiki 정적 파일 서빙 및 viewer API wikiindex/ — Wiki 표시용 인덱스 생성기 (`wiki-index.json`) model/ — DB 모델 diff --git a/guide/ko/mcp-tools.md b/guide/ko/mcp-tools.md index 708e301..e10dd27 100644 --- a/guide/ko/mcp-tools.md +++ b/guide/ko/mcp-tools.md @@ -1,6 +1,6 @@ # MCP 도구 -code-context-graph는 로컬 `ccg serve`와 셀프호스트 `ccg-server` 런타임 모두에서 17개의 MCP 도구를 제공합니다. +code-context-graph는 로컬 `ccg serve`와 셀프호스트 `ccg-server` 런타임 모두에서 18개의 MCP 도구를 제공합니다. ## 파싱 및 빌드 @@ -15,27 +15,28 @@ code-context-graph는 로컬 `ccg serve`와 셀프호스트 `ccg-server` 런타 | 도구 | 용도 | | ---- | ---- | | `get_node` | qualified name으로 node 하나 조회 | -| `search` | path scope를 선택적으로 적용하는 code node full-text search | +| `search` | path scope를 선택적으로 적용하는 code node full-text search; `namespaces: []`로 여러 namespace 연합 검색 (결과에 namespace 라벨) | | `get_annotation` | node의 annotation과 문서 tag 조회 | -| `query_graph` | callers, callees, imports, children, tests, inheritors, file summary 조회 | -| `list_graph_stats` | node와 edge를 kind 및 language별로 집계 | +| `query_graph` | callers, callees, imports, children, tests, inheritors, file summary 조회; `namespaces: []`는 namespace별 그룹 응답 | +| `list_graph_stats` | node와 edge를 kind 및 language별로 집계; `namespaces: []`는 namespace별 그룹 응답 | | `list_namespaces` | graph data가 있는 namespace와 node count 목록 조회 | ## 분석 | 도구 | 용도 | | ---- | ---- | -| `get_impact_radius` | node 주변의 제한된 BFS 영향 반경 계산 | -| `trace_flow` | node에서 시작하는 제한된 call chain 추적 | +| `get_impact_radius` | node 주변의 제한된 BFS 영향 반경 계산; `cross_namespace: true`면 resolved `ccg://` ref를 양방향으로 통과 | +| `trace_flow` | node에서 시작하는 제한된 call chain 추적; `cross_namespace: true`면 resolved `ccg://` ref를 넘어 계속 추적 | | `detect_changes` | git diff 기반 변경 함수 탐지 및 risk score 계산 | | `get_affected_flows` | 최근 변경의 영향을 받는 저장 flow 조회 | | `list_flows` | 저장 flow를 페이지네이션해 조회 | +| `list_cross_refs` | 실체화된 `ccg://` cross-namespace 참조 목록 (direction: outbound/inbound/both, status 필터) | ## 문서 및 컨텍스트 | 도구 | 용도 | | ---- | ---- | -| `search_docs` | DB-backed 문서 후보와 graph evidence 검색 | +| `search_docs` | DB-backed 문서 후보와 graph evidence 검색; `namespaces: []`는 namespace별 그룹 응답 | | `get_doc_content` | 선택한 생성 Markdown 파일을 안전하게 읽기 | | `get_minimal_context` | 작은 프로젝트/변경 요약과 다음 도구 제안 반환 | @@ -51,4 +52,4 @@ code-context-graph는 로컬 `ccg serve`와 셀프호스트 `ccg-server` 런타 `query_graph`와 `list_flows`에는 명시적인 `limit`, `offset`을 사용하십시오. 50 또는 100개로 시작하고 페이지네이션 metadata를 따라 확장합니다. -로컬 MCP client는 stdio 방식의 `ccg serve`를 시작하고, 원격 client는 `ccg-server`의 `/mcp` Streamable HTTP endpoint에 연결합니다. 두 런타임은 동일한 17개 도구를 등록합니다. +로컬 MCP client는 stdio 방식의 `ccg serve`를 시작하고, 원격 client는 `ccg-server`의 `/mcp` Streamable HTTP endpoint에 연결합니다. 두 런타임은 동일한 18개 도구를 등록합니다. diff --git a/guide/ko/runtime-layout.md b/guide/ko/runtime-layout.md index 5326346..4fd70a3 100644 --- a/guide/ko/runtime-layout.md +++ b/guide/ko/runtime-layout.md @@ -29,7 +29,7 @@ HTTP host, Wiki HTTP, webhook adapter, remote runtime은 링크하지 않습니 - `/wiki`, `/wiki/api/*` — 선택적인 CCG 내장 Wiki - `/webhook` — 선택적인 GitHub/Gitea repository sync -두 transport 모두 `Runtime.MCPComponents()`를 사용하므로 동일한 17개 tool과 +두 transport 모두 `Runtime.MCPComponents()`를 사용하므로 동일한 18개 tool과 4개 prompt를 등록합니다. ## 리소스 소유권 diff --git a/guide/mcp-tools.md b/guide/mcp-tools.md index 098b23b..73742fa 100644 --- a/guide/mcp-tools.md +++ b/guide/mcp-tools.md @@ -1,6 +1,6 @@ # MCP Tools -code-context-graph exposes 17 MCP tools through both local `ccg serve` and the self-hosted `ccg-server` runtime. +code-context-graph exposes 18 MCP tools through both local `ccg serve` and the self-hosted `ccg-server` runtime. ## Parse and Build @@ -15,27 +15,28 @@ code-context-graph exposes 17 MCP tools through both local `ccg serve` and the s | Tool | Purpose | | ---- | ------- | | `get_node` | Read one node by qualified name | -| `search` | Full-text search across code nodes, optionally scoped by path | +| `search` | Full-text search across code nodes, optionally scoped by path; `namespaces: []` federates across namespaces with per-item labels | | `get_annotation` | Read annotations and documentation tags for one node | -| `query_graph` | Run callers, callees, imports, children, tests, inheritors, or file-summary queries | -| `list_graph_stats` | Report node and edge counts by kind and language | +| `query_graph` | Run callers, callees, imports, children, tests, inheritors, or file-summary queries; `namespaces: []` groups results per namespace | +| `list_graph_stats` | Report node and edge counts by kind and language; `namespaces: []` returns per-namespace groups | | `list_namespaces` | List namespaces that contain graph data and their node counts | ## Analysis | Tool | Purpose | | ---- | ------- | -| `get_impact_radius` | Compute a bounded BFS blast radius around a node | -| `trace_flow` | Trace a bounded call chain from a node | +| `get_impact_radius` | Compute a bounded BFS blast radius around a node; `cross_namespace: true` follows resolved `ccg://` refs both ways | +| `trace_flow` | Trace a bounded call chain from a node; `cross_namespace: true` continues across resolved `ccg://` refs | | `detect_changes` | Detect changed functions from git diff and calculate risk scores | | `get_affected_flows` | List stored flows affected by recent changes | | `list_flows` | List stored flows with bounded pagination | +| `list_cross_refs` | List materialized `ccg://` cross-namespace references (direction: outbound/inbound/both, status filter) | ## Documentation and Context | Tool | Purpose | | ---- | ------- | -| `search_docs` | Search DB-backed documentation candidates and graph evidence | +| `search_docs` | Search DB-backed documentation candidates and graph evidence; `namespaces: []` groups results per namespace | | `get_doc_content` | Safely read a selected generated Markdown file | | `get_minimal_context` | Return a compact project/change summary and suggested next tools | @@ -53,6 +54,6 @@ Use explicit `limit` and `offset` values for `query_graph` and `list_flows`. Sta ## Runtime -Local MCP clients start `ccg serve` over stdio. Remote clients connect to the `/mcp` Streamable HTTP endpoint served by `ccg-server`. Both runtimes register the same 17 tools. +Local MCP clients start `ccg serve` over stdio. Remote clients connect to the `/mcp` Streamable HTTP endpoint served by `ccg-server`. Both runtimes register the same 18 tools. For tool parameters and response schemas, inspect the MCP schema exposed by the running server; source registration lives under `internal/adapters/inbound/mcp/tools_*.go`. diff --git a/guide/runtime-layout.md b/guide/runtime-layout.md index 44d2bd5..f61d063 100644 --- a/guide/runtime-layout.md +++ b/guide/runtime-layout.md @@ -31,7 +31,7 @@ runtime. This keeps local MCP use independent of remote hosting policy. - `/webhook` — optional GitHub/Gitea repository sync Both transports call `Runtime.MCPComponents()` and therefore register the same -17 tools and four prompts. +18 tools and four prompts. ## Resource ownership diff --git a/internal/adapters/inbound/cli/build.go b/internal/adapters/inbound/cli/build.go index 58caeb8..2ec58f5 100644 --- a/internal/adapters/inbound/cli/build.go +++ b/internal/adapters/inbound/cli/build.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/tae2089/trace" + "github.com/tae2089/code-context-graph/internal/app/crossref" "github.com/tae2089/code-context-graph/internal/app/ingest" "github.com/tae2089/code-context-graph/internal/app/ingest/workflow" requestctx "github.com/tae2089/code-context-graph/internal/ctx" @@ -47,6 +48,9 @@ func newBuildCmd(deps *Deps) *cobra.Command { Walkers: deps.Walkers, Logger: deps.Logger, } + if crossRefStore, ok := deps.Store.(crossref.Store); ok { + svc.CrossRefs = crossref.New(crossRefStore) + } opts := workflow.BuildOptions{ Dir: dir, diff --git a/internal/adapters/inbound/cli/update.go b/internal/adapters/inbound/cli/update.go index 2f5d1fc..b6ef873 100644 --- a/internal/adapters/inbound/cli/update.go +++ b/internal/adapters/inbound/cli/update.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/tae2089/trace" + "github.com/tae2089/code-context-graph/internal/app/crossref" "github.com/tae2089/code-context-graph/internal/app/ingest" "github.com/tae2089/code-context-graph/internal/app/ingest/workflow" requestctx "github.com/tae2089/code-context-graph/internal/ctx" @@ -50,6 +51,9 @@ func newUpdateCmd(deps *Deps) *cobra.Command { Walkers: deps.Walkers, Logger: deps.Logger, } + if crossRefStore, ok := deps.Store.(crossref.Store); ok { + svc.CrossRefs = crossref.New(crossRefStore) + } stats, err := svc.Update(ctx, workflow.UpdateOptions{ BuildOptions: workflow.BuildOptions{ Dir: dir, diff --git a/internal/adapters/inbound/mcp/deps.go b/internal/adapters/inbound/mcp/deps.go index 4dffd33..d823085 100644 --- a/internal/adapters/inbound/mcp/deps.go +++ b/internal/adapters/inbound/mcp/deps.go @@ -109,13 +109,24 @@ type GraphToolsDeps struct { Reader analyze.GraphReadRepository } +// CrossRefLister exposes materialized cross-namespace references for listing tools. +// @intent let handlers enumerate repository-level dependencies without a store implementation dependency. +type CrossRefLister interface { + ListOutboundCrossRefs(ctx context.Context, fromNamespace string) ([]graph.CrossRef, error) + ListInboundCrossRefs(ctx context.Context, toNamespace string) ([]graph.CrossRef, error) +} + // AnalysisToolsDeps owns bounded impact, flow, and git-change analysis dependencies. // @intent group only configured application analyzers and their read-model port. +// @domainRule CrossImpact/CrossFlow/CrossRefs are optional; when nil the cross-namespace analysis surface reports itself unconfigured. type AnalysisToolsDeps struct { - Impact ImpactAnalyzer - Flow FlowTracer - Changes ChangeAnalyzer - Reader analyze.GraphReadRepository + Impact ImpactAnalyzer + Flow FlowTracer + Changes ChangeAnalyzer + Reader analyze.GraphReadRepository + CrossImpact ImpactAnalyzer + CrossFlow FlowTracer + CrossRefs CrossRefLister } // DocsToolsDeps owns DB-primary documentation retrieval. diff --git a/internal/adapters/inbound/mcp/handler_analysis.go b/internal/adapters/inbound/mcp/handler_analysis.go index e1ecdc2..0bdae3a 100644 --- a/internal/adapters/inbound/mcp/handler_analysis.go +++ b/internal/adapters/inbound/mcp/handler_analysis.go @@ -39,9 +39,11 @@ type impactRadiusResponse struct { // traceFlowMember captures one ordered member inside a traced flow. // @intent serialize flow member references without exposing the full node record. +// @domainRule Namespace is set only in cross-namespace mode so single-namespace responses stay unchanged. type traceFlowMember struct { - NodeID uint `json:"node_id"` - Ordinal int `json:"ordinal"` + NodeID uint `json:"node_id"` + Ordinal int `json:"ordinal"` + Namespace string `json:"namespace,omitempty"` } // traceFlowMetadata records bounded trace settings and fallback-edge summary data. @@ -116,14 +118,19 @@ func (h *handlers) getImpactRadius(ctx context.Context, request mcp.CallToolRequ depth := request.GetInt("depth", 1) maxDepth := request.GetInt("max_depth", defaultImpactMaxDepth) maxNodes := request.GetInt("max_nodes", defaultImpactMaxNodes) + crossNamespace := request.GetBool("cross_namespace", false) - log.InfoContext(ctx, "get_impact_radius called", append(obs.TraceLogArgs(ctx), "qualified_name", qn, "depth", depth)...) + log.InfoContext(ctx, "get_impact_radius called", append(obs.TraceLogArgs(ctx), "qualified_name", qn, "depth", depth, "cross_namespace", crossNamespace)...) - if h.deps.Analysis.Impact == nil { + analyzer := h.deps.Analysis.Impact + if crossNamespace { + analyzer = h.deps.Analysis.CrossImpact + } + if analyzer == nil { return mcp.NewToolResultError("ImpactAnalyzer not configured"), nil } - return finalizeToolResult(h.cachedExecute(ctx, "get_impact_radius:", map[string]any{"qualified_name": qn, "depth": depth, "max_depth": maxDepth, "max_nodes": maxNodes, "namespace": requestNamespace(request)}, func() (string, error) { + return finalizeToolResult(h.cachedExecute(ctx, "get_impact_radius:", map[string]any{"qualified_name": qn, "depth": depth, "max_depth": maxDepth, "max_nodes": maxNodes, "namespace": requestNamespace(request), "cross_namespace": crossNamespace}, func() (string, error) { node, err := h.deps.Graph.Store.GetNode(ctx, qn) if err != nil { log.ErrorContext(ctx, "store error", append(obs.TraceLogArgs(ctx), "tool", "get_impact_radius", trace.SlogError(err))...) @@ -134,7 +141,7 @@ func (h *handlers) getImpactRadius(ctx context.Context, request mcp.CallToolRequ return "", nodeNotFoundErr(qn) } - res, err := h.deps.Analysis.Impact.ImpactRadiusBounded(ctx, node.ID, depth, impactpkg.RadiusOptions{MaxDepth: maxDepth, MaxNodes: maxNodes}) + res, err := analyzer.ImpactRadiusBounded(ctx, node.ID, depth, impactpkg.RadiusOptions{MaxDepth: maxDepth, MaxNodes: maxNodes}) if err != nil { log.ErrorContext(ctx, "impact analysis error", append(obs.TraceLogArgs(ctx), "node_id", node.ID, trace.SlogError(err))...) return "", trace.Wrap(err, "impact analysis error") @@ -147,6 +154,9 @@ func (h *handlers) getImpactRadius(ctx context.Context, request mcp.CallToolRequ impactResult := make([]nodeSummary, len(nodes)) for i, n := range nodes { impactResult[i] = nodeToSummary(n) + if crossNamespace { + impactResult[i].Namespace = n.Namespace + } } result, err := marshalJSON(impactRadiusResponse{ Nodes: impactResult, @@ -179,9 +189,14 @@ func (h *handlers) traceFlow(ctx context.Context, request mcp.CallToolRequest) ( return missingParamResult(err) } - log.InfoContext(ctx, "trace_flow called", append(obs.TraceLogArgs(ctx), "qualified_name", qn)...) + crossNamespace := request.GetBool("cross_namespace", false) + log.InfoContext(ctx, "trace_flow called", append(obs.TraceLogArgs(ctx), "qualified_name", qn, "cross_namespace", crossNamespace)...) - if h.deps.Analysis.Flow == nil { + tracer := h.deps.Analysis.Flow + if crossNamespace { + tracer = h.deps.Analysis.CrossFlow + } + if tracer == nil { return mcp.NewToolResultError("FlowTracer not configured"), nil } @@ -192,6 +207,7 @@ func (h *handlers) traceFlow(ctx context.Context, request mcp.CallToolRequest) ( "max_nodes": maxNodes, "include_fallback_calls": includeFallbackCalls, "namespace": requestNamespace(request), + "cross_namespace": crossNamespace, }, func() (string, error) { node, err := h.deps.Graph.Store.GetNode(ctx, qn) if err != nil { @@ -203,7 +219,7 @@ func (h *handlers) traceFlow(ctx context.Context, request mcp.CallToolRequest) ( return "", nodeNotFoundErr(qn) } - res, err := h.deps.Analysis.Flow.TraceFlowBounded(ctx, node.ID, flowspkg.TraceOptions{MaxNodes: maxNodes, IncludeFallbackCalls: &includeFallbackCalls}) + res, err := tracer.TraceFlowBounded(ctx, node.ID, flowspkg.TraceOptions{MaxNodes: maxNodes, IncludeFallbackCalls: &includeFallbackCalls}) if err != nil { log.ErrorContext(ctx, "trace error", append(obs.TraceLogArgs(ctx), "node_id", node.ID, trace.SlogError(err))...) return "", trace.Wrap(err, "trace error") @@ -218,6 +234,9 @@ func (h *handlers) traceFlow(ctx context.Context, request mcp.CallToolRequest) ( members := make([]traceFlowMember, len(flow.Members)) for i, m := range flow.Members { members[i] = traceFlowMember{NodeID: m.NodeID, Ordinal: m.Ordinal} + if crossNamespace { + members[i].Namespace = m.Namespace + } } result, err := marshalJSON(traceFlowResponse{ diff --git a/internal/adapters/inbound/mcp/handler_crossns_test.go b/internal/adapters/inbound/mcp/handler_crossns_test.go new file mode 100644 index 0000000..5e21344 --- /dev/null +++ b/internal/adapters/inbound/mcp/handler_crossns_test.go @@ -0,0 +1,206 @@ +package mcp + +import ( + "context" + "encoding/json" + "testing" + + requestctx "github.com/tae2089/code-context-graph/internal/ctx" + "github.com/tae2089/code-context-graph/internal/domain/graph" +) + +// seedCrossNamespaceDeps stores web.Login -> (cross ref) -> auth.ValidateToken -> auth.RecordAudit. +func seedCrossNamespaceDeps(t *testing.T, deps *Deps) (webLogin, authValidate, authAudit uint) { + t.Helper() + st := testGraphStoreFor(deps) + seed := func(namespace string, node graph.Node) uint { + t.Helper() + ctx := requestctx.WithNamespace(context.Background(), namespace) + if err := st.UpsertNodes(ctx, []graph.Node{node}); err != nil { + t.Fatalf("seed node: %v", err) + } + stored, err := st.GetNode(ctx, node.QualifiedName) + if err != nil || stored == nil { + t.Fatalf("load node %s: %v", node.QualifiedName, err) + } + return stored.ID + } + webLogin = seed("web", graph.Node{QualifiedName: "web.Login", Kind: graph.NodeKindFunction, Name: "Login", FilePath: "internal/web/login.go", StartLine: 5, EndLine: 25}) + authValidate = seed("auth-svc", graph.Node{QualifiedName: "auth.ValidateToken", Kind: graph.NodeKindFunction, Name: "ValidateToken", FilePath: "internal/auth/token.go", StartLine: 10, EndLine: 20}) + authAudit = seed("auth-svc", graph.Node{QualifiedName: "auth.RecordAudit", Kind: graph.NodeKindFunction, Name: "RecordAudit", FilePath: "internal/audit/audit.go", StartLine: 3, EndLine: 9}) + + authCtx := requestctx.WithNamespace(context.Background(), "auth-svc") + if err := st.UpsertEdges(authCtx, []graph.Edge{{ + FromNodeID: authValidate, ToNodeID: authAudit, Kind: graph.EdgeKindCalls, + FilePath: "internal/auth/token.go", Line: 15, Fingerprint: "calls:internal/auth/token.go:RecordAudit:15", + }}); err != nil { + t.Fatalf("seed edge: %v", err) + } + + resolved := authValidate + if err := st.ReplaceCrossRefsFrom(context.Background(), "web", []graph.CrossRef{{ + FromNamespace: "web", FromNodeID: webLogin, Raw: "ccg://auth-svc/internal/auth/token.go#ValidateToken", + ToNamespace: "auth-svc", ToPath: "internal/auth/token.go", ToSymbol: "ValidateToken", + ResolvedNodeID: &resolved, Status: graph.CrossRefStatusResolved, Source: graph.CrossRefSourceAnnotation, + }}); err != nil { + t.Fatalf("seed cross ref: %v", err) + } + return webLogin, authValidate, authAudit +} + +func TestGetImpactRadius_CrossNamespace(t *testing.T) { + deps := setupTestDeps(t) + seedCrossNamespaceDeps(t, deps) + + result := callTool(t, deps, "get_impact_radius", map[string]any{ + "qualified_name": "web.Login", + "namespace": "web", + "depth": 2, + "cross_namespace": true, + }) + var payload struct { + Nodes []struct { + QualifiedName string `json:"qualified_name"` + Namespace string `json:"namespace"` + } `json:"nodes"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + seen := map[string]string{} + for _, n := range payload.Nodes { + seen[n.QualifiedName] = n.Namespace + } + if seen["auth.ValidateToken"] != "auth-svc" { + t.Fatalf("impact nodes = %v, want auth.ValidateToken labeled auth-svc", seen) + } + if seen["auth.RecordAudit"] != "auth-svc" { + t.Fatalf("impact nodes = %v, want depth-2 traversal to continue inside auth-svc", seen) + } +} + +func TestGetImpactRadius_CrossNamespaceReverse(t *testing.T) { + deps := setupTestDeps(t) + seedCrossNamespaceDeps(t, deps) + + result := callTool(t, deps, "get_impact_radius", map[string]any{ + "qualified_name": "auth.ValidateToken", + "namespace": "auth-svc", + "depth": 1, + "cross_namespace": true, + }) + var payload struct { + Nodes []struct { + QualifiedName string `json:"qualified_name"` + Namespace string `json:"namespace"` + } `json:"nodes"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + found := false + for _, n := range payload.Nodes { + if n.QualifiedName == "web.Login" && n.Namespace == "web" { + found = true + } + } + if !found { + t.Fatalf("reverse impact = %+v, want web.Login from web namespace", payload.Nodes) + } +} + +func TestGetImpactRadius_DefaultStaysNamespaceScoped(t *testing.T) { + deps := setupTestDeps(t) + seedCrossNamespaceDeps(t, deps) + + result := callTool(t, deps, "get_impact_radius", map[string]any{ + "qualified_name": "web.Login", + "namespace": "web", + "depth": 2, + }) + var payload struct { + Nodes []struct { + QualifiedName string `json:"qualified_name"` + } `json:"nodes"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, n := range payload.Nodes { + if n.QualifiedName == "auth.ValidateToken" { + t.Fatal("default impact crossed namespaces without cross_namespace flag") + } + } +} + +func TestTraceFlow_CrossNamespace(t *testing.T) { + deps := setupTestDeps(t) + _, authValidate, authAudit := seedCrossNamespaceDeps(t, deps) + + result := callTool(t, deps, "trace_flow", map[string]any{ + "qualified_name": "web.Login", + "namespace": "web", + "cross_namespace": true, + }) + var payload struct { + Members []struct { + NodeID uint `json:"node_id"` + Namespace string `json:"namespace"` + } `json:"members"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + got := map[uint]string{} + for _, m := range payload.Members { + got[m.NodeID] = m.Namespace + } + if _, ok := got[authValidate]; !ok { + t.Fatalf("flow members = %+v, want continuation into auth-svc node %d", payload.Members, authValidate) + } + if _, ok := got[authAudit]; !ok { + t.Fatalf("flow members = %+v, want continuation into auth-svc node %d", payload.Members, authAudit) + } + if got[authValidate] != "auth-svc" || got[authAudit] != "auth-svc" { + t.Fatalf("cross members namespaces = %v, want auth-svc labels so clients can resolve foreign node ids", got) + } +} + +func TestListCrossRefs_Directions(t *testing.T) { + deps := setupTestDeps(t) + webLogin, authValidate, _ := seedCrossNamespaceDeps(t, deps) + + outbound := callTool(t, deps, "list_cross_refs", map[string]any{"namespace": "web", "direction": "outbound"}) + var outPayload struct { + Refs []struct { + FromNamespace string `json:"from_namespace"` + FromNodeID uint `json:"from_node_id"` + ToNamespace string `json:"to_namespace"` + ResolvedNodeID *uint `json:"resolved_node_id"` + Status string `json:"status"` + } `json:"refs"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, outbound)), &outPayload); err != nil { + t.Fatalf("unmarshal outbound: %v", err) + } + if len(outPayload.Refs) != 1 { + t.Fatalf("outbound refs = %d, want 1", len(outPayload.Refs)) + } + ref := outPayload.Refs[0] + if ref.FromNodeID != webLogin || ref.ToNamespace != "auth-svc" || ref.Status != "resolved" || ref.ResolvedNodeID == nil || *ref.ResolvedNodeID != authValidate { + t.Fatalf("outbound ref = %+v, want resolved web -> auth-svc link", ref) + } + + inbound := callTool(t, deps, "list_cross_refs", map[string]any{"namespace": "auth-svc", "direction": "inbound"}) + var inPayload struct { + Refs []struct { + FromNamespace string `json:"from_namespace"` + } `json:"refs"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, inbound)), &inPayload); err != nil { + t.Fatalf("unmarshal inbound: %v", err) + } + if len(inPayload.Refs) != 1 || inPayload.Refs[0].FromNamespace != "web" { + t.Fatalf("inbound refs = %+v, want one ref from web", inPayload.Refs) + } +} diff --git a/internal/adapters/inbound/mcp/handler_crossref.go b/internal/adapters/inbound/mcp/handler_crossref.go new file mode 100644 index 0000000..3f48ab4 --- /dev/null +++ b/internal/adapters/inbound/mcp/handler_crossref.go @@ -0,0 +1,98 @@ +// @index MCP handler exposing materialized cross-namespace references as a repository dependency map. +package mcp + +import ( + "context" + "fmt" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/tae2089/trace" + + requestctx "github.com/tae2089/code-context-graph/internal/ctx" + "github.com/tae2089/code-context-graph/internal/domain/graph" +) + +// crossRefItem serializes one materialized cross-namespace reference. +// @intent expose symbolic target identity and derived resolution state without internal row metadata. +type crossRefItem struct { + FromNamespace string `json:"from_namespace"` + FromNodeID uint `json:"from_node_id"` + Raw string `json:"raw"` + ToNamespace string `json:"to_namespace"` + ToPath string `json:"to_path,omitempty"` + ToSymbol string `json:"to_symbol,omitempty"` + ResolvedNodeID *uint `json:"resolved_node_id,omitempty"` + Status string `json:"status"` + Source string `json:"source"` +} + +// listCrossRefsResponse is the typed wire payload for listCrossRefs. +// @intent keep the requested namespace and direction visible next to the reference list. +type listCrossRefsResponse struct { + Namespace string `json:"namespace"` + Direction string `json:"direction"` + Refs []crossRefItem `json:"refs"` +} + +// listCrossRefs lists cross-namespace references declared by or targeting one namespace. +// @intent give agents a repository-level dependency map derived from ccg:// annotations. +// @param request direction selects outbound, inbound, or both; status optionally filters by resolution state. +// @requires the cross-ref lister must be configured. +// @ensures returns refs sorted as stored (outbound before inbound for direction both). +func (h *handlers) listCrossRefs(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ctx = h.applyNamespace(ctx, request) + log := h.logger() + + direction := request.GetString("direction", "both") + if direction != "outbound" && direction != "inbound" && direction != "both" { + return mcp.NewToolResultError(fmt.Sprintf("unknown direction: %q (want outbound, inbound, or both)", direction)), nil + } + status := request.GetString("status", "") + if status != "" && status != string(graph.CrossRefStatusResolved) && status != string(graph.CrossRefStatusDead) { + return mcp.NewToolResultError(fmt.Sprintf("unknown status: %q (want resolved or dead)", status)), nil + } + ns := requestctx.FromContext(ctx) + + log.Info("list_cross_refs called", "namespace", ns, "direction", direction, "status", status) + + if h.deps.Analysis.CrossRefs == nil { + return mcp.NewToolResultError("cross-ref lister not configured"), nil + } + + return finalizeToolResult(h.cachedExecute(ctx, "list_cross_refs:", map[string]any{"namespace": ns, "direction": direction, "status": status}, func() (string, error) { + var rows []graph.CrossRef + if direction == "outbound" || direction == "both" { + outbound, err := h.deps.Analysis.CrossRefs.ListOutboundCrossRefs(ctx, ns) + if err != nil { + return "", trace.Wrap(err, "list outbound cross refs") + } + rows = append(rows, outbound...) + } + if direction == "inbound" || direction == "both" { + inbound, err := h.deps.Analysis.CrossRefs.ListInboundCrossRefs(ctx, ns) + if err != nil { + return "", trace.Wrap(err, "list inbound cross refs") + } + rows = append(rows, inbound...) + } + + refs := make([]crossRefItem, 0, len(rows)) + for _, row := range rows { + if status != "" && string(row.Status) != status { + continue + } + refs = append(refs, crossRefItem{ + FromNamespace: row.FromNamespace, + FromNodeID: row.FromNodeID, + Raw: row.Raw, + ToNamespace: row.ToNamespace, + ToPath: row.ToPath, + ToSymbol: row.ToSymbol, + ResolvedNodeID: row.ResolvedNodeID, + Status: string(row.Status), + Source: string(row.Source), + }) + } + return marshalJSON(listCrossRefsResponse{Namespace: ns, Direction: direction, Refs: refs}) + })) +} diff --git a/internal/adapters/inbound/mcp/handler_docs.go b/internal/adapters/inbound/mcp/handler_docs.go index 002cd2f..9c87198 100644 --- a/internal/adapters/inbound/mcp/handler_docs.go +++ b/internal/adapters/inbound/mcp/handler_docs.go @@ -10,6 +10,8 @@ import ( "strings" "github.com/mark3labs/mcp-go/mcp" + "github.com/tae2089/trace" + "github.com/tae2089/code-context-graph/internal/app/wiki" requestctx "github.com/tae2089/code-context-graph/internal/ctx" ) @@ -104,6 +106,11 @@ func (h *handlers) searchDocs(ctx context.Context, request mcp.CallToolRequest) if h.deps.Docs.Retrieval == nil { return mcp.NewToolResultError("DB is not configured"), nil } + + if namespaces := requestNamespaces(request); len(namespaces) > 0 { + return h.searchDocsFederated(ctx, query, limit, namespaces) + } + return finalizeToolResult(h.cachedExecute(ctx, "search_docs:db:", map[string]any{"query": query, "limit": limit, "namespace": namespace}, func() (string, error) { results, err := h.searchDocsFromDB(ctx, namespace, query, limit) if err != nil { @@ -114,6 +121,34 @@ func (h *handlers) searchDocs(ctx context.Context, request mcp.CallToolRequest) })) } +// federatedDocsEntry labels one namespace's documentation candidates in a federated response. +// @intent keep doc candidates attributable to their source repository. +type federatedDocsEntry struct { + Namespace string `json:"namespace"` + Results []wiki.SearchResult `json:"results"` +} + +// searchDocsFederated searches documentation candidates across several namespaces. +// @intent let one docs query cover multiple repositories with per-namespace grouping. +func (h *handlers) searchDocsFederated(ctx context.Context, query string, limit int, namespaces []string) (*mcp.CallToolResult, error) { + return finalizeToolResult(h.cachedExecute(ctx, "search_docs:db:", map[string]any{"query": query, "limit": limit, "namespaces": namespaces}, func() (string, error) { + entries := make([]federatedDocsEntry, 0, len(namespaces)) + for _, ns := range namespaces { + nsCtx := requestctx.WithNamespace(ctx, ns) + results, err := h.searchDocsFromDB(nsCtx, ns, query, limit) + if err != nil { + return "", newToolResultErr(err.Error()) + } + entries = append(entries, federatedDocsEntry{Namespace: ns, Results: results}) + } + b, err := json.Marshal(map[string]any{"namespaces": entries}) + if err != nil { + return "", trace.Wrap(err, "marshal result") + } + return string(b), nil + })) +} + // @intent search persisted graph nodes directly from DB and search backend. // @requires ctx must carry the selected namespace for SearchBackend.Query. // @ensures returns SearchResult-compatible JSON items without requiring generated index files. diff --git a/internal/adapters/inbound/mcp/handler_federated_test.go b/internal/adapters/inbound/mcp/handler_federated_test.go new file mode 100644 index 0000000..7bc7a5e --- /dev/null +++ b/internal/adapters/inbound/mcp/handler_federated_test.go @@ -0,0 +1,244 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/mcp" +) + +func seedFederatedNamespaces(t *testing.T, deps *Deps) { + t.Helper() + write := func(dir, rel, content string) string { + t.Helper() + full := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + return full + } + alphaDir := t.TempDir() + betaDir := t.TempDir() + deps.Runtime.RepoRoot = filepath.Dir(alphaDir) + write(alphaDir, "pay.go", "package alpha\n\nfunc PaymentProcess() string {\n\treturn \"payment\"\n}\n") + write(betaDir, "refund.go", "package beta\n\nfunc PaymentRefund() string {\n\treturn \"payment\"\n}\n") + + deps.Runtime.RepoRoot = alphaDir + if res := callTool(t, deps, "build_or_update_graph", map[string]any{"path": alphaDir, "namespace": "alpha"}); res.IsError { + t.Fatalf("parse alpha failed: %+v", res) + } + deps.Runtime.RepoRoot = betaDir + if res := callTool(t, deps, "build_or_update_graph", map[string]any{"path": betaDir, "namespace": "beta"}); res.IsError { + t.Fatalf("parse beta failed: %+v", res) + } +} + +func resultTextOf(t *testing.T, result *mcp.CallToolResult) string { + t.Helper() + if result.IsError { + t.Fatalf("tool returned error: %+v", result.Content) + } + for _, content := range result.Content { + if text, ok := content.(mcp.TextContent); ok { + return text.Text + } + if text, ok := content.(*mcp.TextContent); ok { + return text.Text + } + } + t.Fatal("no text content in result") + return "" +} + +func TestSearch_FederatesAcrossNamespaces(t *testing.T) { + deps := setupTestDeps(t) + seedFederatedNamespaces(t, deps) + + result := callTool(t, deps, "search", map[string]any{ + "query": "Payment", + "namespaces": []string{"alpha", "beta"}, + }) + var items []struct { + QualifiedName string `json:"qualified_name"` + Namespace string `json:"namespace"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &items); err != nil { + t.Fatalf("unmarshal: %v", err) + } + seen := map[string]string{} + for _, item := range items { + seen[item.QualifiedName] = item.Namespace + } + if seen["alpha.PaymentProcess"] != "alpha" || seen["beta.PaymentRefund"] != "beta" { + t.Fatalf("federated search items = %v, want hits from both namespaces with labels", seen) + } +} + +func TestSearch_FederatedLimitDoesNotStarveNamespaces(t *testing.T) { + deps := setupTestDeps(t) + + alphaDir := t.TempDir() + betaDir := t.TempDir() + var alphaSrc strings.Builder + alphaSrc.WriteString("package alpha\n") + for i := 1; i <= 5; i++ { + fmt.Fprintf(&alphaSrc, "\nfunc Payment%d() string {\n\treturn \"payment\"\n}\n", i) + } + if err := os.WriteFile(filepath.Join(alphaDir, "pay.go"), []byte(alphaSrc.String()), 0o644); err != nil { + t.Fatalf("write alpha: %v", err) + } + if err := os.WriteFile(filepath.Join(betaDir, "refund.go"), []byte("package beta\n\nfunc PaymentZz() string {\n\treturn \"payment\"\n}\n"), 0o644); err != nil { + t.Fatalf("write beta: %v", err) + } + deps.Runtime.RepoRoot = alphaDir + if res := callTool(t, deps, "build_or_update_graph", map[string]any{"path": alphaDir, "namespace": "alpha"}); res.IsError { + t.Fatalf("parse alpha failed: %+v", res) + } + deps.Runtime.RepoRoot = betaDir + if res := callTool(t, deps, "build_or_update_graph", map[string]any{"path": betaDir, "namespace": "beta"}); res.IsError { + t.Fatalf("parse beta failed: %+v", res) + } + + result := callTool(t, deps, "search", map[string]any{ + "query": "Payment", + "limit": 3, + "namespaces": []string{"alpha", "beta"}, + }) + var items []struct { + QualifiedName string `json:"qualified_name"` + Namespace string `json:"namespace"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &items); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(items) != 3 { + t.Fatalf("result count = %d, want limit 3", len(items)) + } + perNS := map[string]int{} + for _, item := range items { + perNS[item.Namespace]++ + } + if perNS["beta"] == 0 { + t.Fatalf("federated limit starved namespace beta: %v", perNS) + } + if perNS["alpha"] == 0 { + t.Fatalf("federated limit starved namespace alpha: %v", perNS) + } +} + +func TestSearch_SingleNamespaceResponseUnchanged(t *testing.T) { + deps := setupTestDeps(t) + seedFederatedNamespaces(t, deps) + + result := callTool(t, deps, "search", map[string]any{"query": "Payment", "namespace": "alpha"}) + text := resultTextOf(t, result) + var raw []map[string]any + if err := json.Unmarshal([]byte(text), &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(raw) == 0 { + t.Fatal("single-namespace search returned no results") + } + for _, item := range raw { + if _, exists := item["namespace"]; exists { + t.Fatalf("single-namespace response gained a namespace field: %v", item) + } + } +} + +func TestListGraphStats_FederatesAcrossNamespaces(t *testing.T) { + deps := setupTestDeps(t) + seedFederatedNamespaces(t, deps) + + result := callTool(t, deps, "list_graph_stats", map[string]any{"namespaces": []string{"alpha", "beta"}}) + var payload struct { + Namespaces []struct { + Namespace string `json:"namespace"` + TotalNodes int64 `json:"total_nodes"` + } `json:"namespaces"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(payload.Namespaces) != 2 { + t.Fatalf("stats groups = %d, want 2", len(payload.Namespaces)) + } + for _, group := range payload.Namespaces { + if group.TotalNodes == 0 { + t.Fatalf("namespace %q has zero nodes in federated stats", group.Namespace) + } + } +} + +func TestQueryGraph_FederatesAcrossNamespaces(t *testing.T) { + deps := setupTestDeps(t) + seedFederatedNamespaces(t, deps) + + result := callTool(t, deps, "query_graph", map[string]any{ + "pattern": "callers_of", + "target": "alpha.PaymentProcess", + "namespaces": []string{"alpha", "beta"}, + }) + var payload struct { + Pattern string `json:"pattern"` + Namespaces []struct { + Namespace string `json:"namespace"` + Response json.RawMessage `json:"response,omitempty"` + Error string `json:"error,omitempty"` + } `json:"namespaces"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(payload.Namespaces) != 2 { + t.Fatalf("query groups = %d, want 2", len(payload.Namespaces)) + } + byNS := map[string]json.RawMessage{} + errsByNS := map[string]string{} + for _, group := range payload.Namespaces { + byNS[group.Namespace] = group.Response + errsByNS[group.Namespace] = group.Error + } + if len(byNS["alpha"]) == 0 { + t.Fatalf("alpha response missing: %+v", payload) + } + if errsByNS["beta"] == "" { + t.Fatalf("beta should report a per-namespace error for missing file, got %+v", payload) + } +} + +func TestSearchDocs_FederatesAcrossNamespaces(t *testing.T) { + deps := setupTestDeps(t) + seedFederatedNamespaces(t, deps) + + result := callTool(t, deps, "search_docs", map[string]any{ + "query": "Payment", + "namespaces": []string{"alpha", "beta"}, + }) + var payload struct { + Namespaces []struct { + Namespace string `json:"namespace"` + Results []struct { + Label string `json:"label"` + } `json:"results"` + } `json:"namespaces"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(payload.Namespaces) != 2 { + t.Fatalf("docs groups = %d, want 2", len(payload.Namespaces)) + } + for _, group := range payload.Namespaces { + if len(group.Results) == 0 { + t.Fatalf("namespace %q returned no doc candidates", group.Namespace) + } + } +} diff --git a/internal/adapters/inbound/mcp/handler_parse.go b/internal/adapters/inbound/mcp/handler_parse.go index 33a7207..def4e5d 100644 --- a/internal/adapters/inbound/mcp/handler_parse.go +++ b/internal/adapters/inbound/mcp/handler_parse.go @@ -11,6 +11,7 @@ import ( "github.com/tae2089/trace" flowspkg "github.com/tae2089/code-context-graph/internal/app/analyze/flow" + "github.com/tae2089/code-context-graph/internal/app/crossref" "github.com/tae2089/code-context-graph/internal/app/ingest" "github.com/tae2089/code-context-graph/internal/app/ingest/workflow" "github.com/tae2089/code-context-graph/internal/obs" @@ -72,7 +73,7 @@ func (h *handlers) graphService() *workflow.Service { graphStore = candidate } parseCache, _ := h.deps.Build.Store.(ingest.ParseCache) - return &workflow.Service{ + svc := &workflow.Service{ Store: graphStore, UnitOfWork: h.deps.Build.UnitOfWork, Search: h.deps.Build.Search, @@ -80,6 +81,10 @@ func (h *handlers) graphService() *workflow.Service { Parsers: walkers, Logger: h.logger(), } + if crossRefStore, ok := h.deps.Build.Store.(crossref.Store); ok { + svc.CrossRefs = crossref.New(crossRefStore) + } + return svc } // parseProject parses a project directory and stores discovered graph elements. diff --git a/internal/adapters/inbound/mcp/handler_query.go b/internal/adapters/inbound/mcp/handler_query.go index eed7a1b..7f77ec3 100644 --- a/internal/adapters/inbound/mcp/handler_query.go +++ b/internal/adapters/inbound/mcp/handler_query.go @@ -3,6 +3,8 @@ package mcp import ( "context" + "encoding/json" + "errors" "fmt" "strings" @@ -10,6 +12,7 @@ import ( "github.com/tae2089/code-context-graph/internal/app/analyze" querypkg "github.com/tae2089/code-context-graph/internal/app/analyze/query" searchrank "github.com/tae2089/code-context-graph/internal/app/search/rank" + requestctx "github.com/tae2089/code-context-graph/internal/ctx" "github.com/tae2089/code-context-graph/internal/domain/graph" "github.com/tae2089/code-context-graph/internal/domain/reference" "github.com/tae2089/code-context-graph/internal/pathspec" @@ -87,12 +90,22 @@ type queryGraphResponse struct { // searchResultItem summarizes one node hit returned by full-text search. // @intent preserve a stable per-item DTO for search responses. +// @domainRule Namespace is set only in federated (multi-namespace) mode so single-namespace responses stay unchanged. type searchResultItem struct { ID uint `json:"id"` QualifiedName string `json:"qualified_name"` Kind graph.NodeKind `json:"kind"` Name string `json:"name"` FilePath string `json:"file_path"` + Namespace string `json:"namespace,omitempty"` +} + +// federatedNamespaceEntry wraps one namespace's result inside a federated tool response. +// @intent label per-namespace payloads and isolate per-namespace failures in federated reads. +type federatedNamespaceEntry struct { + Namespace string `json:"namespace"` + Response json.RawMessage `json:"response,omitempty"` + Error string `json:"error,omitempty"` } // fileSummaryResponse is the typed wire payload for file_summary queryGraph requests. @@ -201,6 +214,10 @@ func (h *handlers) search(ctx context.Context, request mcp.CallToolRequest) (*mc return mcp.NewToolResultError("SearchBackend not configured"), nil } + if namespaces := requestNamespaces(request); len(namespaces) > 0 { + return h.searchFederated(ctx, query, limit, pathPrefix, namespaces) + } + return finalizeToolResult(h.cachedExecute(ctx, "search:", map[string]any{"query": query, "limit": limit, "path": pathPrefix, "namespace": requestNamespace(request)}, func() (string, error) { // Over-fetch a wider candidate pool so structural reranking can promote // good matches that FTS ranked below the caller's limit, and so path @@ -238,6 +255,92 @@ func (h *handlers) search(ctx context.Context, request mcp.CallToolRequest) (*mc })) } +// searchFederated fans full-text search out over an explicit namespace set and merges reranked hits. +// @intent answer one search across several repositories with per-item namespace labels. +// @domainRule each namespace is queried in isolation; reranking happens once over the merged candidate pool. +func (h *handlers) searchFederated(ctx context.Context, query string, limit int, pathPrefix string, namespaces []string) (*mcp.CallToolResult, error) { + log := h.logger() + return finalizeToolResult(h.cachedExecute(ctx, "search:", map[string]any{"query": query, "limit": limit, "path": pathPrefix, "namespaces": namespaces}, func() (string, error) { + var merged []graph.Node + for _, ns := range namespaces { + nsCtx := requestctx.WithNamespace(ctx, ns) + nodes, err := h.deps.Graph.Search.Query(nsCtx, query, searchrank.FetchLimit(limit)) + if err != nil { + log.Error("federated search error", "query", query, "namespace", ns, trace.SlogError(err)) + return "", trace.Wrap(err, "federated search error") + } + for i := range nodes { + nodes[i].Namespace = ns + } + merged = append(merged, nodes...) + } + + if pathPrefix != "" { + filtered := merged[:0] + for _, n := range merged { + if pathspec.HasPathPrefix(n.FilePath, pathPrefix) { + filtered = append(filtered, n) + } + } + merged = filtered + } + + merged = searchrank.Rerank(query, merged, 0) + merged = selectWithNamespaceQuota(merged, limit, len(namespaces)) + log.Info("federated search completed", "query", query, "namespaces", namespaces, "result_count", len(merged)) + + items := make([]searchResultItem, len(merged)) + for i, n := range merged { + items[i] = searchResultItem{ID: n.ID, QualifiedName: n.QualifiedName, Kind: n.Kind, Name: n.Name, FilePath: n.FilePath, Namespace: n.Namespace} + } + result, err := marshalJSON(items) + if err != nil { + return "", trace.Wrap(err, "marshal result") + } + return result, nil + })) +} + +// selectWithNamespaceQuota bounds globally-ranked federated results while guaranteeing +// every namespace with hits at least limit/namespaceCount slots (minimum one). +// @intent keep one high-scoring repository from starving the other namespaces out of a federated result. +// @domainRule remaining slots after the per-namespace quota pass are filled in global rank order. +func selectWithNamespaceQuota(ranked []graph.Node, limit, namespaceCount int) []graph.Node { + if limit <= 0 || len(ranked) <= limit { + return ranked + } + quota := max(limit/max(namespaceCount, 1), 1) + chosen := make([]bool, len(ranked)) + count := 0 + perNamespace := map[string]int{} + for i, n := range ranked { + if count == limit { + break + } + if perNamespace[n.Namespace] < quota { + chosen[i] = true + perNamespace[n.Namespace]++ + count++ + } + } + for i := range ranked { + if count == limit { + break + } + if !chosen[i] { + chosen[i] = true + count++ + } + } + selected := make([]graph.Node, 0, limit) + for i, n := range ranked { + if chosen[i] { + selected = append(selected, n) + } + } + return selected +} + // getAnnotation returns stored annotation metadata for a graph node. // @intent fetch stored annotation tags and summary data so semantic search results can show business context. // @param request qualified_name is the fully qualified node name whose annotations should be loaded. @@ -338,6 +441,10 @@ func (h *handlers) queryGraph(ctx context.Context, request mcp.CallToolRequest) return mcp.NewToolResultError(fmt.Sprintf("unknown pattern: %q", pattern)), nil } + if namespaces := requestNamespaces(request); len(namespaces) > 0 { + return h.queryGraphFederated(ctx, pattern, target, limit, offset, includeFallbackCalls, namespaces) + } + return finalizeToolResult(h.cachedExecute(ctx, "query_graph:", map[string]any{ "pattern": pattern, "target": target, @@ -346,171 +453,214 @@ func (h *handlers) queryGraph(ctx context.Context, request mcp.CallToolRequest) "include_fallback_calls": includeFallbackCalls, "namespace": requestNamespace(request), }, func() (string, error) { - // file_summary does not require node lookup. - if pattern == "file_summary" { - if h.deps.Graph.Query == nil { - return "", newToolResultErr("QueryService not configured") - } - summary, err := h.deps.Graph.Query.FileSummaryOf(ctx, target) - if err != nil { - return "", newToolResultErr(fmt.Sprintf("file summary error: %v", err)) - } - result, err := marshalJSON(fileSummaryResponse{Pattern: pattern, Target: target, Results: summary}) - if err != nil { - return "", trace.Wrap(err, "marshal result") - } - return result, nil - } + return h.queryGraphInNamespace(ctx, pattern, target, limit, offset, includeFallbackCalls) + })) +} - // The remaining patterns resolve the target node first. - node, err := h.deps.Graph.Store.GetNode(ctx, target) - if err != nil { - return "", trace.Wrap(err, "store error") - } - if node == nil { - if h.deps.Graph.Query == nil { - return "", nodeNotFoundErr(target) - } - matches, err := h.deps.Graph.Query.FindExactNameMatches(ctx, target, 10) +// queryGraphFederatedResponse is the typed wire payload for federated query_graph calls. +// @intent group per-namespace traversal outcomes under one envelope with per-namespace errors. +type queryGraphFederatedResponse struct { + Pattern string `json:"pattern"` + Target string `json:"target"` + Namespaces []federatedNamespaceEntry `json:"namespaces"` +} + +// queryGraphFederated runs one predefined query across several namespaces and groups the outcomes. +// @intent keep federated traversal per-namespace so a missing target in one namespace never fails the rest. +func (h *handlers) queryGraphFederated(ctx context.Context, pattern, target string, limit, offset int, includeFallbackCalls bool, namespaces []string) (*mcp.CallToolResult, error) { + return finalizeToolResult(h.cachedExecute(ctx, "query_graph:", map[string]any{ + "pattern": pattern, + "target": target, + "limit": limit, + "offset": offset, + "include_fallback_calls": includeFallbackCalls, + "namespaces": namespaces, + }, func() (string, error) { + entries := make([]federatedNamespaceEntry, 0, len(namespaces)) + for _, ns := range namespaces { + nsCtx := requestctx.WithNamespace(ctx, ns) + payload, err := h.queryGraphInNamespace(nsCtx, pattern, target, limit, offset, includeFallbackCalls) if err != nil { - return "", trace.Wrap(err, "query target fallback") - } - switch len(matches) { - case 0: - return "", nodeNotFoundErr(target) - case 1: - node, err = h.deps.Graph.Store.GetNode(ctx, matches[0].QualifiedName) - if err != nil { - return "", trace.Wrap(err, "store fallback lookup") + var resultErr *toolResultErr + if errors.As(err, &resultErr) { + entries = append(entries, federatedNamespaceEntry{Namespace: ns, Error: err.Error()}) + continue } - if node == nil { - return "", nodeNotFoundErr(matches[0].QualifiedName) - } - default: - return "", newToolResultErr(compactQueryTargetAmbiguity(target, matches)) + return "", trace.Wrap(err, "federated query error") } + entries = append(entries, federatedNamespaceEntry{Namespace: ns, Response: json.RawMessage(payload)}) } + return marshalJSON(queryGraphFederatedResponse{Pattern: pattern, Target: target, Namespaces: entries}) + })) +} +// queryGraphInNamespace runs one predefined graph query inside the context namespace. +// @intent share one traversal implementation between single-namespace and federated query_graph calls. +func (h *handlers) queryGraphInNamespace(ctx context.Context, pattern, target string, limit, offset int, includeFallbackCalls bool) (string, error) { + // file_summary does not require node lookup. + if pattern == "file_summary" { if h.deps.Graph.Query == nil { return "", newToolResultErr("QueryService not configured") } - - queryOpts := querypkg.QueryOptions{ - IncludeFallbackCalls: &includeFallbackCalls, - Limit: limit, - Offset: offset, - } - - var nodes []graph.Node - var totalCount int - var page querypkg.PagedNodes - switch pattern { - case "callers_of": - page, err = h.deps.Graph.Query.CallersOfPage(ctx, node.ID, queryOpts) - nodes = page.Nodes - totalCount = page.TotalCount - case "callees_of": - page, err = h.deps.Graph.Query.CalleesOfPage(ctx, node.ID, queryOpts) - nodes = page.Nodes - totalCount = page.TotalCount - case "imports_of": - page, err = h.deps.Graph.Query.ImportsOfPage(ctx, node.ID, queryOpts) - nodes = page.Nodes - totalCount = page.TotalCount - case "importers_of": - page, err = h.deps.Graph.Query.ImportersOfPage(ctx, node.ID, queryOpts) - nodes = page.Nodes - totalCount = page.TotalCount - case "children_of": - page, err = h.deps.Graph.Query.ChildrenOfPage(ctx, node.ID, queryOpts) - nodes = page.Nodes - totalCount = page.TotalCount - case "tests_for": - page, err = h.deps.Graph.Query.TestsForPage(ctx, node.ID, queryOpts) - nodes = page.Nodes - totalCount = page.TotalCount - case "inheritors_of": - page, err = h.deps.Graph.Query.InheritorsOfPage(ctx, node.ID, queryOpts) - nodes = page.Nodes - totalCount = page.TotalCount + summary, err := h.deps.Graph.Query.FileSummaryOf(ctx, target) + if err != nil { + return "", newToolResultErr(fmt.Sprintf("file summary error: %v", err)) } - + result, err := marshalJSON(fileSummaryResponse{Pattern: pattern, Target: target, Results: summary}) if err != nil { - return "", trace.Wrap(err, "query error") + return "", trace.Wrap(err, "marshal result") } + return result, nil + } - neighborEdgeByNodeID := map[uint]graph.Edge{} - var strictPage querypkg.PagedNodes - if pattern == "callers_of" || pattern == "callees_of" { - if includeFallbackCalls { - strictOpts := querypkg.QueryOptions{IncludeFallbackCalls: &strictFalse, Limit: 1, Offset: 0} - switch pattern { - case "callers_of": - strictPage, err = h.deps.Graph.Query.CallersOfPage(ctx, node.ID, strictOpts) - case "callees_of": - strictPage, err = h.deps.Graph.Query.CalleesOfPage(ctx, node.ID, strictOpts) - } - if err != nil { - return "", trace.Wrap(err, "strict query error") - } - } - // Only augment edge evidence for nodes on the current response page. - neighborEdgeByNodeID, err = h.callQueryPatternEdges(ctx, node.ID, pattern, nodes) + // The remaining patterns resolve the target node first. + node, err := h.deps.Graph.Store.GetNode(ctx, target) + if err != nil { + return "", trace.Wrap(err, "store error") + } + if node == nil { + if h.deps.Graph.Query == nil { + return "", nodeNotFoundErr(target) + } + matches, err := h.deps.Graph.Query.FindExactNameMatches(ctx, target, 10) + if err != nil { + return "", trace.Wrap(err, "query target fallback") + } + switch len(matches) { + case 0: + return "", nodeNotFoundErr(target) + case 1: + node, err = h.deps.Graph.Store.GetNode(ctx, matches[0].QualifiedName) if err != nil { - return "", trace.Wrap(err, "query evidence edges") + return "", trace.Wrap(err, "store fallback lookup") } + if node == nil { + return "", nodeNotFoundErr(matches[0].QualifiedName) + } + default: + return "", newToolResultErr(compactQueryTargetAmbiguity(target, matches)) } + } - strictTotal := 0 - if pattern == "callers_of" || pattern == "callees_of" { - if includeFallbackCalls { - strictTotal = strictPage.TotalCount - } else { - strictTotal = totalCount + if h.deps.Graph.Query == nil { + return "", newToolResultErr("QueryService not configured") + } + + queryOpts := querypkg.QueryOptions{ + IncludeFallbackCalls: &includeFallbackCalls, + Limit: limit, + Offset: offset, + } + + var nodes []graph.Node + var totalCount int + var page querypkg.PagedNodes + switch pattern { + case "callers_of": + page, err = h.deps.Graph.Query.CallersOfPage(ctx, node.ID, queryOpts) + nodes = page.Nodes + totalCount = page.TotalCount + case "callees_of": + page, err = h.deps.Graph.Query.CalleesOfPage(ctx, node.ID, queryOpts) + nodes = page.Nodes + totalCount = page.TotalCount + case "imports_of": + page, err = h.deps.Graph.Query.ImportsOfPage(ctx, node.ID, queryOpts) + nodes = page.Nodes + totalCount = page.TotalCount + case "importers_of": + page, err = h.deps.Graph.Query.ImportersOfPage(ctx, node.ID, queryOpts) + nodes = page.Nodes + totalCount = page.TotalCount + case "children_of": + page, err = h.deps.Graph.Query.ChildrenOfPage(ctx, node.ID, queryOpts) + nodes = page.Nodes + totalCount = page.TotalCount + case "tests_for": + page, err = h.deps.Graph.Query.TestsForPage(ctx, node.ID, queryOpts) + nodes = page.Nodes + totalCount = page.TotalCount + case "inheritors_of": + page, err = h.deps.Graph.Query.InheritorsOfPage(ctx, node.ID, queryOpts) + nodes = page.Nodes + totalCount = page.TotalCount + } + + if err != nil { + return "", trace.Wrap(err, "query error") + } + + neighborEdgeByNodeID := map[uint]graph.Edge{} + var strictPage querypkg.PagedNodes + if pattern == "callers_of" || pattern == "callees_of" { + if includeFallbackCalls { + strictOpts := querypkg.QueryOptions{IncludeFallbackCalls: &strictFalse, Limit: 1, Offset: 0} + switch pattern { + case "callers_of": + strictPage, err = h.deps.Graph.Query.CallersOfPage(ctx, node.ID, strictOpts) + case "callees_of": + strictPage, err = h.deps.Graph.Query.CalleesOfPage(ctx, node.ID, strictOpts) + } + if err != nil { + return "", trace.Wrap(err, "strict query error") } } - truncated := false - nextOffset := 0 - if offset+len(nodes) < totalCount { - truncated = true - nextOffset = offset + len(nodes) + // Only augment edge evidence for nodes on the current response page. + neighborEdgeByNodeID, err = h.callQueryPatternEdges(ctx, node.ID, pattern, nodes) + if err != nil { + return "", trace.Wrap(err, "query evidence edges") } + } - qgResults := make([]queryGraphResultItem, len(nodes)) - for i, n := range nodes { - item := queryGraphResultItem{ID: n.ID, QualifiedName: n.QualifiedName, Kind: n.Kind, Name: n.Name, FilePath: n.FilePath} - if pattern == "callers_of" || pattern == "callees_of" { - edge, hasEvidence := neighborEdgeByNodeID[n.ID] - if hasEvidence && edge.Kind == graph.EdgeKindCalls { - item.Confidence = "strict" - item.EdgeKind = string(graph.EdgeKindCalls) - } else { - item.Confidence = "tentative" - item.EdgeKind = string(graph.EdgeKindFallbackCalls) - } - if hasEvidence { - item.Evidence = &queryGraphEvidence{FilePath: edge.FilePath, Line: edge.Line, Fingerprint: edge.Fingerprint} - } - } - qgResults[i] = item + strictTotal := 0 + if pattern == "callers_of" || pattern == "callees_of" { + if includeFallbackCalls { + strictTotal = strictPage.TotalCount + } else { + strictTotal = totalCount } + } + truncated := false + nextOffset := 0 + if offset+len(nodes) < totalCount { + truncated = true + nextOffset = offset + len(nodes) + } - metadata := queryGraphMetadata{Limit: limit, Offset: offset, ReturnedCount: len(qgResults), TotalCount: totalCount, Truncated: truncated} - if truncated { - metadata.NextOffset = &nextOffset - } + qgResults := make([]queryGraphResultItem, len(nodes)) + for i, n := range nodes { + item := queryGraphResultItem{ID: n.ID, QualifiedName: n.QualifiedName, Kind: n.Kind, Name: n.Name, FilePath: n.FilePath} if pattern == "callers_of" || pattern == "callees_of" { - tentativeCount := totalCount - strictTotal - metadata.StrictCount = &strictTotal - metadata.TentativeCount = &tentativeCount - metadata.IncludeFallbackCalls = &includeFallbackCalls - } - result, err := marshalJSON(queryGraphResponse{Pattern: pattern, Target: target, Results: qgResults, Metadata: metadata, Evidence: h.namespaceEvidenceFromContext(ctx)}) - if err != nil { - return "", trace.Wrap(err, "marshal result") + edge, hasEvidence := neighborEdgeByNodeID[n.ID] + if hasEvidence && edge.Kind == graph.EdgeKindCalls { + item.Confidence = "strict" + item.EdgeKind = string(graph.EdgeKindCalls) + } else { + item.Confidence = "tentative" + item.EdgeKind = string(graph.EdgeKindFallbackCalls) + } + if hasEvidence { + item.Evidence = &queryGraphEvidence{FilePath: edge.FilePath, Line: edge.Line, Fingerprint: edge.Fingerprint} + } } - return result, nil - })) + qgResults[i] = item + } + + metadata := queryGraphMetadata{Limit: limit, Offset: offset, ReturnedCount: len(qgResults), TotalCount: totalCount, Truncated: truncated} + if truncated { + metadata.NextOffset = &nextOffset + } + if pattern == "callers_of" || pattern == "callees_of" { + tentativeCount := totalCount - strictTotal + metadata.StrictCount = &strictTotal + metadata.TentativeCount = &tentativeCount + metadata.IncludeFallbackCalls = &includeFallbackCalls + } + result, err := marshalJSON(queryGraphResponse{Pattern: pattern, Target: target, Results: qgResults, Metadata: metadata, Evidence: h.namespaceEvidenceFromContext(ctx)}) + if err != nil { + return "", trace.Wrap(err, "marshal result") + } + return result, nil } // callQueryPatternEdges loads only edge evidence for current page nodes. @@ -552,20 +702,15 @@ func (h *handlers) listGraphStats(ctx context.Context, request mcp.CallToolReque log := h.logger() log.Info("list_graph_stats called") + if namespaces := requestNamespaces(request); len(namespaces) > 0 { + return h.listGraphStatsFederated(ctx, namespaces) + } + return finalizeToolResult(h.cachedExecute(ctx, "list_graph_stats:", map[string]any{"namespace": requestNamespace(request)}, func() (string, error) { - stats, err := h.deps.Graph.Statistics.GraphStatistics(ctx) + statsData, err := h.graphStatsInNamespace(ctx) if err != nil { return "", err } - - statsData := listGraphStatsResponse{ - TotalNodes: stats.NodeCount, - TotalEdges: stats.EdgeCount, - NodesByKind: stats.NodesByKind, - NodesByLanguage: stats.NodesByLanguage, - EdgesByKind: stats.EdgesByKind, - Evidence: h.namespaceEvidenceFromContext(ctx), - } result, err := marshalJSON(statsData) if err != nil { return "", trace.Wrap(err, "marshal result") @@ -574,6 +719,47 @@ func (h *handlers) listGraphStats(ctx context.Context, request mcp.CallToolReque })) } +// graphStatsInNamespace loads the statistics payload for the context namespace. +// @intent share one statistics assembly between single-namespace and federated calls. +func (h *handlers) graphStatsInNamespace(ctx context.Context) (listGraphStatsResponse, error) { + stats, err := h.deps.Graph.Statistics.GraphStatistics(ctx) + if err != nil { + return listGraphStatsResponse{}, err + } + return listGraphStatsResponse{ + TotalNodes: stats.NodeCount, + TotalEdges: stats.EdgeCount, + NodesByKind: stats.NodesByKind, + NodesByLanguage: stats.NodesByLanguage, + EdgesByKind: stats.EdgesByKind, + Evidence: h.namespaceEvidenceFromContext(ctx), + }, nil +} + +// federatedGraphStatsEntry labels one namespace's statistics inside a federated response. +// @intent keep per-namespace statistics separable instead of summing unrelated graphs. +type federatedGraphStatsEntry struct { + Namespace string `json:"namespace"` + listGraphStatsResponse +} + +// listGraphStatsFederated returns statistics grouped per namespace. +// @intent give one call visibility over several repositories without merging their counts. +func (h *handlers) listGraphStatsFederated(ctx context.Context, namespaces []string) (*mcp.CallToolResult, error) { + return finalizeToolResult(h.cachedExecute(ctx, "list_graph_stats:", map[string]any{"namespaces": namespaces}, func() (string, error) { + entries := make([]federatedGraphStatsEntry, 0, len(namespaces)) + for _, ns := range namespaces { + nsCtx := requestctx.WithNamespace(ctx, ns) + statsData, err := h.graphStatsInNamespace(nsCtx) + if err != nil { + return "", trace.Wrap(err, "federated stats error") + } + entries = append(entries, federatedGraphStatsEntry{Namespace: ns, listGraphStatsResponse: statsData}) + } + return marshalJSON(map[string]any{"namespaces": entries}) + })) +} + // validateQueryGraphLimit checks that the limit parameter for queryGraph is within acceptable bounds. // @intent enforce reasonable limits on queryGraph results to prevent excessive load and encourage pagination. func validateQueryGraphLimit(limit int) error { diff --git a/internal/adapters/inbound/mcp/handlers.go b/internal/adapters/inbound/mcp/handlers.go index 5763eb3..c0fbb4f 100644 --- a/internal/adapters/inbound/mcp/handlers.go +++ b/internal/adapters/inbound/mcp/handlers.go @@ -106,6 +106,27 @@ func requestNamespace(request mcp.CallToolRequest) string { return request.GetString("namespace", "") } +// requestNamespaces reads the multi-namespace federation argument. +// @intent let read tools fan out over several namespaces while keeping the single-namespace contract untouched. +// @domainRule values are normalized and deduplicated; an empty selection means single-namespace mode. +func requestNamespaces(request mcp.CallToolRequest) []string { + raw := request.GetStringSlice("namespaces", nil) + if len(raw) == 0 { + return nil + } + seen := make(map[string]bool, len(raw)) + namespaces := make([]string, 0, len(raw)) + for _, ns := range raw { + normalized := requestctx.Normalize(ns) + if seen[normalized] { + continue + } + seen[normalized] = true + namespaces = append(namespaces, normalized) + } + return namespaces +} + // makeCacheKey builds a cache key from a prefix and JSON-encoded parameters. // @intent turn request parameters into a stable string key so tool-result caching can reuse previous responses. // @param prefix scopes the cache namespace for a tool family. @@ -218,12 +239,14 @@ func finalizeToolResult(result string, err error) (*mcp.CallToolResult, error) { // nodeSummary is a compact node response payload shared by graph handlers. // @intent reuse one typed node representation across multiple tool responses. +// @domainRule Namespace is set only by cross-namespace analysis so existing responses keep their wire format. type nodeSummary struct { ID uint `json:"id"` QualifiedName string `json:"qualified_name"` Kind graph.NodeKind `json:"kind"` Name string `json:"name"` FilePath string `json:"file_path"` + Namespace string `json:"namespace,omitempty"` } // nodeToSummary converts a graph node into a compact typed response payload. diff --git a/internal/adapters/inbound/mcp/server_test.go b/internal/adapters/inbound/mcp/server_test.go index aad676a..fa6f7ca 100644 --- a/internal/adapters/inbound/mcp/server_test.go +++ b/internal/adapters/inbound/mcp/server_test.go @@ -23,6 +23,7 @@ func TestMCPServer_ListTools(t *testing.T) { "search", "get_annotation", "trace_flow", + "list_cross_refs", "build_or_update_graph", "run_postprocess", "query_graph", @@ -63,8 +64,8 @@ func TestMCPServer_ListTools_Count(t *testing.T) { srv := NewServer(deps) tools := srv.ListTools() - if len(tools) != 17 { - t.Fatalf("expected 17 tools, got %d", len(tools)) + if len(tools) != 18 { + t.Fatalf("expected 18 tools, got %d", len(tools)) } } diff --git a/internal/adapters/inbound/mcp/testhelpers_test.go b/internal/adapters/inbound/mcp/testhelpers_test.go index 4cee259..e2b5753 100644 --- a/internal/adapters/inbound/mcp/testhelpers_test.go +++ b/internal/adapters/inbound/mcp/testhelpers_test.go @@ -204,9 +204,14 @@ func groupedTestDeps(st *graphgorm.Store, db *gorm.DB, sb search.Backend, parser Statistics: st, Reader: st, }, - Analysis: AnalysisToolsDeps{Impact: impact.New(st), Flow: flows.New(st), Reader: st}, - Docs: DocsToolsDeps{Retrieval: retrieval.New(reader, reader)}, - Runtime: RuntimeToolsDeps{Logger: log, RepoRoot: os.TempDir()}, + Analysis: AnalysisToolsDeps{ + Impact: impact.New(st), Flow: flows.New(st), Reader: st, + CrossImpact: impact.New(st.CrossNamespaceReader()), + CrossFlow: flows.New(st.CrossNamespaceReader()), + CrossRefs: st, + }, + Docs: DocsToolsDeps{Retrieval: retrieval.New(reader, reader)}, + Runtime: RuntimeToolsDeps{Logger: log, RepoRoot: os.TempDir()}, } } diff --git a/internal/adapters/inbound/mcp/tools_analysis.go b/internal/adapters/inbound/mcp/tools_analysis.go index 894c236..a382093 100644 --- a/internal/adapters/inbound/mcp/tools_analysis.go +++ b/internal/adapters/inbound/mcp/tools_analysis.go @@ -17,6 +17,7 @@ func analysisTools(h *handlers) []server.ServerTool { mcp.WithNumber("depth", mcp.Description("BFS traversal depth"), mcp.DefaultNumber(1)), mcp.WithNumber("max_depth", mcp.Description("Maximum BFS depth returned"), mcp.DefaultNumber(defaultImpactMaxDepth)), mcp.WithNumber("max_nodes", mcp.Description("Maximum nodes returned"), mcp.DefaultNumber(defaultImpactMaxNodes)), + mcp.WithBoolean("cross_namespace", mcp.Description("When true, traversal follows resolved ccg:// cross-namespace refs in both directions; results carry a namespace label")), )...), Handler: h.getImpactRadius, }, @@ -26,9 +27,18 @@ func analysisTools(h *handlers) []server.ServerTool { mcp.WithString("qualified_name", mcp.Description("Fully qualified node name"), mcp.Required()), mcp.WithNumber("max_nodes", mcp.Description("Maximum flow members returned"), mcp.DefaultNumber(defaultTraceMaxNodes)), mcp.WithBoolean("include_fallback_calls", mcp.Description("When false, trace_flow excludes fallback_calls edges; defaults to true")), + mcp.WithBoolean("cross_namespace", mcp.Description("When true, the trace continues across resolved ccg:// cross-namespace refs")), )...), Handler: h.traceFlow, }, + { + Tool: mcp.NewTool("list_cross_refs", withNamespaceParam( + mcp.WithDescription("List materialized ccg:// cross-namespace references for a namespace, as a repository-level dependency map"), + mcp.WithString("direction", mcp.Description("outbound (refs this namespace declares), inbound (refs targeting this namespace), or both (default)")), + mcp.WithString("status", mcp.Description("Filter by resolution status: resolved or dead")), + )...), + Handler: h.listCrossRefs, + }, { Tool: mcp.NewTool("detect_changes", withNamespaceParam( mcp.WithDescription("Detect changed functions with risk scores based on git diff"), diff --git a/internal/adapters/inbound/mcp/tools_docs.go b/internal/adapters/inbound/mcp/tools_docs.go index 6989e2c..400f485 100644 --- a/internal/adapters/inbound/mcp/tools_docs.go +++ b/internal/adapters/inbound/mcp/tools_docs.go @@ -24,6 +24,7 @@ func docsTools(h *handlers) []server.ServerTool { mcp.WithString("query", mcp.Description("Search keyword (case-insensitive)"), mcp.Required()), mcp.WithNumber("limit", mcp.Description("Maximum number of results (default: 10, max: 500)")), mcp.WithString("namespace", mcp.Description("Namespace. When set, searches that namespace's DB-backed documentation candidates.")), + mcp.WithArray("namespaces", mcp.Description("Federate this search across multiple namespaces (overrides 'namespace'); results are grouped per namespace"), mcp.WithStringItems()), ), Handler: h.searchDocs, }, diff --git a/internal/adapters/inbound/mcp/tools_query.go b/internal/adapters/inbound/mcp/tools_query.go index 3fbfe14..c82db6a 100644 --- a/internal/adapters/inbound/mcp/tools_query.go +++ b/internal/adapters/inbound/mcp/tools_query.go @@ -14,6 +14,14 @@ func withNamespaceParam(opts ...mcp.ToolOption) []mcp.ToolOption { ) } +// withFederatedNamespaceParams appends single- and multi-namespace arguments to a tool definition. +// @intent let federated read tools accept an explicit namespace set alongside the canonical single namespace. +func withFederatedNamespaceParams(opts ...mcp.ToolOption) []mcp.ToolOption { + return append(withNamespaceParam(opts...), + mcp.WithArray("namespaces", mcp.Description("Federate this call across multiple namespaces (overrides 'namespace'); results are labeled per namespace"), mcp.WithStringItems()), + ) +} + // queryTools registers lookup and traversal tools over the stored graph. // @intent expose reusable graph query primitives that other prompts and agents can compose. func queryTools(h *handlers) []server.ServerTool { @@ -26,7 +34,7 @@ func queryTools(h *handlers) []server.ServerTool { Handler: h.getNode, }, { - Tool: mcp.NewTool("search", withNamespaceParam( + Tool: mcp.NewTool("search", withFederatedNamespaceParams( mcp.WithDescription("Full-text search across code nodes. Use 'path' to scope results to a module for token-efficient queries."), mcp.WithString("query", mcp.Description("Search query string"), mcp.Required()), mcp.WithNumber("limit", mcp.Description("Maximum number of results"), mcp.DefaultNumber(10)), @@ -42,7 +50,7 @@ func queryTools(h *handlers) []server.ServerTool { Handler: h.getAnnotation, }, { - Tool: mcp.NewTool("query_graph", withNamespaceParam( + Tool: mcp.NewTool("query_graph", withFederatedNamespaceParams( mcp.WithDescription("Run predefined graph queries: callers_of, callees_of, imports_of, importers_of, children_of, tests_for, inheritors_of, file_summary"), mcp.WithString("pattern", mcp.Description("Query pattern"), mcp.Required()), mcp.WithString("target", mcp.Description("Target qualified name or file path"), mcp.Required()), @@ -53,7 +61,7 @@ func queryTools(h *handlers) []server.ServerTool { Handler: h.queryGraph, }, { - Tool: mcp.NewTool("list_graph_stats", withNamespaceParam( + Tool: mcp.NewTool("list_graph_stats", withFederatedNamespaceParams( mcp.WithDescription("Get graph statistics: total nodes, edges, and breakdowns by kind and language"), )...), Handler: h.listGraphStats, diff --git a/internal/adapters/outbound/graphgorm/crossnamespace.go b/internal/adapters/outbound/graphgorm/crossnamespace.go new file mode 100644 index 0000000..4a1cf48 --- /dev/null +++ b/internal/adapters/outbound/graphgorm/crossnamespace.go @@ -0,0 +1,126 @@ +// @index Namespace-agnostic graph reader that merges cross_refs into traversal edges for cross-repository analysis. +package graphgorm + +import ( + "context" + "errors" + + "gorm.io/gorm" + + "github.com/tae2089/trace" + + "github.com/tae2089/code-context-graph/internal/domain/graph" +) + +// CrossNamespaceReader reads nodes and edges across every namespace and augments +// traversal with synthetic cross_ref edges derived from resolved cross-namespace references. +// @intent let impact and flow analysis walk across repository boundaries declared by annotations. +// @domainRule node ids are globally unique, so id-based reads are safe without a namespace filter. +type CrossNamespaceReader struct { + db *gorm.DB +} + +// CrossNamespaceReader returns a namespace-agnostic reader over the same database. +// @intent derive the cross-repository read surface from an existing store without new wiring inputs. +func (s *Store) CrossNamespaceReader() *CrossNamespaceReader { + return &CrossNamespaceReader{db: s.db} +} + +// GetEdgesFrom returns outgoing edges of one node across namespaces, including cross refs. +// @intent satisfy the impact analyzer contract for cross-namespace traversal. +func (r *CrossNamespaceReader) GetEdgesFrom(ctx context.Context, nodeID uint) ([]graph.Edge, error) { + return r.GetEdgesFromNodes(ctx, []uint{nodeID}) +} + +// GetEdgesFromNodes returns outgoing edges of the node set across namespaces, including cross refs. +// @intent expand traversal frontiers across repository boundaries in one query pair. +func (r *CrossNamespaceReader) GetEdgesFromNodes(ctx context.Context, nodeIDs []uint) ([]graph.Edge, error) { + if len(nodeIDs) == 0 { + return nil, nil + } + var edges []graph.Edge + if err := r.db.WithContext(ctx).Where("from_node_id IN ?", nodeIDs).Find(&edges).Error; err != nil { + return nil, trace.Wrap(err, "cross-namespace edges from nodes") + } + var refs []graph.CrossRef + if err := r.db.WithContext(ctx). + Where("from_node_id IN ? AND status = ? AND resolved_node_id IS NOT NULL", nodeIDs, graph.CrossRefStatusResolved). + Find(&refs).Error; err != nil { + return nil, trace.Wrap(err, "cross-namespace refs from nodes") + } + return append(edges, crossRefEdges(refs)...), nil +} + +// GetEdgesTo returns incoming edges of one node across namespaces, including cross refs. +// @intent satisfy the impact analyzer contract for reverse cross-namespace traversal. +func (r *CrossNamespaceReader) GetEdgesTo(ctx context.Context, nodeID uint) ([]graph.Edge, error) { + return r.GetEdgesToNodes(ctx, []uint{nodeID}) +} + +// GetEdgesToNodes returns incoming edges of the node set across namespaces, including cross refs. +// @intent let impact analysis find foreign namespaces that depend on the target nodes. +func (r *CrossNamespaceReader) GetEdgesToNodes(ctx context.Context, nodeIDs []uint) ([]graph.Edge, error) { + if len(nodeIDs) == 0 { + return nil, nil + } + var edges []graph.Edge + if err := r.db.WithContext(ctx).Where("to_node_id IN ?", nodeIDs).Find(&edges).Error; err != nil { + return nil, trace.Wrap(err, "cross-namespace edges to nodes") + } + var refs []graph.CrossRef + if err := r.db.WithContext(ctx). + Where("resolved_node_id IN ? AND status = ?", nodeIDs, graph.CrossRefStatusResolved). + Find(&refs).Error; err != nil { + return nil, trace.Wrap(err, "cross-namespace refs to nodes") + } + return append(edges, crossRefEdges(refs)...), nil +} + +// GetNodeByID retrieves one node by primary key regardless of namespace. +// @intent resolve traversal frontiers that crossed into another namespace. +func (r *CrossNamespaceReader) GetNodeByID(ctx context.Context, id uint) (*graph.Node, error) { + var node graph.Node + result := r.db.WithContext(ctx).Where("id = ?", id).First(&node) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + if result.Error != nil { + return nil, trace.Wrap(result.Error, "cross-namespace node by id") + } + return &node, nil +} + +// GetNodesByIDs retrieves nodes by primary key regardless of namespace. +// @intent load result nodes for cross-namespace traversals in one query. +func (r *CrossNamespaceReader) GetNodesByIDs(ctx context.Context, ids []uint) ([]graph.Node, error) { + if len(ids) == 0 { + return nil, nil + } + var nodes []graph.Node + if err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&nodes).Error; err != nil { + return nil, trace.Wrap(err, "cross-namespace nodes by ids") + } + return nodes, nil +} + +// crossRefEdges converts resolved cross refs into synthetic traversal edges. +// @intent reuse existing traversal algorithms unchanged by presenting refs as edges. +func crossRefEdges(refs []graph.CrossRef) []graph.Edge { + if len(refs) == 0 { + return nil + } + edges := make([]graph.Edge, 0, len(refs)) + for _, ref := range refs { + if ref.ResolvedNodeID == nil { + continue + } + edges = append(edges, graph.Edge{ + Namespace: ref.FromNamespace, + FromNodeID: ref.FromNodeID, + ToNodeID: *ref.ResolvedNodeID, + Kind: graph.EdgeKindCrossRef, + Fingerprint: ref.Raw, + }) + } + return edges +} diff --git a/internal/adapters/outbound/graphgorm/crossnamespace_test.go b/internal/adapters/outbound/graphgorm/crossnamespace_test.go new file mode 100644 index 0000000..7d7fa33 --- /dev/null +++ b/internal/adapters/outbound/graphgorm/crossnamespace_test.go @@ -0,0 +1,136 @@ +package graphgorm + +import ( + "context" + "testing" + + requestctx "github.com/tae2089/code-context-graph/internal/ctx" + "github.com/tae2089/code-context-graph/internal/domain/graph" +) + +func seedCrossNamespaceGraph(t *testing.T, s *Store) (webLogin, authValidate, authAudit uint) { + t.Helper() + webLogin = seedOneNode(t, s, "web", graph.Node{ + QualifiedName: "web.Login", Kind: graph.NodeKindFunction, Name: "Login", + FilePath: "internal/web/login.go", StartLine: 5, EndLine: 25, + }) + authValidate = seedOneNode(t, s, "auth-svc", graph.Node{ + QualifiedName: "auth.ValidateToken", Kind: graph.NodeKindFunction, Name: "ValidateToken", + FilePath: "internal/auth/token.go", StartLine: 10, EndLine: 20, + }) + authAudit = seedOneNode(t, s, "auth-svc", graph.Node{ + QualifiedName: "auth.RecordAudit", Kind: graph.NodeKindFunction, Name: "RecordAudit", + FilePath: "internal/audit/audit.go", StartLine: 3, EndLine: 9, + }) + + authCtx := requestctx.WithNamespace(context.Background(), "auth-svc") + if err := s.UpsertEdges(authCtx, []graph.Edge{{ + FromNodeID: authValidate, ToNodeID: authAudit, Kind: graph.EdgeKindCalls, + FilePath: "internal/auth/token.go", Line: 15, Fingerprint: "calls:internal/auth/token.go:RecordAudit:15", + }}); err != nil { + t.Fatalf("seed auth edge: %v", err) + } + + resolved := authValidate + if err := s.ReplaceCrossRefsFrom(context.Background(), "web", []graph.CrossRef{{ + FromNamespace: "web", FromNodeID: webLogin, Raw: "ccg://auth-svc/internal/auth/token.go#ValidateToken", + ToNamespace: "auth-svc", ToPath: "internal/auth/token.go", ToSymbol: "ValidateToken", + ResolvedNodeID: &resolved, Status: graph.CrossRefStatusResolved, Source: graph.CrossRefSourceAnnotation, + }}); err != nil { + t.Fatalf("seed cross ref: %v", err) + } + return webLogin, authValidate, authAudit +} + +func seedOneNode(t *testing.T, s *Store, namespace string, node graph.Node) uint { + t.Helper() + ctx := requestctx.WithNamespace(context.Background(), namespace) + if err := s.UpsertNodes(ctx, []graph.Node{node}); err != nil { + t.Fatalf("seed node: %v", err) + } + stored, err := s.GetNode(ctx, node.QualifiedName) + if err != nil || stored == nil { + t.Fatalf("load node %s: %v", node.QualifiedName, err) + } + return stored.ID +} + +func TestCrossNamespaceReader_MergesCrossRefEdges(t *testing.T) { + s := setupTestDB(t) + webLogin, authValidate, _ := seedCrossNamespaceGraph(t, s) + reader := s.CrossNamespaceReader() + ctx := requestctx.WithNamespace(context.Background(), "web") + + edges, err := reader.GetEdgesFromNodes(ctx, []uint{webLogin}) + if err != nil { + t.Fatalf("GetEdgesFromNodes: %v", err) + } + found := false + for _, e := range edges { + if e.Kind == graph.EdgeKindCrossRef && e.ToNodeID == authValidate { + found = true + } + } + if !found { + t.Fatalf("edges from web.Login = %+v, want synthetic cross_ref edge to node %d", edges, authValidate) + } + + inbound, err := reader.GetEdgesToNodes(ctx, []uint{authValidate}) + if err != nil { + t.Fatalf("GetEdgesToNodes: %v", err) + } + foundInbound := false + for _, e := range inbound { + if e.Kind == graph.EdgeKindCrossRef && e.FromNodeID == webLogin { + foundInbound = true + } + } + if !foundInbound { + t.Fatalf("edges to auth.ValidateToken = %+v, want reverse cross_ref edge from %d", inbound, webLogin) + } +} + +func TestCrossNamespaceReader_ReadsNodesAcrossNamespaces(t *testing.T) { + s := setupTestDB(t) + webLogin, authValidate, _ := seedCrossNamespaceGraph(t, s) + reader := s.CrossNamespaceReader() + // Context carries the web namespace, but reads must cross it. + ctx := requestctx.WithNamespace(context.Background(), "web") + + node, err := reader.GetNodeByID(ctx, authValidate) + if err != nil || node == nil { + t.Fatalf("GetNodeByID across namespaces: node=%v err=%v", node, err) + } + if node.Namespace != "auth-svc" { + t.Fatalf("node namespace = %q, want auth-svc", node.Namespace) + } + + nodes, err := reader.GetNodesByIDs(ctx, []uint{webLogin, authValidate}) + if err != nil { + t.Fatalf("GetNodesByIDs: %v", err) + } + if len(nodes) != 2 { + t.Fatalf("GetNodesByIDs returned %d nodes, want 2 across namespaces", len(nodes)) + } +} + +func TestCrossNamespaceReader_FollowsRegularEdgesInTargetNamespace(t *testing.T) { + s := setupTestDB(t) + _, authValidate, authAudit := seedCrossNamespaceGraph(t, s) + reader := s.CrossNamespaceReader() + ctx := requestctx.WithNamespace(context.Background(), "web") + + edges, err := reader.GetEdgesFromNodes(ctx, []uint{authValidate}) + if err != nil { + t.Fatalf("GetEdgesFromNodes: %v", err) + } + found := false + for _, e := range edges { + if e.Kind == graph.EdgeKindCalls && e.ToNodeID == authAudit { + found = true + } + } + if !found { + t.Fatalf("edges = %+v, want auth-svc internal call edge despite web context", edges) + } +} diff --git a/internal/adapters/outbound/graphgorm/crossref.go b/internal/adapters/outbound/graphgorm/crossref.go new file mode 100644 index 0000000..8c09dd1 --- /dev/null +++ b/internal/adapters/outbound/graphgorm/crossref.go @@ -0,0 +1,120 @@ +// @index GORM adapter for materialized cross-namespace reference rows and ccg ref resolution. +package graphgorm + +import ( + "context" + "errors" + + "gorm.io/gorm" + + "github.com/tae2089/trace" + + crossrefapp "github.com/tae2089/code-context-graph/internal/app/crossref" + "github.com/tae2089/code-context-graph/internal/domain/graph" + "github.com/tae2089/code-context-graph/internal/domain/reference" +) + +var _ crossrefapp.Store = (*Store)(nil) + +// ListAnnotationCCGRefs returns every @see ccg:// tag value declared by nodes of one namespace. +// @intent collect the source facts for rebuilding a namespace's outbound cross refs. +func (s *Store) ListAnnotationCCGRefs(ctx context.Context, namespace string) ([]crossrefapp.AnnotationRef, error) { + var rows []crossrefapp.AnnotationRef + err := s.db.WithContext(ctx).Model(&graph.DocTag{}). + Select("annotations.node_id AS node_id, doc_tags.value AS value"). + Joins("JOIN annotations ON annotations.id = doc_tags.annotation_id"). + Joins("JOIN nodes ON nodes.id = annotations.node_id"). + Where("doc_tags.kind = ?", graph.TagSee). + Where("doc_tags.value LIKE ?", reference.Scheme+"://%"). + Where("nodes.namespace = ?", namespace). + Scan(&rows).Error + if err != nil { + return nil, trace.Wrap(err, "list annotation ccg refs") + } + return rows, nil +} + +// ResolveCCGRef resolves a parsed ccg:// reference to a target node id. +// @intent give cross-ref materialization the concrete node identity behind a symbolic reference. +// @domainRule namespace-scope refs resolve with a zero node id when the namespace has any nodes. +// @domainRule path-scope refs prefer the file node of the path; remaining ties resolve to the lowest node id. +func (s *Store) ResolveCCGRef(ctx context.Context, ref reference.Ref) (uint, bool, error) { + if ref.Path == "" && ref.Symbol == "" { + var count int64 + if err := s.db.WithContext(ctx).Model(&graph.Node{}).Where("namespace = ?", ref.Namespace).Count(&count).Error; err != nil { + return 0, false, trace.Wrap(err, "resolve namespace-scope ccg ref") + } + return 0, count > 0, nil + } + var node graph.Node + err := s.ccgRefNodeQuery(ctx, ref). + Order("CASE WHEN kind = 'file' THEN 0 ELSE 1 END, id"). + First(&node).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, false, nil + } + if err != nil { + return 0, false, trace.Wrap(err, "resolve ccg ref") + } + return node.ID, true, nil +} + +// ReplaceCrossRefsFrom atomically replaces every cross ref originating from one namespace. +// @intent make outbound cross-ref state a pure function of the namespace's current annotations. +// @sideEffect deletes and inserts cross_refs rows in one transaction. +func (s *Store) ReplaceCrossRefsFrom(ctx context.Context, fromNamespace string, refs []graph.CrossRef) error { + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("from_namespace = ?", fromNamespace).Delete(&graph.CrossRef{}).Error; err != nil { + return err + } + if len(refs) == 0 { + return nil + } + return tx.CreateInBatches(refs, 100).Error + }) + if err != nil { + return trace.Wrap(err, "replace cross refs") + } + return nil +} + +// ListInboundCrossRefs returns refs from other namespaces that target the given namespace. +// @intent select the rows whose resolution may change after this namespace rebuilds. +// @domainRule self-namespace refs are excluded because the outbound rebuild already re-resolved them. +func (s *Store) ListInboundCrossRefs(ctx context.Context, toNamespace string) ([]graph.CrossRef, error) { + var rows []graph.CrossRef + err := s.db.WithContext(ctx). + Where("to_namespace = ? AND from_namespace <> ?", toNamespace, toNamespace). + Order("id"). + Find(&rows).Error + if err != nil { + return nil, trace.Wrap(err, "list inbound cross refs") + } + return rows, nil +} + +// ListOutboundCrossRefs returns every cross ref originating from one namespace. +// @intent expose a namespace's declared external dependencies for listing and analysis. +func (s *Store) ListOutboundCrossRefs(ctx context.Context, fromNamespace string) ([]graph.CrossRef, error) { + var rows []graph.CrossRef + err := s.db.WithContext(ctx). + Where("from_namespace = ?", fromNamespace). + Order("id"). + Find(&rows).Error + if err != nil { + return nil, trace.Wrap(err, "list outbound cross refs") + } + return rows, nil +} + +// UpdateCrossRefResolution updates one row's derived resolution state. +// @intent remap or invalidate a reference after its target namespace rebuilt. +// @sideEffect updates resolved_node_id and status of one cross_refs row. +func (s *Store) UpdateCrossRefResolution(ctx context.Context, id uint, resolvedNodeID *uint, status graph.CrossRefStatus) error { + err := s.db.WithContext(ctx).Model(&graph.CrossRef{}).Where("id = ?", id). + Updates(map[string]any{"resolved_node_id": resolvedNodeID, "status": status}).Error + if err != nil { + return trace.Wrap(err, "update cross ref resolution") + } + return nil +} diff --git a/internal/adapters/outbound/graphgorm/crossref_test.go b/internal/adapters/outbound/graphgorm/crossref_test.go new file mode 100644 index 0000000..95dd87f --- /dev/null +++ b/internal/adapters/outbound/graphgorm/crossref_test.go @@ -0,0 +1,167 @@ +package graphgorm + +import ( + "context" + "testing" + + requestctx "github.com/tae2089/code-context-graph/internal/ctx" + "github.com/tae2089/code-context-graph/internal/domain/graph" + "github.com/tae2089/code-context-graph/internal/domain/reference" +) + +func seedCrossRefNodes(t *testing.T, s *Store, namespace string, nodes []graph.Node) map[string]uint { + t.Helper() + ctx := requestctx.WithNamespace(context.Background(), namespace) + if err := s.UpsertNodes(ctx, nodes); err != nil { + t.Fatalf("seed nodes for %s: %v", namespace, err) + } + stored, err := s.GetNodesByFiles(ctx, uniqueFilePaths(nodes)) + if err != nil { + t.Fatalf("load seeded nodes: %v", err) + } + ids := map[string]uint{} + for _, byFile := range stored { + for _, n := range byFile { + ids[n.QualifiedName] = n.ID + } + } + return ids +} + +func uniqueFilePaths(nodes []graph.Node) []string { + seen := map[string]bool{} + paths := []string{} + for _, n := range nodes { + if !seen[n.FilePath] { + seen[n.FilePath] = true + paths = append(paths, n.FilePath) + } + } + return paths +} + +func TestResolveCCGRef_Scopes(t *testing.T) { + s := setupTestDB(t) + ids := seedCrossRefNodes(t, s, "auth-svc", []graph.Node{ + {QualifiedName: "internal/auth/token.go", Kind: graph.NodeKindFile, Name: "token.go", FilePath: "internal/auth/token.go", StartLine: 1, EndLine: 1}, + {QualifiedName: "auth.ValidateToken", Kind: graph.NodeKindFunction, Name: "ValidateToken", FilePath: "internal/auth/token.go", StartLine: 10, EndLine: 20}, + {QualifiedName: "auth.RenewToken", Kind: graph.NodeKindFunction, Name: "RenewToken", FilePath: "internal/auth/token.go", StartLine: 30, EndLine: 40}, + }) + ctx := context.Background() + + cases := []struct { + name string + ref reference.Ref + wantID uint + wantOK bool + }{ + {"path and symbol", reference.Ref{Namespace: "auth-svc", Path: "internal/auth/token.go", Symbol: "ValidateToken"}, ids["auth.ValidateToken"], true}, + {"path only prefers file node", reference.Ref{Namespace: "auth-svc", Path: "internal/auth/token.go"}, ids["internal/auth/token.go"], true}, + {"symbol only", reference.Ref{Namespace: "auth-svc", Symbol: "RenewToken"}, ids["auth.RenewToken"], true}, + {"namespace scope resolves without node", reference.Ref{Namespace: "auth-svc"}, 0, true}, + {"missing symbol", reference.Ref{Namespace: "auth-svc", Symbol: "Nope"}, 0, false}, + {"missing namespace", reference.Ref{Namespace: "ghost"}, 0, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + id, ok, err := s.ResolveCCGRef(ctx, tc.ref) + if err != nil { + t.Fatalf("ResolveCCGRef: %v", err) + } + if ok != tc.wantOK || id != tc.wantID { + t.Fatalf("ResolveCCGRef = (%d, %v), want (%d, %v)", id, ok, tc.wantID, tc.wantOK) + } + }) + } +} + +func TestReplaceCrossRefsFrom_ReplacesAndLists(t *testing.T) { + s := setupTestDB(t) + ctx := context.Background() + first := []graph.CrossRef{ + {FromNamespace: "web", FromNodeID: 1, Raw: "ccg://auth-svc/#Old", ToNamespace: "auth-svc", ToSymbol: "Old", Status: graph.CrossRefStatusDead, Source: graph.CrossRefSourceAnnotation}, + } + if err := s.ReplaceCrossRefsFrom(ctx, "web", first); err != nil { + t.Fatalf("first replace: %v", err) + } + second := []graph.CrossRef{ + {FromNamespace: "web", FromNodeID: 2, Raw: "ccg://auth-svc/#New", ToNamespace: "auth-svc", ToSymbol: "New", Status: graph.CrossRefStatusResolved, Source: graph.CrossRefSourceAnnotation}, + {FromNamespace: "web", FromNodeID: 3, Raw: "ccg://billing/#Charge", ToNamespace: "billing", ToSymbol: "Charge", Status: graph.CrossRefStatusDead, Source: graph.CrossRefSourceAnnotation}, + } + if err := s.ReplaceCrossRefsFrom(ctx, "web", second); err != nil { + t.Fatalf("second replace: %v", err) + } + + inbound, err := s.ListInboundCrossRefs(ctx, "auth-svc") + if err != nil { + t.Fatalf("ListInboundCrossRefs: %v", err) + } + if len(inbound) != 1 || inbound[0].Raw != "ccg://auth-svc/#New" { + t.Fatalf("inbound after replace = %+v, want only the new auth-svc ref", inbound) + } + + outbound, err := s.ListOutboundCrossRefs(ctx, "web") + if err != nil { + t.Fatalf("ListOutboundCrossRefs: %v", err) + } + if len(outbound) != 2 { + t.Fatalf("outbound rows = %d, want 2", len(outbound)) + } +} + +func TestListInboundCrossRefs_ExcludesSelfNamespace(t *testing.T) { + s := setupTestDB(t) + ctx := context.Background() + rows := []graph.CrossRef{ + {FromNamespace: "auth-svc", FromNodeID: 1, Raw: "ccg://auth-svc/internal#Self", ToNamespace: "auth-svc", ToSymbol: "Self", Status: graph.CrossRefStatusResolved, Source: graph.CrossRefSourceAnnotation}, + } + if err := s.ReplaceCrossRefsFrom(ctx, "auth-svc", rows); err != nil { + t.Fatalf("replace: %v", err) + } + inbound, err := s.ListInboundCrossRefs(ctx, "auth-svc") + if err != nil { + t.Fatalf("ListInboundCrossRefs: %v", err) + } + if len(inbound) != 0 { + t.Fatalf("inbound = %d rows, want 0 (self refs are rebuilt outbound)", len(inbound)) + } +} + +func TestUpdateCrossRefResolution_RemapsAndKills(t *testing.T) { + s := setupTestDB(t) + ctx := context.Background() + resolved := uint(42) + rows := []graph.CrossRef{ + {FromNamespace: "web", FromNodeID: 1, Raw: "ccg://auth-svc/#Fn", ToNamespace: "auth-svc", ToSymbol: "Fn", ResolvedNodeID: &resolved, Status: graph.CrossRefStatusResolved, Source: graph.CrossRefSourceAnnotation}, + } + if err := s.ReplaceCrossRefsFrom(ctx, "web", rows); err != nil { + t.Fatalf("replace: %v", err) + } + stored, err := s.ListInboundCrossRefs(ctx, "auth-svc") + if err != nil || len(stored) != 1 { + t.Fatalf("load stored row: %v (%d rows)", err, len(stored)) + } + + if err := s.UpdateCrossRefResolution(ctx, stored[0].ID, nil, graph.CrossRefStatusDead); err != nil { + t.Fatalf("kill resolution: %v", err) + } + after, err := s.ListInboundCrossRefs(ctx, "auth-svc") + if err != nil { + t.Fatalf("reload: %v", err) + } + if after[0].Status != graph.CrossRefStatusDead || after[0].ResolvedNodeID != nil { + t.Fatalf("after kill = %+v, want dead with nil node", after[0]) + } + + remapped := uint(99) + if err := s.UpdateCrossRefResolution(ctx, stored[0].ID, &remapped, graph.CrossRefStatusResolved); err != nil { + t.Fatalf("remap resolution: %v", err) + } + final, err := s.ListInboundCrossRefs(ctx, "auth-svc") + if err != nil { + t.Fatalf("reload final: %v", err) + } + if final[0].Status != graph.CrossRefStatusResolved || final[0].ResolvedNodeID == nil || *final[0].ResolvedNodeID != 99 { + t.Fatalf("after remap = %+v, want resolved node 99", final[0]) + } +} diff --git a/internal/adapters/outbound/graphgorm/docs.go b/internal/adapters/outbound/graphgorm/docs.go index 43e767f..50b45a4 100644 --- a/internal/adapters/outbound/graphgorm/docs.go +++ b/internal/adapters/outbound/graphgorm/docs.go @@ -5,6 +5,8 @@ import ( "context" "strings" + "gorm.io/gorm" + docsapp "github.com/tae2089/code-context-graph/internal/app/docs" "github.com/tae2089/code-context-graph/internal/domain/graph" "github.com/tae2089/code-context-graph/internal/domain/reference" @@ -73,6 +75,15 @@ func (s *Store) QualifiedNameExists(ctx context.Context, namespace, name string) // @intent validate parsed cross-namespace ccg references against graph path and symbol semantics. func (s *Store) CCGRefExists(ctx context.Context, ref reference.Ref) (bool, error) { + var count int64 + err := s.ccgRefNodeQuery(ctx, ref).Count(&count).Error + return count > 0, err +} + +// ccgRefNodeQuery builds the node query matching a parsed ccg:// reference target. +// @intent keep one matcher for every consumer of ccg ref resolution so lint and cross-ref state never disagree. +// @domainRule a path scope matches the file itself or any file under the path; a symbol scope matches name or qualified-name suffix. +func (s *Store) ccgRefNodeQuery(ctx context.Context, ref reference.Ref) *gorm.DB { q := s.db.WithContext(ctx).Model(&graph.Node{}).Where("namespace = ?", ref.Namespace) if ref.Path != "" { if ref.Symbol != "" { @@ -83,7 +94,5 @@ func (s *Store) CCGRefExists(ctx context.Context, ref reference.Ref) (bool, erro } else if ref.Symbol != "" { q = q.Where("name = ? OR qualified_name = ? OR qualified_name LIKE ? OR qualified_name LIKE ?", ref.Symbol, ref.Symbol, "%."+ref.Symbol, "%::"+ref.Symbol) } - var count int64 - err := q.Count(&count).Error - return count > 0, err + return q } diff --git a/internal/adapters/outbound/graphgorm/store.go b/internal/adapters/outbound/graphgorm/store.go index abc5ecd..caa5f72 100644 --- a/internal/adapters/outbound/graphgorm/store.go +++ b/internal/adapters/outbound/graphgorm/store.go @@ -49,6 +49,7 @@ func (s *Store) AutoMigrate() error { &graph.ParseCacheEntry{}, &graph.UnresolvedEdgeCandidate{}, &graph.UnresolvedIndexState{}, + &graph.CrossRef{}, ); err != nil { return err } diff --git a/internal/app/analyze/flow/flow.go b/internal/app/analyze/flow/flow.go index 187e9dc..636b8b4 100644 --- a/internal/app/analyze/flow/flow.go +++ b/internal/app/analyze/flow/flow.go @@ -22,6 +22,41 @@ type Tracer struct { store EdgeReader } +// nodeBatchReader is the optional capability used to resolve members' true namespaces. +// @intent let cross-namespace readers label foreign members without widening the EdgeReader contract. +type nodeBatchReader interface { + GetNodesByIDs(ctx context.Context, ids []uint) ([]graph.Node, error) +} + +// stampMemberNamespaces replaces the context-namespace default with each member node's stored namespace. +// @intent make cross-namespace flow members resolvable by callers; single-namespace traces are unchanged +// because every member already lives in the context namespace. +// @domainRule stores without batch node reads keep the context-namespace stamp (persisted-flow rebuild path). +func (t *Tracer) stampMemberNamespaces(ctx context.Context, members []graph.FlowMembership) error { + reader, ok := t.store.(nodeBatchReader) + if !ok || len(members) == 0 { + return nil + } + ids := make([]uint, len(members)) + for i, m := range members { + ids[i] = m.NodeID + } + nodes, err := reader.GetNodesByIDs(ctx, ids) + if err != nil { + return err + } + namespaceByID := make(map[uint]string, len(nodes)) + for _, n := range nodes { + namespaceByID[n.ID] = n.Namespace + } + for i := range members { + if ns, found := namespaceByID[members[i].NodeID]; found { + members[i].Namespace = ns + } + } + return nil +} + // TraceOptions bounds how far a single flow trace is allowed to expand. // @intent let callers cap traversal cost when tracing large call graphs type TraceOptions struct { @@ -113,7 +148,9 @@ func (t *Tracer) TraceFlowBounded(ctx context.Context, startNodeID uint, opts Tr nextFrontier := make([]uint, 0) for _, current := range frontier { for _, e := range edgesBySource[current] { - if !graph.IsCallKind(e.Kind) || visited[e.ToNodeID] { + // Cross-ref edges only appear when a cross-namespace reader supplies them; + // regular stores never return this kind. + if (!graph.IsCallKind(e.Kind) && e.Kind != graph.EdgeKindCrossRef) || visited[e.ToNodeID] { continue } if e.Kind == graph.EdgeKindFallbackCalls { @@ -134,6 +171,10 @@ func (t *Tracer) TraceFlowBounded(ctx context.Context, startNodeID uint, opts Tr frontier = nextFrontier } + if err := t.stampMemberNamespaces(ctx, members); err != nil { + return nil, err + } + node, _ := t.store.GetNodeByID(ctx, startNodeID) name := fmt.Sprintf("flow_from_%d", startNodeID) if node != nil { diff --git a/internal/app/analyze/ports.go b/internal/app/analyze/ports.go index 3f75257..bc3fa21 100644 --- a/internal/app/analyze/ports.go +++ b/internal/app/analyze/ports.go @@ -6,7 +6,7 @@ import ( "github.com/tae2089/code-context-graph/internal/domain/graph" ) -// test + // FlowRebuildStore is the transaction-scoped graph and flow persistence surface used by a rebuild. // @intent let flow application policy trace and replace flows without importing a database adapter. type FlowRebuildStore interface { diff --git a/internal/app/crossref/service.go b/internal/app/crossref/service.go new file mode 100644 index 0000000..d3c48c1 --- /dev/null +++ b/internal/app/crossref/service.go @@ -0,0 +1,150 @@ +// @index Cross-namespace reference sync: materializes @see ccg:// annotation tags into cross_refs rows. +package crossref + +import ( + "context" + "log/slog" + + "github.com/tae2089/trace" + + requestctx "github.com/tae2089/code-context-graph/internal/ctx" + "github.com/tae2089/code-context-graph/internal/domain/graph" + "github.com/tae2089/code-context-graph/internal/domain/reference" +) + +// AnnotationRef pairs one ccg:// @see tag value with the node that declared it. +// @intent carry the minimal source facts needed to materialize a cross-namespace reference. +type AnnotationRef struct { + NodeID uint + Value string +} + +// Store exposes the persistence operations cross-ref sync needs. +// @intent keep the sync policy independent from GORM by owning a minimal consumer-side port. +type Store interface { + ListAnnotationCCGRefs(ctx context.Context, namespace string) ([]AnnotationRef, error) + ResolveCCGRef(ctx context.Context, ref reference.Ref) (uint, bool, error) + ReplaceCrossRefsFrom(ctx context.Context, fromNamespace string, refs []graph.CrossRef) error + ListInboundCrossRefs(ctx context.Context, toNamespace string) ([]graph.CrossRef, error) + UpdateCrossRefResolution(ctx context.Context, id uint, resolvedNodeID *uint, status graph.CrossRefStatus) error +} + +// Service rebuilds cross-namespace reference state after a namespace ingest. +// @intent keep cross_refs derived state consistent with annotations and both namespaces' current nodes. +type Service struct { + Store Store + Logger *slog.Logger +} + +// New constructs a cross-ref sync service. +// @intent bind the sync policy to one persistence port instance. +func New(store Store) *Service { + return &Service{Store: store} +} + +// SyncNamespace rebuilds outbound cross refs of the context namespace and re-resolves inbound ones. +// @intent run after a build/update commit so cross-namespace links reflect the namespace's new node identity. +// @domainRule outbound rows are fully replaced from current @see tags; malformed refs are skipped (lint owns reporting). +// @domainRule inbound rows are re-resolved because a replace-style build regenerates the target node ids. +// @sideEffect deletes, inserts, and updates cross_refs rows. +func (s *Service) SyncNamespace(ctx context.Context) error { + if s == nil || s.Store == nil { + return trace.New("cross-ref store is not configured") + } + ns := requestctx.FromContext(ctx) + if err := s.rebuildOutbound(ctx, ns); err != nil { + return trace.Wrap(err, "rebuild outbound cross refs") + } + if err := s.reresolveInbound(ctx, ns); err != nil { + return trace.Wrap(err, "re-resolve inbound cross refs") + } + return nil +} + +// @intent replace the namespace's outbound rows with rows derived from its current annotations. +func (s *Service) rebuildOutbound(ctx context.Context, ns string) error { + tags, err := s.Store.ListAnnotationCCGRefs(ctx, ns) + if err != nil { + return err + } + rows := make([]graph.CrossRef, 0, len(tags)) + seen := map[AnnotationRef]bool{} + for _, tag := range tags { + if seen[tag] { + continue + } + seen[tag] = true + ref, err := reference.Parse(tag.Value) + if err != nil { + s.logger().Debug("skipping malformed ccg ref", "node_id", tag.NodeID, "value", tag.Value, "error", err) + continue + } + resolvedID, status, err := s.resolve(ctx, *ref) + if err != nil { + return err + } + rows = append(rows, graph.CrossRef{ + FromNamespace: ns, + FromNodeID: tag.NodeID, + Raw: ref.Raw, + ToNamespace: ref.Namespace, + ToPath: ref.Path, + ToSymbol: ref.Symbol, + ResolvedNodeID: resolvedID, + Status: status, + Source: graph.CrossRefSourceAnnotation, + }) + } + return s.Store.ReplaceCrossRefsFrom(ctx, ns, rows) +} + +// @intent update inbound rows whose resolution changed after this namespace's nodes were rebuilt. +func (s *Service) reresolveInbound(ctx context.Context, ns string) error { + inbound, err := s.Store.ListInboundCrossRefs(ctx, ns) + if err != nil { + return err + } + for _, row := range inbound { + ref := reference.Ref{Raw: row.Raw, Namespace: row.ToNamespace, Path: row.ToPath, Symbol: row.ToSymbol} + resolvedID, status, err := s.resolve(ctx, ref) + if err != nil { + return err + } + if status == row.Status && equalNodeID(resolvedID, row.ResolvedNodeID) { + continue + } + if err := s.Store.UpdateCrossRefResolution(ctx, row.ID, resolvedID, status); err != nil { + return err + } + } + return nil +} + +// @intent translate matcher output into row state: namespace-scope hits stay resolved without a node target. +func (s *Service) resolve(ctx context.Context, ref reference.Ref) (*uint, graph.CrossRefStatus, error) { + id, ok, err := s.Store.ResolveCCGRef(ctx, ref) + if err != nil { + return nil, graph.CrossRefStatusDead, err + } + if !ok { + return nil, graph.CrossRefStatusDead, nil + } + if id == 0 { + return nil, graph.CrossRefStatusResolved, nil + } + return &id, graph.CrossRefStatusResolved, nil +} + +func equalNodeID(a, b *uint) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + +func (s *Service) logger() *slog.Logger { + if s.Logger != nil { + return s.Logger + } + return slog.Default() +} diff --git a/internal/app/crossref/service_test.go b/internal/app/crossref/service_test.go new file mode 100644 index 0000000..24d11be --- /dev/null +++ b/internal/app/crossref/service_test.go @@ -0,0 +1,176 @@ +package crossref_test + +import ( + "context" + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/tae2089/code-context-graph/internal/adapters/outbound/graphgorm" + "github.com/tae2089/code-context-graph/internal/app/crossref" + requestctx "github.com/tae2089/code-context-graph/internal/ctx" + "github.com/tae2089/code-context-graph/internal/domain/graph" +) + +func setupStore(t *testing.T) *graphgorm.Store { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Discard}) + if err != nil { + t.Fatalf("open test db: %v", err) + } + s := graphgorm.New(db) + if err := s.AutoMigrate(); err != nil { + t.Fatalf("migrate: %v", err) + } + return s +} + +func seedNode(t *testing.T, s *graphgorm.Store, namespace string, node graph.Node) uint { + t.Helper() + ctx := requestctx.WithNamespace(context.Background(), namespace) + if err := s.UpsertNodes(ctx, []graph.Node{node}); err != nil { + t.Fatalf("seed node %s: %v", node.QualifiedName, err) + } + stored, err := s.GetNode(ctx, node.QualifiedName) + if err != nil || stored == nil { + t.Fatalf("load node %s: %v", node.QualifiedName, err) + } + return stored.ID +} + +func annotate(t *testing.T, s *graphgorm.Store, namespace string, nodeID uint, seeValues ...string) { + t.Helper() + ctx := requestctx.WithNamespace(context.Background(), namespace) + tags := make([]graph.DocTag, len(seeValues)) + for i, v := range seeValues { + tags[i] = graph.DocTag{Kind: graph.TagSee, Value: v, Ordinal: i} + } + if err := s.UpsertAnnotation(ctx, &graph.Annotation{NodeID: nodeID, Summary: "test", Tags: tags}); err != nil { + t.Fatalf("annotate node %d: %v", nodeID, err) + } +} + +func syncNamespace(t *testing.T, svc *crossref.Service, namespace string) { + t.Helper() + ctx := requestctx.WithNamespace(context.Background(), namespace) + if err := svc.SyncNamespace(ctx); err != nil { + t.Fatalf("sync %s: %v", namespace, err) + } +} + +func TestSyncNamespace_MaterializesResolvedAndDeadRefs(t *testing.T) { + s := setupStore(t) + svc := crossref.New(s) + ctx := context.Background() + + targetID := seedNode(t, s, "auth-svc", graph.Node{ + QualifiedName: "auth.ValidateToken", Kind: graph.NodeKindFunction, Name: "ValidateToken", + FilePath: "internal/auth/token.go", StartLine: 10, EndLine: 20, + }) + callerID := seedNode(t, s, "web", graph.Node{ + QualifiedName: "web.Login", Kind: graph.NodeKindFunction, Name: "Login", + FilePath: "internal/web/login.go", StartLine: 5, EndLine: 25, + }) + annotate(t, s, "web", callerID, + "ccg://auth-svc/internal/auth/token.go#ValidateToken", + "ccg://billing/#Charge", + "SessionManager.Create", + ) + + syncNamespace(t, svc, "web") + + rows, err := s.ListOutboundCrossRefs(ctx, "web") + if err != nil { + t.Fatalf("list outbound: %v", err) + } + if len(rows) != 2 { + t.Fatalf("outbound rows = %d, want 2 (local @see ignored)", len(rows)) + } + byRaw := map[string]graph.CrossRef{} + for _, r := range rows { + byRaw[r.Raw] = r + } + resolved := byRaw["ccg://auth-svc/internal/auth/token.go#ValidateToken"] + if resolved.Status != graph.CrossRefStatusResolved || resolved.ResolvedNodeID == nil || *resolved.ResolvedNodeID != targetID { + t.Fatalf("resolved row = %+v, want resolved to node %d", resolved, targetID) + } + if resolved.FromNodeID != callerID || resolved.ToNamespace != "auth-svc" || resolved.ToSymbol != "ValidateToken" { + t.Fatalf("resolved row identity = %+v", resolved) + } + dead := byRaw["ccg://billing/#Charge"] + if dead.Status != graph.CrossRefStatusDead || dead.ResolvedNodeID != nil { + t.Fatalf("dead row = %+v, want dead without node", dead) + } +} + +func TestSyncNamespace_SkipsMalformedRefs(t *testing.T) { + s := setupStore(t) + svc := crossref.New(s) + + callerID := seedNode(t, s, "web", graph.Node{ + QualifiedName: "web.Login", Kind: graph.NodeKindFunction, Name: "Login", + FilePath: "internal/web/login.go", StartLine: 5, EndLine: 25, + }) + annotate(t, s, "web", callerID, "ccg://bad/../escape#X") + + syncNamespace(t, svc, "web") + + rows, err := s.ListOutboundCrossRefs(context.Background(), "web") + if err != nil { + t.Fatalf("list outbound: %v", err) + } + if len(rows) != 0 { + t.Fatalf("outbound rows = %d, want 0 for malformed ref", len(rows)) + } +} + +func TestSyncNamespace_ReresolvesInboundAfterTargetRebuild(t *testing.T) { + s := setupStore(t) + svc := crossref.New(s) + ctx := context.Background() + authCtx := requestctx.WithNamespace(ctx, "auth-svc") + + oldID := seedNode(t, s, "auth-svc", graph.Node{ + QualifiedName: "auth.ValidateToken", Kind: graph.NodeKindFunction, Name: "ValidateToken", + FilePath: "internal/auth/token.go", StartLine: 10, EndLine: 20, + }) + callerID := seedNode(t, s, "web", graph.Node{ + QualifiedName: "web.Login", Kind: graph.NodeKindFunction, Name: "Login", + FilePath: "internal/web/login.go", StartLine: 5, EndLine: 25, + }) + annotate(t, s, "web", callerID, "ccg://auth-svc/internal/auth/token.go#ValidateToken") + syncNamespace(t, svc, "web") + + // Simulate a replace-style rebuild of auth-svc: node ids change. + if err := s.DeleteNodesByFile(authCtx, "internal/auth/token.go"); err != nil { + t.Fatalf("delete target nodes: %v", err) + } + syncNamespace(t, svc, "auth-svc") + + rows, err := s.ListOutboundCrossRefs(ctx, "web") + if err != nil { + t.Fatalf("list after delete: %v", err) + } + if rows[0].Status != graph.CrossRefStatusDead || rows[0].ResolvedNodeID != nil { + t.Fatalf("after target deletion row = %+v, want dead", rows[0]) + } + + newID := seedNode(t, s, "auth-svc", graph.Node{ + QualifiedName: "auth.ValidateToken", Kind: graph.NodeKindFunction, Name: "ValidateToken", + FilePath: "internal/auth/token.go", StartLine: 42, EndLine: 60, + }) + if newID == oldID { + t.Fatalf("test setup: expected a fresh node id, got same id %d", newID) + } + syncNamespace(t, svc, "auth-svc") + + rows, err = s.ListOutboundCrossRefs(ctx, "web") + if err != nil { + t.Fatalf("list after revive: %v", err) + } + if rows[0].Status != graph.CrossRefStatusResolved || rows[0].ResolvedNodeID == nil || *rows[0].ResolvedNodeID != newID { + t.Fatalf("after revive row = %+v, want resolved to remapped node %d", rows[0], newID) + } +} diff --git a/internal/app/ingest/workflow/build.go b/internal/app/ingest/workflow/build.go index 9b178f6..6f73e5a 100644 --- a/internal/app/ingest/workflow/build.go +++ b/internal/app/ingest/workflow/build.go @@ -196,6 +196,9 @@ func (s *Service) Build(ctx context.Context, opts BuildOptions) (BuildStats, err if err != nil { return stats, err } + if err := s.syncCrossRefs(ctx); err != nil { + return stats, err + } stats.Timing.TotalMS = time.Since(totalStart).Milliseconds() s.logger().Info("build complete", diff --git a/internal/app/ingest/workflow/crossref_hook_test.go b/internal/app/ingest/workflow/crossref_hook_test.go new file mode 100644 index 0000000..a39dee0 --- /dev/null +++ b/internal/app/ingest/workflow/crossref_hook_test.go @@ -0,0 +1,160 @@ +package workflow + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" + + "github.com/tae2089/code-context-graph/internal/adapters/outbound/graphgorm" + "github.com/tae2089/code-context-graph/internal/adapters/outbound/treesitter" + "github.com/tae2089/code-context-graph/internal/app/crossref" + "github.com/tae2089/code-context-graph/internal/app/ingest/incremental" + requestctx "github.com/tae2089/code-context-graph/internal/ctx" + "github.com/tae2089/code-context-graph/internal/domain/graph" + "github.com/tae2089/trace" +) + +type recordingCrossRefSyncer struct { + namespaces []string + err error +} + +func (r *recordingCrossRefSyncer) SyncNamespace(ctx context.Context) error { + if r.err != nil { + return r.err + } + r.namespaces = append(r.namespaces, requestctx.FromContext(ctx)) + return nil +} + +func newCrossRefHookService(t *testing.T) (*Service, *graphgorm.Store, string) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: gormlogger.Discard}) + if err != nil { + t.Fatalf("open db: %v", err) + } + st := graphgorm.New(db) + if err := st.AutoMigrate(); err != nil { + t.Fatalf("migrate: %v", err) + } + svc := &Service{Store: st, UnitOfWork: newTestUnitOfWork(db, nil), Walkers: map[string]Parser{".go": treesitter.NewWalker(treesitter.GoSpec)}, Logger: slog.Default()} + + tmpDir := t.TempDir() + writeFile := func(rel, content string) { + full := filepath.Join(tmpDir, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", rel, err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + writeFile("go.mod", "module github.com/example/project\n\ngo 1.25.0\n") + writeFile("mainpkg/main.go", "package mainpkg\n\nfunc Run() {}\n") + return svc, st, tmpDir +} + +func TestBuild_MaterializesCrossRefsFromSourceComments(t *testing.T) { + svc, st, tmpDir := newCrossRefHookService(t) + svc.CrossRefs = crossref.New(st) + + target := "package mainpkg\n\n// ValidateToken validates a token.\nfunc ValidateToken() {}\n" + if err := os.MkdirAll(filepath.Join(tmpDir, "auth"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "auth/token.go"), []byte(target), 0o644); err != nil { + t.Fatalf("write target: %v", err) + } + authCtx := requestctx.WithNamespace(context.Background(), "auth-svc") + if _, err := svc.Build(authCtx, BuildOptions{Dir: tmpDir}); err != nil { + t.Fatalf("build auth-svc: %v", err) + } + + caller := "package mainpkg\n\n// Login logs a user in.\n// @intent authenticate the session.\n// @see ccg://auth-svc/auth/token.go#ValidateToken\nfunc Login() {}\n" + callerDir := t.TempDir() + if err := os.WriteFile(filepath.Join(callerDir, "go.mod"), []byte("module github.com/example/web\n\ngo 1.25.0\n"), 0o644); err != nil { + t.Fatalf("write go.mod: %v", err) + } + if err := os.MkdirAll(filepath.Join(callerDir, "mainpkg"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(callerDir, "mainpkg/login.go"), []byte(caller), 0o644); err != nil { + t.Fatalf("write caller: %v", err) + } + webCtx := requestctx.WithNamespace(context.Background(), "web") + if _, err := svc.Build(webCtx, BuildOptions{Dir: callerDir}); err != nil { + t.Fatalf("build web: %v", err) + } + + rows, err := st.ListOutboundCrossRefs(context.Background(), "web") + if err != nil { + t.Fatalf("list outbound: %v", err) + } + if len(rows) == 0 { + t.Fatal("no cross refs materialized from @see comment") + } + // The comment may bind to both the function and its file node; every + // materialized row must resolve into auth-svc. + for _, row := range rows { + if row.ToNamespace != "auth-svc" || row.ToSymbol != "ValidateToken" || row.Status != graph.CrossRefStatusResolved || row.ResolvedNodeID == nil { + t.Fatalf("materialized row = %+v, want resolved ref into auth-svc", row) + } + } +} + +func TestBuild_TriggersCrossRefSync(t *testing.T) { + svc, _, tmpDir := newCrossRefHookService(t) + rec := &recordingCrossRefSyncer{} + svc.CrossRefs = rec + + ctx := requestctx.WithNamespace(context.Background(), "web") + if _, err := svc.Build(ctx, BuildOptions{Dir: tmpDir}); err != nil { + t.Fatalf("Build: %v", err) + } + if len(rec.namespaces) != 1 || rec.namespaces[0] != "web" { + t.Fatalf("cross-ref sync calls = %v, want one call for namespace web", rec.namespaces) + } +} + +func TestBuild_PropagatesCrossRefSyncError(t *testing.T) { + svc, _, tmpDir := newCrossRefHookService(t) + svc.CrossRefs = &recordingCrossRefSyncer{err: trace.New("sync boom")} + + if _, err := svc.Build(context.Background(), BuildOptions{Dir: tmpDir}); err == nil { + t.Fatal("Build should surface cross-ref sync failure") + } +} + +func TestBuild_WithoutSyncerSkipsCrossRefSync(t *testing.T) { + svc, _, tmpDir := newCrossRefHookService(t) + if _, err := svc.Build(context.Background(), BuildOptions{Dir: tmpDir}); err != nil { + t.Fatalf("Build without syncer: %v", err) + } +} + +func TestUpdate_TriggersCrossRefSyncOnce(t *testing.T) { + svc, st, tmpDir := newCrossRefHookService(t) + ctx := requestctx.WithNamespace(context.Background(), "web") + if _, err := svc.Build(ctx, BuildOptions{Dir: tmpDir}); err != nil { + t.Fatalf("Build: %v", err) + } + + rec := &recordingCrossRefSyncer{} + svc.CrossRefs = rec + if err := os.WriteFile(filepath.Join(tmpDir, "mainpkg/main.go"), []byte("package mainpkg\n\nfunc Run() {}\n\nfunc Stop() {}\n"), 0o644); err != nil { + t.Fatalf("modify file: %v", err) + } + syncer := incremental.NewWithRegistry(st, map[string]incremental.Parser{".go": treesitter.NewWalker(treesitter.GoSpec)}, incremental.WithLogger(slog.Default())) + if _, err := svc.Update(ctx, UpdateOptions{BuildOptions: BuildOptions{Dir: tmpDir}, Syncer: syncer}); err != nil { + t.Fatalf("Update: %v", err) + } + if len(rec.namespaces) != 1 || rec.namespaces[0] != "web" { + t.Fatalf("cross-ref sync calls = %v, want exactly one call for namespace web", rec.namespaces) + } +} diff --git a/internal/app/ingest/workflow/indexer.go b/internal/app/ingest/workflow/indexer.go index 2e541b0..9ad8bc9 100644 --- a/internal/app/ingest/workflow/indexer.go +++ b/internal/app/ingest/workflow/indexer.go @@ -6,6 +6,8 @@ import ( "fmt" "log/slog" + "github.com/tae2089/trace" + ingestapp "github.com/tae2089/code-context-graph/internal/app/ingest" "github.com/tae2089/code-context-graph/internal/app/ingest/resolve" "github.com/tae2089/code-context-graph/internal/domain/graph" @@ -45,6 +47,12 @@ type batchIncrementalSyncer = ingestapp.BatchIncrementalSyncer // @intent let staged incremental reconciliation use the graph store from the active update transaction. type transactionalBatchIncrementalSyncer = ingestapp.TransactionalBatchIncrementalSyncer +// CrossRefSyncer rebuilds cross-namespace reference state after an ingest commit. +// @intent let build/update trigger cross-ref materialization without depending on its implementation. +type CrossRefSyncer interface { + SyncNamespace(ctx context.Context) error +} + // Service orchestrates graph building and search document refresh. // @intent 파싱 결과 저장과 검색 인덱스 재구성을 하나의 서비스로 묶는다. type Service struct { @@ -54,6 +62,7 @@ type Service struct { ParseCache ingestapp.ParseCache Walkers map[string]Parser Parsers map[string]Parser + CrossRefs CrossRefSyncer Logger *slog.Logger // resolveEdges overrides build-time edge resolution; nil uses resolve.ResolveWithOptions. @@ -71,6 +80,18 @@ func (s *Service) edgeResolver() resolveBuildEdgesFn { return resolve.ResolveWithOptions } +// syncCrossRefs runs the optional cross-ref sync after a successful ingest commit. +// @intent keep cross-namespace reference state current without making it a hard build dependency. +func (s *Service) syncCrossRefs(ctx context.Context) error { + if s.CrossRefs == nil { + return nil + } + if err := s.CrossRefs.SyncNamespace(ctx); err != nil { + return trace.Wrap(err, "sync cross-namespace refs") + } + return nil +} + // logger returns the configured slog.Logger or the process default when none was supplied. // @intent keep service code logging-safe even when callers leave Logger nil. func (s *Service) logger() *slog.Logger { diff --git a/internal/app/ingest/workflow/update.go b/internal/app/ingest/workflow/update.go index cf22daf..b67b02a 100644 --- a/internal/app/ingest/workflow/update.go +++ b/internal/app/ingest/workflow/update.go @@ -53,7 +53,14 @@ func (s *Service) Update(ctx context.Context, opts UpdateOptions) (*ingest.SyncS } defer spool.cleanup(s.logger()) spool.packages = packages - return s.updateGraphWithoutTx(ctx, absDir, opts, packages, spool) + stats, err := s.updateGraphWithoutTx(ctx, absDir, opts, packages, spool) + if err != nil { + return nil, err + } + if err := s.syncCrossRefs(ctx); err != nil { + return nil, err + } + return stats, nil } spool, err := s.prepareUpdateSpool(ctx, absDir, opts) @@ -75,6 +82,9 @@ func (s *Service) Update(ctx context.Context, opts UpdateOptions) (*ingest.SyncS spool.cleanup(s.logger()) return s.buildForUpdate(ctx, opts.BuildOptions, outcome.stats) } + if err := s.syncCrossRefs(ctx); err != nil { + return nil, err + } return outcome.stats, nil } diff --git a/internal/db/migration/migration.go b/internal/db/migration/migration.go index a7203e2..1a8229b 100644 --- a/internal/db/migration/migration.go +++ b/internal/db/migration/migration.go @@ -23,7 +23,7 @@ import ( ) const ( - RequiredSchemaVersion = 14 + RequiredSchemaVersion = 15 SchemaVersionKey = "schema" LegacySchemaVersionTable = "ccg_schema_versions" ) @@ -385,6 +385,7 @@ func RequiredSchemaTables() []string { "parse_cache_entries", "unresolved_edge_candidates", "unresolved_index_states", + "cross_refs", } } @@ -436,6 +437,13 @@ func RequiredTextColumns() []SchemaColumn { {Table: "unresolved_edge_candidates", Column: "kind"}, {Table: "unresolved_index_states", Column: "namespace"}, {Table: "unresolved_index_states", Column: "version"}, + {Table: "cross_refs", Column: "from_namespace"}, + {Table: "cross_refs", Column: "raw"}, + {Table: "cross_refs", Column: "to_namespace"}, + {Table: "cross_refs", Column: "to_path"}, + {Table: "cross_refs", Column: "to_symbol"}, + {Table: "cross_refs", Column: "status"}, + {Table: "cross_refs", Column: "source"}, } } @@ -472,6 +480,12 @@ func ModelNullabilityColumns() []SchemaColumn { {Table: "unresolved_edge_candidates", Column: "kind"}, {Table: "unresolved_index_states", Column: "namespace"}, {Table: "unresolved_index_states", Column: "version"}, + {Table: "cross_refs", Column: "from_namespace"}, + {Table: "cross_refs", Column: "from_node_id"}, + {Table: "cross_refs", Column: "raw"}, + {Table: "cross_refs", Column: "to_namespace"}, + {Table: "cross_refs", Column: "status"}, + {Table: "cross_refs", Column: "source"}, } } diff --git a/internal/db/migration/migration_test.go b/internal/db/migration/migration_test.go index 53c35b4..893eb03 100644 --- a/internal/db/migration/migration_test.go +++ b/internal/db/migration/migration_test.go @@ -9,9 +9,9 @@ import ( "gorm.io/gorm" ) -func TestRequiredSchemaVersion_IncludesUnboundedGraphStrings(t *testing.T) { - if RequiredSchemaVersion != 14 { - t.Fatalf("RequiredSchemaVersion = %d, want 14", RequiredSchemaVersion) +func TestRequiredSchemaVersion_IncludesCrossRefs(t *testing.T) { + if RequiredSchemaVersion != 15 { + t.Fatalf("RequiredSchemaVersion = %d, want 15", RequiredSchemaVersion) } } @@ -143,6 +143,40 @@ func TestSQLiteMigrationThirteen_AddsResolverFileLookupIndexAndCanMigrateDown(t } } +func TestSQLiteMigrationFifteen_CreatesCrossRefsAndCanMigrateDown(t *testing.T) { + dsn := filepath.Join(t.TempDir(), "migration.db") + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("open SQLite: %v", err) + } + migrator, _, err := NewMigrator(db, "sqlite", "") + if err != nil { + t.Fatalf("NewMigrator: %v", err) + } + if err := migrator.Steps(15); err != nil { + t.Fatalf("migrate to version 15: %v", err) + } + + if !db.Migrator().HasTable("cross_refs") { + t.Fatal("version 15 missing table cross_refs") + } + for _, indexName := range []string{"idx_crossref_from_ns", "idx_crossref_to_ns", "idx_crossref_resolved_node"} { + exists, err := sqliteIndexExists(db, indexName) + if err != nil { + t.Fatalf("inspect index %q: %v", indexName, err) + } + if !exists { + t.Fatalf("version 15 missing index %q", indexName) + } + } + if err := migrator.Steps(-1); err != nil { + t.Fatalf("migrate down to version 14: %v", err) + } + if db.Migrator().HasTable("cross_refs") { + t.Fatal("version 14 retained table cross_refs") + } +} + func TestSQLiteMigrationFourteen_PreservesTextColumnsAndCanMigrateDown(t *testing.T) { dsn := filepath.Join(t.TempDir(), "migration.db") db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) diff --git a/internal/db/migration/postgres/000015_cross_refs.down.sql b/internal/db/migration/postgres/000015_cross_refs.down.sql new file mode 100644 index 0000000..fe1e94b --- /dev/null +++ b/internal/db/migration/postgres/000015_cross_refs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS cross_refs; diff --git a/internal/db/migration/postgres/000015_cross_refs.up.sql b/internal/db/migration/postgres/000015_cross_refs.up.sql new file mode 100644 index 0000000..a66ca1c --- /dev/null +++ b/internal/db/migration/postgres/000015_cross_refs.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS cross_refs ( + id bigserial PRIMARY KEY, + from_namespace text NOT NULL, + from_node_id bigint NOT NULL, + raw text NOT NULL, + to_namespace text NOT NULL, + to_path text NOT NULL DEFAULT '', + to_symbol text NOT NULL DEFAULT '', + resolved_node_id bigint, + status text NOT NULL, + source text NOT NULL DEFAULT 'annotation', + created_at timestamptz, + updated_at timestamptz +); + +CREATE INDEX IF NOT EXISTS idx_crossref_from_ns ON cross_refs(from_namespace); +CREATE INDEX IF NOT EXISTS idx_crossref_from_node ON cross_refs(from_node_id); +CREATE INDEX IF NOT EXISTS idx_crossref_to_ns ON cross_refs(to_namespace); +CREATE INDEX IF NOT EXISTS idx_crossref_resolved_node ON cross_refs(resolved_node_id); diff --git a/internal/db/migration/sqlite/000015_cross_refs.down.sql b/internal/db/migration/sqlite/000015_cross_refs.down.sql new file mode 100644 index 0000000..fe1e94b --- /dev/null +++ b/internal/db/migration/sqlite/000015_cross_refs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS cross_refs; diff --git a/internal/db/migration/sqlite/000015_cross_refs.up.sql b/internal/db/migration/sqlite/000015_cross_refs.up.sql new file mode 100644 index 0000000..7cb3ba3 --- /dev/null +++ b/internal/db/migration/sqlite/000015_cross_refs.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS cross_refs ( + id integer PRIMARY KEY AUTOINCREMENT, + from_namespace text NOT NULL, + from_node_id integer NOT NULL, + raw text NOT NULL, + to_namespace text NOT NULL, + to_path text NOT NULL DEFAULT '', + to_symbol text NOT NULL DEFAULT '', + resolved_node_id integer, + status text NOT NULL, + source text NOT NULL DEFAULT 'annotation', + created_at datetime, + updated_at datetime +); + +CREATE INDEX IF NOT EXISTS idx_crossref_from_ns ON cross_refs(from_namespace); +CREATE INDEX IF NOT EXISTS idx_crossref_from_node ON cross_refs(from_node_id); +CREATE INDEX IF NOT EXISTS idx_crossref_to_ns ON cross_refs(to_namespace); +CREATE INDEX IF NOT EXISTS idx_crossref_resolved_node ON cross_refs(resolved_node_id); diff --git a/internal/domain/graph/crossref.go b/internal/domain/graph/crossref.go new file mode 100644 index 0000000..c71a9af --- /dev/null +++ b/internal/domain/graph/crossref.go @@ -0,0 +1,38 @@ +// @index Materialized cross-namespace annotation references for federated graph analysis. +package graph + +import "time" + +// CrossRefStatus describes whether a cross-namespace reference currently resolves to a node. +// @intent distinguish navigable references from dangling ones without deleting authored links. +type CrossRefStatus string + +const ( + CrossRefStatusResolved CrossRefStatus = "resolved" + CrossRefStatusDead CrossRefStatus = "dead" +) + +// CrossRefSource records which signal produced a cross-namespace reference. +// @intent keep room for future non-annotation signals (e.g. import mapping) without schema rework. +type CrossRefSource string + +const CrossRefSourceAnnotation CrossRefSource = "annotation" + +// CrossRef materializes one @see ccg:// annotation tag as queryable cross-namespace graph state. +// @intent make annotation-declared repository links traversable and listable instead of plain tag text. +// @domainRule target identity is symbolic (namespace, path, symbol); resolved_node_id is derived state that rebuilds change. +// @domainRule rows for one source namespace are fully replaced on each build, so no uniqueness constraint is required. +type CrossRef struct { + ID uint `gorm:"primaryKey"` + FromNamespace string `gorm:"type:text;not null;index:idx_crossref_from_ns"` + FromNodeID uint `gorm:"not null;index:idx_crossref_from_node"` + Raw string `gorm:"type:text;not null"` + ToNamespace string `gorm:"type:text;not null;index:idx_crossref_to_ns"` + ToPath string `gorm:"type:text;not null;default:''"` + ToSymbol string `gorm:"type:text;not null;default:''"` + ResolvedNodeID *uint `gorm:"index:idx_crossref_resolved_node"` + Status CrossRefStatus `gorm:"type:text;not null"` + Source CrossRefSource `gorm:"type:text;not null;default:'annotation'"` + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/internal/domain/graph/edge.go b/internal/domain/graph/edge.go index b5aac5a..c965b44 100644 --- a/internal/domain/graph/edge.go +++ b/internal/domain/graph/edge.go @@ -16,6 +16,11 @@ const ( EdgeKindTestedBy EdgeKind = "tested_by" EdgeKindDependsOn EdgeKind = "depends_on" EdgeKindReferences EdgeKind = "references" + + // EdgeKindCrossRef marks a synthetic traversal edge derived from a resolved cross-namespace + // annotation reference. It is never persisted in the edges table; cross-namespace readers + // materialize it from cross_refs rows at query time. + EdgeKindCrossRef EdgeKind = "cross_ref" ) // CallEdgeKinds returns edge kinds that represent a callable relationship. diff --git a/internal/runtime/mcp/runtime.go b/internal/runtime/mcp/runtime.go index 66ec1e6..9f5bb3a 100644 --- a/internal/runtime/mcp/runtime.go +++ b/internal/runtime/mcp/runtime.go @@ -109,6 +109,9 @@ func New(components Components, opts Options) (*Instance, error) { Analysis: mcp.AnalysisToolsDeps{ Impact: impact.New(components.Store), Flow: flows.New(components.Store), Changes: changes.New(components.Store, gitexec.NewExecGitClient()), Reader: components.Store, + CrossImpact: impact.New(components.Store.CrossNamespaceReader()), + CrossFlow: flows.New(components.Store.CrossNamespaceReader()), + CrossRefs: components.Store, }, Docs: mcp.DocsToolsDeps{Retrieval: retrieval.New(components.SearchReader, components.SearchReader)}, Runtime: mcp.RuntimeToolsDeps{ diff --git a/internal/runtime/remote/http.go b/internal/runtime/remote/http.go index 8025b55..527f3dc 100644 --- a/internal/runtime/remote/http.go +++ b/internal/runtime/remote/http.go @@ -17,6 +17,7 @@ import ( "github.com/tae2089/code-context-graph/internal/adapters/outbound/gitrepo" "github.com/tae2089/code-context-graph/internal/adapters/outbound/reposyncgraph" "github.com/tae2089/code-context-graph/internal/adapters/outbound/reposyncobs" + "github.com/tae2089/code-context-graph/internal/app/crossref" "github.com/tae2089/code-context-graph/internal/app/ingest/workflow" "github.com/tae2089/code-context-graph/internal/app/reposync" "github.com/tae2089/code-context-graph/internal/app/search/retrieval" @@ -100,6 +101,9 @@ func buildRepoSyncHTTP(rt *ccgruntime.Runtime, cfg httpin.Config, rules []reposy walkers[ext] = walker } graphSvc := &workflow.Service{Store: rt.Store, UnitOfWork: rt.UnitOfWork, Search: rt.Search, ParseCache: rt.Store, Walkers: walkers, Logger: rt.Logger} + if crossRefStore, ok := any(rt.Store).(crossref.Store); ok { + graphSvc.CrossRefs = crossref.New(crossRefStore) + } syncService := &reposync.Service{ Checkout: gitrepo.NewCheckout(cfg.RepoRoot, repoLocker, nil), BuildScope: configfiles.BuildScope{}, Graph: reposyncgraph.Updater{Service: graphSvc, Syncer: rt.Syncer}, diff --git a/scripts/integration-test.sh b/scripts/integration-test.sh index 9c23502..d1d35f3 100755 --- a/scripts/integration-test.sh +++ b/scripts/integration-test.sh @@ -680,6 +680,72 @@ else warn "MCP session unavailable under local debug override — skipping Phase 12" fi +# ── Phase 13: Cross-namespace refs (ccg:// annotations) ── +if [ -n "$MCP_SESSION" ]; then + info "Phase 13: Testing cross-namespace refs..." + + # Declare a cross-namespace dependency from sample-go to sample-calc via a ccg:// annotation. + cat > "${TMPDIR_CLONE}/repo/main.go" <<'GOEOF' +package main + +import "fmt" + +// @intent greet a user by name +// @see ccg://sample-calc/math.go#subtract +func greet(name string) string { + return fmt.Sprintf("Hello, %s!", name) +} + +func main() { + fmt.Println(greet("world")) +} +GOEOF + pushd "${TMPDIR_CLONE}/repo" >/dev/null + git add -A + git commit -q -m "docs: declare cross-namespace ref to sample-calc" + info "Pushing cross-ref annotation to Gitea (triggers webhook)..." + git push -q origin main + popd >/dev/null + + # The namespace already has nodes, so wait_for_webhook_sync would return + # immediately; poll until the materialized ref itself becomes visible. + info "Waiting for cross-ref materialization (max ${WEBHOOK_WAIT_SECONDS}s)..." + CROSSREF_DEADLINE=$((SECONDS + WEBHOOK_WAIT_SECONDS)) + CROSSREF_RESP="" + while [ $SECONDS -lt $CROSSREF_DEADLINE ]; do + CROSSREF_RESP=$(mcp_call "list_cross_refs" "{\"namespace\":\"${REPO_NAME}\",\"direction\":\"outbound\"}") + CROSSREF_TEXT="" + [ -n "$CROSSREF_RESP" ] && CROSSREF_TEXT=$(mcp_text "$CROSSREF_RESP") || CROSSREF_TEXT="" + if [[ "$CROSSREF_TEXT" == *'"status":"resolved"'* ]]; then + break + fi + sleep 2 + done + assert_mcp_ok "list_cross_refs(outbound)" "$CROSSREF_RESP" + assert_mcp_contains "list_cross_refs(outbound)" "$CROSSREF_RESP" "\"to_namespace\":\"${REPO2_NAME}\"" + assert_mcp_contains "list_cross_refs(outbound)" "$CROSSREF_RESP" '"status":"resolved"' + + RESP=$(mcp_call "list_cross_refs" "{\"namespace\":\"${REPO2_NAME}\",\"direction\":\"inbound\"}") + assert_mcp_contains "list_cross_refs(inbound)" "$RESP" "\"from_namespace\":\"${REPO_NAME}\"" + + # Cross-namespace impact: greet -> (ccg ref) -> subtract in sample-calc. + RESP=$(mcp_call "get_impact_radius" "{\"qualified_name\":\"main.greet\",\"depth\":1,\"namespace\":\"${REPO_NAME}\",\"cross_namespace\":true}") + assert_mcp_contains "get_impact_radius(cross_namespace)" "$RESP" "main.subtract" + assert_mcp_contains "get_impact_radius(cross_namespace)" "$RESP" "\"namespace\":\"${REPO2_NAME}\"" + + # Default impact stays namespace-scoped. + RESP=$(mcp_call "get_impact_radius" "{\"qualified_name\":\"main.greet\",\"depth\":1,\"namespace\":\"${REPO_NAME}\"}") + assert_mcp_not_contains "get_impact_radius(default)" "$RESP" "main.subtract" + + # Federated search across both namespaces labels each hit. + RESP=$(mcp_call "search" "{\"query\":\"subtract\",\"limit\":5,\"namespaces\":[\"${REPO_NAME}\",\"${REPO2_NAME}\"]}") + assert_mcp_contains "search(namespaces)" "$RESP" "\"namespace\":\"${REPO2_NAME}\"" + + info "Phase 13 complete ✅" +else + warn "MCP session unavailable under local debug override — skipping Phase 13" +fi + # ── Phase 14: Documentation discovery tools ── if [ -n "$MCP_SESSION" ]; then info "Phase 14: Testing documentation discovery tools..." @@ -703,9 +769,9 @@ fi # ── Summary ── TOOLS_TESTED=0 -TOTAL_MCP_TOOLS=17 +TOTAL_MCP_TOOLS=18 if [ -n "$MCP_SESSION" ]; then - TOOLS_TESTED=15 + TOOLS_TESTED=16 fi echo "" @@ -718,7 +784,7 @@ fi echo -e "${GREEN}════════════════════════════════════════════════════════${NC}" echo "" info "Pipeline: Gitea push → webhook → ccg clone → build → DB ✅" -info "MCP tools: graph query, analysis, namespace, documentation discovery, postprocess ✅" +info "MCP tools: graph query, analysis, namespace, cross-namespace refs, documentation discovery, postprocess ✅" } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then diff --git a/skills/ccg-analyze/SKILL.md b/skills/ccg-analyze/SKILL.md index ed209d8..c3c10d1 100644 --- a/skills/ccg-analyze/SKILL.md +++ b/skills/ccg-analyze/SKILL.md @@ -26,6 +26,8 @@ Graph-based analysis for **change impact, call flow, and recent-change risk**. | "Who calls this function?" | `query_graph` (callers_of) | | | "What does this function call?" | `query_graph` (callees_of) | | | "Risk of this change" | `detect_changes` + `get_affected_flows` | git diff-based | +| "Which repos depend on this one?" | `list_cross_refs` (direction inbound) | Annotation `ccg://` refs, materialized | +| "Impact across repos?" | `get_impact_radius` with `cross_namespace: true` | Crosses resolved `ccg://` refs both ways | ## Thin `trace_flow` Results @@ -90,11 +92,12 @@ the user specifically needs a bulk export. | Tool | One-liner | | --------------------------- | ---------------------------- | -| `get_impact_radius` | BFS blast radius | -| `trace_flow` | Call chain trace | +| `get_impact_radius` | BFS blast radius; `cross_namespace: true` follows resolved `ccg://` refs | +| `trace_flow` | Call chain trace; `cross_namespace: true` continues into referenced namespaces | | `detect_changes` | Git diff risk score | | `get_affected_flows` | Flows affected by change | | `list_flows` | Stored flow list, paginated | +| `list_cross_refs` | Repository dependency map from materialized `ccg://` refs (`direction`: outbound/inbound/both, `status` filter) | For detailed parameters, see MCP schema. diff --git a/skills/ccg-namespace/SKILL.md b/skills/ccg-namespace/SKILL.md index 2667c8d..fffba12 100644 --- a/skills/ccg-namespace/SKILL.md +++ b/skills/ccg-namespace/SKILL.md @@ -2,7 +2,7 @@ name: ccg-namespace description: "Isolate CCG graph build, search, documentation discovery, and analysis by namespace. Use when working across multiple repositories or services, preventing cross-project graph leakage, listing populated namespaces, or applying one namespace consistently across MCP and CLI operations. Do not use for ordinary single-repository work that fits the default namespace." metadata: - version: 1.1.0 + version: 1.2.0 openclaw: category: "code-intelligence" domain: "namespace" @@ -42,9 +42,10 @@ search_docs(namespace: "payment", query: "payment flow") | ---- | --- | | `list_namespaces` | List namespaces containing graph data and their node counts | | `build_or_update_graph` | Build or incrementally update one namespace from a filesystem path | -| `search` | Search code nodes inside a namespace | -| `search_docs` | Find documentation candidates inside a namespace | +| `search` | Search code nodes inside a namespace; `namespaces: []` federates across several with per-item labels | +| `search_docs` | Find documentation candidates inside a namespace; `namespaces: []` groups results per namespace | | `get_doc_content` | Read a selected generated document, optionally namespace-scoped | +| `list_cross_refs` | List materialized `ccg://` refs for a namespace (`direction`: outbound/inbound/both) | ## Operational Guidance @@ -55,10 +56,27 @@ search_docs(namespace: "payment", query: "payment flow") - Namespace deletion and file upload are not MCP capabilities; manage source directories outside CCG and rebuild graph state as needed. - A graph namespace does not generate or copy Markdown files. Generate and place docs separately before expecting namespace-scoped `get_doc_content` reads to succeed. +## Cross-Namespace Links + +Annotation `@see ccg://{namespace}/{path}#{symbol}` tags are materialized into +queryable cross-namespace references on every build/update: + +- `list_cross_refs(namespace, direction)` returns the repository-level + dependency map (outbound = declared dependencies, inbound = dependents). +- `get_impact_radius(..., cross_namespace: true)` and + `trace_flow(..., cross_namespace: true)` traverse resolved refs across + namespace boundaries; result nodes carry a namespace label. +- Refs re-resolve automatically after either side rebuilds; `status: dead` + marks targets that no longer exist (also reported by `ccg lint` as + `dead-ref`). +- Federated reads (`namespaces: []` on `search`, `query_graph`, + `list_graph_stats`, `search_docs`) fan out per namespace and label every + result; they never merge counts across namespaces. + ## Boundary - Use the default namespace for one local repository unless isolation is required. -- Never combine evidence from different namespaces without labeling each source. +- Never combine evidence from different namespaces without labeling each source; federated and cross-namespace tools label results for you. - Keep filesystem source ownership outside CCG; namespaces isolate graph state, not repository permissions. - Verify the selected namespace has graph rows before interpreting an empty search as no match.