diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f71715..c8e60b65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ the compatibility and migration notes before upgrading. ### Added +- Name-addressed callers, callees and impact radius for v2 (CodeGraph) + generations: `POST /v1/graph/callers`, `/v1/graph/callees` and + `/v1/graph/impact-radius`, and the MCP tools `graph_callers`, `graph_callees` + and `graph_impact_radius`. They follow CodeGraph's `codegraph_callers`, + `codegraph_callees` and `codegraph_impact`: qualified names, per-definition + sections, `file` narrowing and limits. They are compared with the pinned + CodeGraph answers. Differences in definition order and non-exact name fallback + are documented in `docs/graph-exploration.md`. - Repository-scoped graph publication (CodeGraph parity S1.08). `POST /v1/graph/uploads` now accepts v2 artifacts (`application/vnd.graphnest.graph.v2+protobuf`). Administrators can publish diff --git a/docs/execplans/codegraph-parity.md b/docs/execplans/codegraph-parity.md index 1ce09725..020c0272 100644 --- a/docs/execplans/codegraph-parity.md +++ b/docs/execplans/codegraph-parity.md @@ -5,6 +5,25 @@ Implementation, validation, draft publication, and release are separate states. ## Progress +- 2026-09-27: S1.07 symbol tools on `feat/codegraph/s1-07-symbol-tools`, based + on `main`. The pinned harness now also runs the upstream `codegraph_callers`, + `codegraph_callees`, and `codegraph_impact` handlers: eleven new real answers, + 52 in all. + - The timing baseline was refreshed because it pins the harness hash; timings + were measured on the same machine class. + - GraphNest adds name-addressed `SymbolCalls`/`SymbolImpact` queries, service + authorization, REST (`/v1/graph/callers`, `/callees`, `/impact-radius`), MCP + (`graph_callers`, `graph_callees`, `graph_impact_radius`), and capability + workflows. + - `TestGraphSymbolToolsMatchCodeGraph` loads the real fixture into PostgreSQL + and renders GraphNest's structured answers in upstream's layout. The eight + callers/callees answers match exactly apart from the order of same-named + definitions. The three impact answers match as sets: depth 1 is equal, and + depth 2 adds only the two documented shortest-depth nodes. + - Reversing the relation order fails five cases. + - REST and MCP return identical answers from PostgreSQL, and an ungranted + repository returns 404. + - 2026-09-27: S1.08 repository-scoped publication on `feat/codegraph/s1-08-publish-policy`, based on `main`. `POST /v1/graph/uploads` now accepts v2 artifacts from administrators and from @@ -509,6 +528,19 @@ these tests. transaction if that window matters. - REST-published generations record no producer capability list. Stage 2 should declare capabilities once it negotiates CodeGraph schema versions. +- Symbol tools resolve exact-name candidates with upstream's `matchesSymbol` + rules instead of `Discover`. `Discover` is exploration ranking: it splits + `MissingFixtureSymbol987` and matches `Fixture`, while upstream `searchNodes` + returns nothing. Two consequences are recorded until `searchNodes` (FTS5 BM25, + LIKE and Levenshtein fallbacks, rescoring) is ported: + - same-named definitions are ordered by generated flag, path, and line instead + of by BM25; + - a name with no exact match returns `not_found` instead of the best + non-exact hit. +- Callers and callees read edges in the producer's row order. Upstream's + unordered `IN (...)` over `idx_edges_{target,source}_kind` yields kind + ascending, then rowid; v2 fixtures keep rowid order as the edge ordinal. Stage + 2 importers must export edges in rowid order to keep this. ## Discoveries @@ -683,6 +715,10 @@ The rebased sessions layer also passes the exact two-call restoration comparison implemented. Production query parity, browser parity, CLI import, and local-engine work remains pending. Publication has no MCP tool; publishers use REST. +- `searchNodes` has no GraphNest port; symbol tools use exact-name candidates + (see Decisions). `codegraph_node` (file and symbol modes) and the remaining + S1.07 transports are still to do. The Nix option-path branch is covered by + code review only; the pinned fixture has no Nix sources. - Full Stage 1 validation (including authorization, database, browser, deployment, and real-producer conformance) has not run and is not claimed as passing. - The proposed warm-query p95 budgets remain unchanged: existing GraphNest within @@ -712,6 +748,7 @@ The rebased sessions layer also passes the exact two-call restoration comparison | S1.06a3 entity impact | `feat/codegraph/s1-06a3-entity-impact` | Implemented, independently approved and signed (`931e7d9`); depends on PR #81 | Draft [PR #82](https://github.com/balcsida/graphnest/pull/82); native stack #66, position 17; CI and CodeQL passed | | S1.06b1 type relations and hierarchy | `feat/codegraph/type-hierarchy` | Implemented; focused unit, service and PostgreSQL checks pass; based on `main` | [PR #119](https://github.com/balcsida/graphnest/pull/119) | | S1.08 publication policy | `feat/codegraph/s1-08-publish-policy` | Implemented; unit race, PostgreSQL integration race (apart from clock-skewed supply-chain claims that fail on `main` too), vet, staticcheck and OpenAPI checks pass; based on `main` | [PR #123](https://github.com/balcsida/graphnest/pull/123) | +| S1.07 symbol tools | `feat/codegraph/s1-07-symbol-tools` | Implemented; oracle, unit race, PostgreSQL integration race, vet, staticcheck, OpenAPI and parity-reference checks pass; based on `main` | [PR #124](https://github.com/balcsida/graphnest/pull/124) | The first one-branch submission created a draft PR without a remote stack. Submitting the second real dependent layer created native stack #66 diff --git a/docs/graph-exploration.md b/docs/graph-exploration.md index c48db436..3fabe3bc 100644 --- a/docs/graph-exploration.md +++ b/docs/graph-exploration.md @@ -224,7 +224,46 @@ changes before delivery. Discovery and exploration require an artifact v2 generation with its discovery projection. Missing or stale data returns `graph_not_ready`; an empty ready result is never used to hide unavailable data. Capabilities report the selected -generation and producer coverage separately from server workflows. Public graph -upload remains v1 only: there is no public v2 upload endpoint in this milestone. +generation and producer coverage separately from server workflows. v2 +generations are published through `POST /v1/graph/uploads` by administrators or +repository publication grantees; see [operations](operations.md#publishing-v2-graph-generations). Session history, entity selectors, and qualified wildcard methods are not part of these public requests. + +## Symbol callers, callees and impact + +`POST /v1/graph/callers`, `/v1/graph/callees`, and `/v1/graph/impact-radius`, and +the MCP tools `graph_callers`, `graph_callees`, and `graph_impact_radius`, address +a v2 generation by symbol name, like CodeGraph's `codegraph_callers`, +`codegraph_callees`, and `codegraph_impact`. They follow the pinned handlers: + +- A name matches a node's name, a file's name without its extension, or a + qualified form (`Class.method`, `module::fn`, `dir/module`). The qualified form + matches the qualified-name suffix, or the containing directories and file for + Rust modules and Python packages. `crate::`, `super::`, and `self::` are + ignored, and an Erlang arity (`fn/3`) must match. A dotted Nix option resolves + its `options.` declaration and writes first. +- Matches sharing a path and qualified name form one definition, so same-file + overloads stay together and same-named classes in different apps stay apart. + `file` narrows by path or path suffix. When it matches nothing, every + definition is returned with `file_filter: unmatched`. +- Callers and callees follow `calls`, `imports`, `instantiates`, `navigates`, and + `references` one hop, in the producer's edge order. Each neighbor appears once + with the first edge that reached it; the edge kind tells an instantiation or + import apart from a call. `limit` bounds each definition (default 20, 1-100) + and `truncated` says more exist. +- Impact merges each definition's impact radius (`depth` default 2, 1-10). + +`TestGraphSymbolToolsMatchCodeGraph` compares all eleven captured `mcp-callers-*`, +`mcp-callees-*`, and `mcp-impact-*` answers. Known differences: + +- CodeGraph orders same-named definitions by SQLite FTS5 BM25 score. GraphNest + orders them by generated file last, then path and line. +- CodeGraph falls back to its best non-exact search hit when no definition + matches exactly. GraphNest returns `not_found` until a `searchNodes` port lands. + At most 50 matches are considered, as upstream does; `candidate_limit` marks + more. +- Impact visits dependency levels by shortest depth (see + [graph analysis](graph-analysis.md#entity-impact-and-public-graph-projections)). + It can therefore list nodes that CodeGraph's depth-first walk omits, in a + different order within a file. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9915f1b8..09033c7a 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -558,6 +558,54 @@ paths: '500': {description: Response exceeded the configured byte limit} '503': {$ref: '#/components/responses/Unavailable'} '504': {$ref: '#/components/responses/Timeout'} + /v1/graph/callers: + post: + description: Everything that calls, imports, instantiates, navigates to, or references each definition of a symbol name, one entry per distinct definition (same path and qualified name). Each neighbor appears once with the first edge that reached it, in the producer's edge order. + security: [{bearerAuth: []}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/GraphSymbolCallsRequest'}}}} + responses: + '200': {description: Definitions of the symbol with their bounded results; status not_found when no definition matches exactly, content: {application/json: {schema: {$ref: '#/components/schemas/GraphSymbolResponse'}}}} + '400': {$ref: '#/components/responses/InvalidRequest'} + '401': {$ref: '#/components/responses/Unauthenticated'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/GraphConflict'} + '413': {$ref: '#/components/responses/InvalidRequest'} + '415': {$ref: '#/components/responses/InvalidRequest'} + '500': {description: Response exceeded the configured byte limit} + '503': {$ref: '#/components/responses/Unavailable'} + '504': {$ref: '#/components/responses/Timeout'} + /v1/graph/callees: + post: + description: The outgoing counterpart of /v1/graph/callers. + security: [{bearerAuth: []}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/GraphSymbolCallsRequest'}}}} + responses: + '200': {description: Definitions of the symbol with their bounded results; status not_found when no definition matches exactly, content: {application/json: {schema: {$ref: '#/components/schemas/GraphSymbolResponse'}}}} + '400': {$ref: '#/components/responses/InvalidRequest'} + '401': {$ref: '#/components/responses/Unauthenticated'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/GraphConflict'} + '413': {$ref: '#/components/responses/InvalidRequest'} + '415': {$ref: '#/components/responses/InvalidRequest'} + '500': {description: Response exceeded the configured byte limit} + '503': {$ref: '#/components/responses/Unavailable'} + '504': {$ref: '#/components/responses/Timeout'} + /v1/graph/impact-radius: + post: + description: Each distinct definition's merged impact radius. Unlike /v1/graph/impact it addresses v2 generations by symbol name. + security: [{bearerAuth: []}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/GraphSymbolImpactRequest'}}}} + responses: + '200': {description: Definitions of the symbol with their bounded results; status not_found when no definition matches exactly, content: {application/json: {schema: {$ref: '#/components/schemas/GraphSymbolResponse'}}}} + '400': {$ref: '#/components/responses/InvalidRequest'} + '401': {$ref: '#/components/responses/Unauthenticated'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/GraphConflict'} + '413': {$ref: '#/components/responses/InvalidRequest'} + '415': {$ref: '#/components/responses/InvalidRequest'} + '500': {description: Response exceeded the configured byte limit} + '503': {$ref: '#/components/responses/Unavailable'} + '504': {$ref: '#/components/responses/Timeout'} /v1/graph/explore: post: security: [{bearerAuth: []}] @@ -2107,7 +2155,7 @@ components: version: {type: integer, const: 1} query_artifact_versions: {type: array, items: {type: integer, enum: [1, 2]}} upload_artifact_versions: {type: array, items: {type: integer, enum: [1, 2]}, description: POST /v1/graph/uploads accepts v1 from administrators and v2 from administrators or publication grantees.} - workflows: {type: array, items: {type: object, additionalProperties: false, required: [name, artifact_version], properties: {name: {type: string, enum: [context, impact, trace, discover, explore, files, capabilities]}, artifact_version: {type: integer, enum: [1, 2]}}}} + workflows: {type: array, items: {type: object, additionalProperties: false, required: [name, artifact_version], properties: {name: {type: string, enum: [context, impact, trace, discover, explore, files, capabilities, callers, callees, impact_radius]}, artifact_version: {type: integer, enum: [1, 2]}}}} status: {type: string, enum: [ready]} freshness: {type: string, enum: [current]} repository_id: {type: integer, format: int64, minimum: 1} @@ -2209,6 +2257,54 @@ components: range: {$ref: '#/components/schemas/GraphPosition'} confidence: {type: number} resolution_reason: {type: string} + GraphSymbolCallsRequest: + type: object + additionalProperties: false + required: [symbol] + properties: + repo: {$ref: '#/components/schemas/GraphRepositorySelector'} + branch: {type: string, minLength: 1} + symbol: {type: string, minLength: 1, maxLength: 16384, description: 'Name, or qualified name such as Class.method, module::fn or dir/module'} + file: {type: string, maxLength: 16384, description: Narrow same-named definitions to this path or path suffix; when none matches every definition is returned and file_filter is unmatched.} + limit: {type: integer, description: Per definition; default 20; clamped to 1-100.} + GraphSymbolImpactRequest: + type: object + additionalProperties: false + required: [symbol] + properties: + repo: {$ref: '#/components/schemas/GraphRepositorySelector'} + branch: {type: string, minLength: 1} + symbol: {type: string, minLength: 1, maxLength: 16384} + file: {type: string, maxLength: 16384} + depth: {type: integer, description: Default 2; clamped to 1-10.} + GraphSymbolRelated: + type: object + additionalProperties: false + required: [entity, edge] + properties: + entity: {$ref: '#/components/schemas/GraphEntityV2'} + edge: {$ref: '#/components/schemas/GraphEvidenceV2'} + GraphSymbolDefinition: + type: object + additionalProperties: false + required: [definitions] + properties: + definitions: {type: array, minItems: 1, items: {$ref: '#/components/schemas/GraphEntityV2'}, description: Every match sharing one path and qualified name.} + related: {type: array, items: {$ref: '#/components/schemas/GraphSymbolRelated'}, description: Callers or callees.} + truncated: {type: boolean, description: More callers or callees exist than limit.} + entities: {type: array, items: {$ref: '#/components/schemas/GraphEntityV2'}, description: Impact radius, definitions included.} + edges: {type: array, items: {$ref: '#/components/schemas/GraphEvidenceV2'}} + GraphSymbolResponse: + type: object + additionalProperties: false + required: [status, definitions, generations, partial] + properties: + status: {type: string, enum: [ok, not_found]} + file_filter: {type: string, enum: [matched, unmatched]} + definitions: {type: array, items: {$ref: '#/components/schemas/GraphSymbolDefinition'}} + generations: {type: array, minItems: 1, items: {$ref: '#/components/schemas/GraphGeneration'}} + boundaries: {type: array, items: {$ref: '#/components/schemas/GraphBoundary'}} + partial: {type: boolean} GraphBoundary: type: object additionalProperties: false diff --git a/docs/parity/codegraph.md b/docs/parity/codegraph.md index 92801400..63d40d54 100644 --- a/docs/parity/codegraph.md +++ b/docs/parity/codegraph.md @@ -13,7 +13,7 @@ This inventory is the S1.01 acceptance backlog, based on command declarations, M - Source references below are paths at the pinned commit. Test references establish useful oracle cases; the acceptance task still requires a GraphNest-side assertion. Synthetic schema records establish transport coverage only; they never count as producer extraction or language/framework parity. - Preserve existing GraphNest managed enrichment and SCIP compatibility. No other analyzer is an input or a new dependency in this initiative. GraphNest numeric relationship values must retain their existing identities rather than adopt CodeGraph array positions. -Captured `oracle:` IDs below are exact keys in `test/fixtures/codegraph/library-expected.json` and `manifest.json.reference_tasks`. The workflow layer records 41 real producer answers, including 28 representative MCP/CLI/viewer-service cases, with runnable source-evidenced assertions in `test/parity/reference.mjs` and `test/parity/test_reference.py`. These captures do not complete the broader acceptance task, browser interaction, transport contract or GraphNest comparison. Native client exports and remaining variants retain their planned rows. +Captured `oracle:` IDs below are exact keys in `test/fixtures/codegraph/library-expected.json` and `manifest.json.reference_tasks`. The workflow layer records 52 real producer answers, including 39 representative MCP/CLI/viewer-service cases, with runnable source-evidenced assertions in `test/parity/reference.mjs` and `test/parity/test_reference.py`. These captures do not complete the broader acceptance task, browser interaction, transport contract or GraphNest comparison. Native client exports and remaining variants retain their planned rows. S1.02's [artifact v2 contract](../graph-artifact-v2.md) has passing serialization checks in `internal/graphartifact/v2_fixture_test.go`: every original column in @@ -70,9 +70,9 @@ The upstream exports eight definitions, all read-only annotated. **Only `codegra | Behavior / schema | Upstream source / test | Stage | Intended GraphNest surface | Fixture/task; comparison | Status | |---|---|---|---|---|---| | search: required query; optional kind enum (including upstream `type`), limit 10, projectPath | `src/mcp/tools.ts:1037`; `__tests__/mcp-tool-annotations.test.ts` | S1.07, S3.04 | search MCP | planned:mcp-search; schema + answer, explicit kind mapping | planned | -| callers: required symbol; file suffix disambiguation, limit 20, projectPath | `src/mcp/tools.ts:1063`; `__tests__/same-name-disambiguation.test.ts` | S1.07, S3.04 | callers MCP | planned:mcp-callers; schema + answer | planned | -| callees: required symbol; file, limit 20, projectPath | `src/mcp/tools.ts:1088`; `__tests__/graph.test.ts` | S1.07, S3.04 | callees MCP | planned:mcp-callees; schema + answer | planned | -| impact: required symbol; file, depth 2, projectPath | `src/mcp/tools.ts:1113`; `__tests__/graph.test.ts` | S1.07, S3.04 | impact MCP | planned:mcp-impact; schema + answer | planned | +| callers: required symbol; file suffix disambiguation, limit 20, projectPath | `src/mcp/tools.ts:1063`; `__tests__/same-name-disambiguation.test.ts` | S1.07, S3.04 | callers MCP | oracle: mcp-callers-grouped, mcp-callers-file, mcp-callers-file-miss, mcp-callers-limit, mcp-callers-qualified, mcp-callers-missing; schema + answer | GraphNest REST/MCP compared (`TestGraphSymbolToolsMatchCodeGraph`); definition order and non-exact fallback differ, see graph-exploration.md | +| callees: required symbol; file, limit 20, projectPath | `src/mcp/tools.ts:1088`; `__tests__/graph.test.ts` | S1.07, S3.04 | callees MCP | oracle: mcp-callees-single, mcp-callees-grouped; schema + answer | GraphNest REST/MCP compared (`TestGraphSymbolToolsMatchCodeGraph`); definition order and non-exact fallback differ, see graph-exploration.md | +| impact: required symbol; file, depth 2, projectPath | `src/mcp/tools.ts:1113`; `__tests__/graph.test.ts` | S1.07, S3.04 | impact MCP | oracle: mcp-impact-file, mcp-impact-depth, mcp-impact-grouped; schema + answer | GraphNest REST/MCP compared (`TestGraphSymbolToolsMatchCodeGraph`); definition order and non-exact fallback differ, see graph-exploration.md | | node: symbol or file mode; includeCode false, offset 1-based, limit capped at 2000 in file mode, symbolsOnly false, line disambiguation; schema has no required fields | `src/mcp/tools.ts:1138`; `__tests__/node-file-view.test.ts`, `symbol-lookup.test.ts` | S1.07, S3.04 | source/entity MCP | planned:mcp-node; both modes and invalid-empty call | planned | | explore: required query; maxFiles 12, projectPath; source and call path in one capped response | `src/mcp/tools.ts:1180`; `__tests__/explore-output-budget.test.ts` | S1.07, S3.04 | primary exploration MCP | planned:mcp-explore; oracle: mcp-explore-source, mcp-explore-unmatched-fallback; schema + answer | source/fallback reference captured; transport, schema variants and GraphNest planned | | status: optional projectPath | `src/mcp/tools.ts:1201`; `__tests__/mcp-unindexed.test.ts` | S1.07, S3.04 | health/capabilities MCP | planned:mcp-status; missing/partial/failed graph | planned | diff --git a/internal/graphprotocol/symbols.go b/internal/graphprotocol/symbols.go new file mode 100644 index 00000000..8a812531 --- /dev/null +++ b/internal/graphprotocol/symbols.go @@ -0,0 +1,49 @@ +package graphprotocol + +// SymbolCallsRequest addresses a symbol by name, the way CodeGraph's callers +// and callees tools do. Direction is "incoming" (callers) or "outgoing" +// (callees). File narrows same-named definitions by path or path suffix. +type SymbolCallsRequest struct { + Scope Scope `json:"scope"` + Symbol string `json:"symbol"` + File string `json:"file,omitempty"` + Direction string `json:"direction"` + Limit int `json:"limit,omitempty"` +} + +// SymbolImpactRequest is the name-addressed impact radius. +type SymbolImpactRequest struct { + Scope Scope `json:"scope"` + Symbol string `json:"symbol"` + File string `json:"file,omitempty"` + Depth int `json:"depth,omitempty"` +} + +// SymbolRelated is one neighbor with the first edge that reached it. +type SymbolRelated struct { + Entity Entity `json:"entity"` + Edge Evidence `json:"edge"` +} + +// SymbolDefinition is one distinct definition: every match sharing a file and +// qualified name (same-file overloads stay together). +type SymbolDefinition struct { + Definitions []Entity `json:"definitions"` + Related []SymbolRelated `json:"related,omitempty"` + Truncated bool `json:"truncated,omitempty"` + // Impact fields; empty for callers and callees. + Entities []Entity `json:"entities,omitempty"` + Edges []Evidence `json:"edges,omitempty"` +} + +// SymbolResponse reports status ok or not_found. FileFilter is "matched" when +// File narrowed the definitions and "unmatched" when no definition matched +// File, so every definition is returned instead. +type SymbolResponse struct { + Status string `json:"status"` + FileFilter string `json:"file_filter,omitempty"` + Definitions []SymbolDefinition `json:"definitions"` + Generations []Generation `json:"generations"` + Boundaries []Boundary `json:"boundaries,omitempty"` + Partial bool `json:"partial"` +} diff --git a/internal/graphquery/entity_store.go b/internal/graphquery/entity_store.go index 8a56ee6d..273afd38 100644 --- a/internal/graphquery/entity_store.go +++ b/internal/graphquery/entity_store.go @@ -28,6 +28,9 @@ type EntityNeighborQuery struct { Direction string MinConfidence float64 Limit int + // ProducerOrder returns edges in the producer's original row order + // instead of by neighbor occurrence. + ProducerOrder bool } type EntityNeighbor struct { diff --git a/internal/graphquery/symbols.go b/internal/graphquery/symbols.go new file mode 100644 index 00000000..5b1e40ed --- /dev/null +++ b/internal/graphquery/symbols.go @@ -0,0 +1,450 @@ +package graphquery + +import ( + "cmp" + "context" + "regexp" + "slices" + "strings" + "unicode/utf8" + + graphv2 "github.com/balcsida/graphnest/internal/graphartifact/v2" + "github.com/balcsida/graphnest/internal/graphprotocol" +) + +// Name-addressed callers, callees and impact, ported from CodeGraph b9ca4b7's +// MCP handlers (src/mcp/tools.ts handleCallers/handleCallees/handleImpact) and +// src/graph/named-symbol-flow.ts findAllSymbols/matchesSymbol. + +var ( + aritySpelling = regexp.MustCompile(`^(.+)/(\d{1,3})$`) + qualifiedArity = regexp.MustCompile(`/(\d{1,3})$`) + trailingArity = regexp.MustCompile(`/\d{1,3}$`) + qualifierSeparator = regexp.MustCompile(`::|[./]`) + trailingExtension = regexp.MustCompile(`\.[^.]+$`) + nixOptionPath = regexp.MustCompile(`^[a-z][\w'-]*(?:\.[\w'-]+)+$`) + rustPathPrefixes = map[string]bool{"crate": true, "super": true, "self": true} +) + +// symbolCallRelations is upstream's getCallers/getCallees edge set in the +// order SQLite walks its IN list over (target|source, kind): kind ascending, +// then row order, which v2 generations keep as the edge ordinal. +var symbolCallRelations = []string{"calls", "imports", "instantiates", "navigates", "references"} + +const symbolSearchLimit = 50 + +func qualifierParts(symbol string) []string { + var parts []string + for _, part := range qualifierSeparator.Split(symbol, -1) { + if part != "" { + parts = append(parts, part) + } + } + return parts +} + +func qualified(symbol string) bool { + return strings.ContainsAny(symbol, "./") || strings.Contains(symbol, "::") +} + +func lastQualifierPart(symbol string) string { + noArity := trailingArity.ReplaceAllString(symbol, "") + if noArity == "" { + noArity = symbol + } + parts := qualifierParts(noArity) + if len(parts) == 0 { + return symbol + } + return parts[len(parts)-1] +} + +func matchesSymbol(node *graphv2.Node, symbol string) bool { + if spelling := aritySpelling.FindStringSubmatch(symbol); spelling != nil { + if arity := qualifiedArity.FindStringSubmatch(node.GetQualifiedName()); arity != nil { + if arity[1] != spelling[2] { + return false + } + symbol = spelling[1] + } + } + if node.GetName() == symbol { + return true + } + if node.GetKind() == "file" && trailingExtension.ReplaceAllString(node.GetName(), "") == symbol { + return true + } + if !qualified(symbol) { + return false + } + parts := qualifierParts(symbol) + if len(parts) < 2 || node.GetName() != parts[len(parts)-1] { + return false + } + if strings.Contains(node.GetQualifiedName(), strings.Join(parts, "::")) { + return true + } + var hints []string + for _, part := range parts[:len(parts)-1] { + if !rustPathPrefixes[part] { + hints = append(hints, part) + } + } + if len(hints) == 0 { + return false + } + var segments []string + for _, segment := range strings.Split(node.GetPath(), "/") { + if segment != "" { + segments = append(segments, segment) + } + } + for _, hint := range hints { + if !slices.ContainsFunc(segments, func(segment string) bool { + return segment == hint || trailingExtension.ReplaceAllString(segment, "") == hint + }) { + return false + } + } + return true +} + +// groupDefinitions keeps one group per (path, qualified name). A file filter +// that matches nothing is reported "unmatched" and every definition is kept. +func groupDefinitions(matches []graphprotocol.Entity, file string) ([][]graphprotocol.Entity, string) { + pool, filter := matches, "" + if file != "" { + wanted := strings.TrimPrefix(file, "./") + var narrowed []graphprotocol.Entity + for _, match := range matches { + path := match.Fact.GetPath() + if path == wanted || strings.HasSuffix(path, wanted) || strings.HasSuffix(path, "/"+wanted) { + narrowed = append(narrowed, match) + } + } + filter = "unmatched" + if len(narrowed) > 0 { + pool, filter = narrowed, "matched" + } + } + type definition struct{ path, qualified string } + index := map[definition]int{} + var groups [][]graphprotocol.Entity + for _, match := range pool { + key := definition{match.Fact.GetPath(), match.Fact.GetQualifiedName()} + if i, ok := index[key]; ok { + groups[i] = append(groups[i], match) + continue + } + index[key] = len(groups) + groups = append(groups, []graphprotocol.Entity{match}) + } + return groups, filter +} + +type symbolScope struct { + ready entityReady + internal map[int64]int64 // public repository ID -> internal + groups [][]graphprotocol.Entity + filter string + truncated bool +} + +func (service *Service) symbolDefinitions(ctx context.Context, scope graphprotocol.Scope, symbol, file string) (symbolScope, error) { + if symbol == "" || len(symbol) > 16384 || !utf8.ValidString(symbol) || len(file) > 16384 || !utf8.ValidString(file) { + return symbolScope{}, ErrInvalidRequest + } + ready, err := service.readyEntities(ctx, scope) + if err != nil { + return symbolScope{}, err + } + result := symbolScope{ready: ready, internal: map[int64]int64{}} + for internal, public := range ready.publicIDs { + result.internal[public] = internal + } + matches, truncated, err := service.findAllSymbols(ctx, result, scope, symbol) + if err != nil { + return symbolScope{}, err + } + result.truncated = truncated + result.groups, result.filter = groupDefinitions(matches, file) + return result, nil +} + +// findAllSymbols returns public entities: the Nix option-path convention, else +// every definition matchesSymbol accepts, generated files last, then by path +// and line. +// +// ponytail: upstream draws candidates from its top 50 searchNodes (SQLite FTS5 +// BM25) results, orders same-named definitions by that score, and falls back +// to the best non-exact hit when nothing matches exactly. Without a +// searchNodes port GraphNest uses exact-name candidates, a deterministic +// order, and reports not_found instead of guessing. +func (service *Service) findAllSymbols(ctx context.Context, scope symbolScope, request graphprotocol.Scope, symbol string) ([]graphprotocol.Entity, bool, error) { + if nixOptionPath.MatchString(symbol) { + hits, err := service.nixOptionHits(ctx, scope.ready, symbol) + if err != nil || len(hits) > 0 { + return hits, false, err + } + } + selectors := []graphprotocol.EntitySelector{{Name: &symbol}} + if tail := lastQualifierPart(symbol); qualified(symbol) && tail != symbol { + selectors = append(selectors, graphprotocol.EntitySelector{Name: &tail}) + } else if !qualified(symbol) { + // A file also matches by its name without the extension. + selectors = append(selectors, graphprotocol.EntitySelector{NameMatch: &graphprotocol.NameSelector{Mode: "prefix", Value: symbol + ".", Kinds: []string{"file"}}}) + } + var matches []graphprotocol.Entity + seen := map[nodeKey]bool{} + for _, selector := range selectors { + found, err := scope.ready.store.QueryEntities(ctx, EntityQuery{Snapshots: scope.ready.selected, Selector: selector, Limit: symbolSearchLimit + 1}) + if err != nil { + return nil, false, err + } + for _, entity := range found { + key := nodeKey{entity.RepositoryID, entity.Fact.GetOccurrence()} + if !seen[key] && matchesSymbol(entity.Fact, symbol) { + seen[key] = true + matches = append(matches, entity) + } + } + } + if err := scope.ready.publicEntities(matches); err != nil { + return nil, false, err + } + generated, err := service.generatedPaths(ctx, request, matches) + if err != nil { + return nil, false, err + } + slices.SortStableFunc(matches, func(a, b graphprotocol.Entity) int { + return cmp.Or( + cmp.Compare(boolRank(generated[a.Fact.GetPath()]), boolRank(generated[b.Fact.GetPath()])), + cmp.Compare(a.Fact.GetPath(), b.Fact.GetPath()), + cmp.Compare(a.Fact.GetLocation().GetStart().GetLine(), b.Fact.GetLocation().GetStart().GetLine()), + cmp.Compare(a.Fact.GetOccurrence(), b.Fact.GetOccurrence()), + ) + }) + truncated := len(matches) > symbolSearchLimit + return matches[:min(len(matches), symbolSearchLimit)], truncated, nil +} + +func boolRank(value bool) int { + if value { + return 1 + } + return 0 +} + +func sameGenerations(a, b []graphprotocol.Generation) bool { + return slices.EqualFunc(a, b, func(x, y graphprotocol.Generation) bool { + return x.RepositoryID == y.RepositoryID && x.UploadID == y.UploadID && x.Commit == y.Commit + }) +} + +func (service *Service) generatedPaths(ctx context.Context, scope graphprotocol.Scope, entities []graphprotocol.Entity) (map[string]bool, error) { + var paths []string + for _, entity := range entities { + if path := entity.Fact.GetPath(); path != "" && !slices.Contains(paths, path) { + paths = append(paths, path) + } + } + generated := map[string]bool{} + for chunk := range slices.Chunk(paths, maxFileClassificationPaths) { + classified, err := service.FileClassifications(ctx, graphprotocol.FileClassificationRequest{Scope: scope, Paths: chunk}) + if err != nil { + return nil, err + } + for _, file := range classified.Files { + generated[file.Path] = file.Generated + } + } + return generated, nil +} + +// nixOptionHits resolves a dotted Nix option: its `options.` declaration, +// then the exact write, then up to 12 prefixed writes, at most 10 in all. +func (service *Service) nixOptionHits(ctx context.Context, ready entityReady, symbol string) ([]graphprotocol.Entity, error) { + byName := func(name string) ([]graphprotocol.Entity, error) { + found, err := ready.store.QueryEntities(ctx, EntityQuery{Snapshots: ready.selected, Selector: graphprotocol.EntitySelector{Name: &name}, Limit: service.limits().MaxNodes}) + slices.SortStableFunc(found, func(a, b graphprotocol.Entity) int { + return cmp.Or(cmp.Compare(a.Fact.GetPath(), b.Fact.GetPath()), cmp.Compare(a.Fact.GetLocation().GetStart().GetLine(), b.Fact.GetLocation().GetStart().GetLine())) + }) + return found, err + } + declarations, err := byName("options." + symbol) + if err != nil { + return nil, err + } + writes, err := byName(symbol) + if err != nil { + return nil, err + } + prefixed, err := ready.store.QueryEntities(ctx, EntityQuery{Snapshots: ready.selected, Selector: graphprotocol.EntitySelector{NameMatch: &graphprotocol.NameSelector{Mode: "prefix", Value: symbol + "."}}, Limit: service.limits().MaxNodes}) + if err != nil { + return nil, err + } + slices.SortStableFunc(prefixed, func(a, b graphprotocol.Entity) int { return cmp.Compare(a.Fact.GetName(), b.Fact.GetName()) }) + prefixed = prefixed[:min(len(prefixed), 12)] + var hits []graphprotocol.Entity + seen := map[nodeKey]bool{} + for _, entity := range slices.Concat(declarations, writes, prefixed) { + key := nodeKey{entity.RepositoryID, entity.Fact.GetOccurrence()} + if entity.Fact.GetLanguage() == "nix" && !seen[key] && len(hits) < 10 { + seen[key] = true + hits = append(hits, entity) + } + } + if err = ready.publicEntities(hits); err != nil { + return nil, err + } + return hits, nil +} + +// SymbolCalls lists the callers (incoming) or callees (outgoing) of every +// definition of a name, one section per distinct definition. Each neighbor +// appears once, with the first edge that reached it; limit bounds each +// section (default 20, 1-100) and Truncated reports that more exist. +func (service *Service) SymbolCalls(ctx context.Context, request graphprotocol.SymbolCallsRequest) (graphprotocol.SymbolResponse, error) { + if service == nil || (request.Direction != "incoming" && request.Direction != "outgoing") { + return graphprotocol.SymbolResponse{}, ErrInvalidRequest + } + ctx, cancel := service.entityContext(ctx) + defer cancel() + limit := request.Limit + if limit == 0 { + limit = 20 + } + limit = min(max(limit, 1), 100) + scope, err := service.symbolDefinitions(ctx, request.Scope, request.Symbol, request.File) + if err != nil { + return graphprotocol.SymbolResponse{}, err + } + result := graphprotocol.SymbolResponse{Status: graphprotocol.StatusNotFound, FileFilter: scope.filter, Definitions: []graphprotocol.SymbolDefinition{}, Generations: scope.ready.publicGenerations()} + if len(scope.groups) > 0 { + result.Status = graphprotocol.StatusOK + } + if scope.truncated { + result.Partial = true + result.Boundaries = appendBoundary(result.Boundaries, "candidate_limit", 0) + } + snapshots := map[int64]QuerySnapshot{} + for _, snapshot := range scope.ready.snapshots { + snapshots[snapshot.RepositoryID] = snapshot + } + usedBytes := 0 + for _, group := range scope.groups { + definition := graphprotocol.SymbolDefinition{Definitions: group} + seen := map[nodeKey]bool{} + collect: + for _, root := range group { + repository := scope.internal[root.RepositoryID] + for _, relation := range symbolCallRelations { + // ponytail: 400 edges per relation and definition; page when a hub needs more. + rows, err := scope.ready.store.EntityNeighbors(ctx, EntityNeighborQuery{Snapshot: snapshots[repository], Occurrence: root.Fact.GetOccurrence(), Relation: relation, Direction: request.Direction, Limit: 401, ProducerOrder: true}) + if err != nil { + return graphprotocol.SymbolResponse{}, err + } + if len(rows) > 400 { + rows = rows[:400] + result.Partial = true + result.Boundaries = appendBoundary(result.Boundaries, "fanout_limit", 1) + } + for _, row := range rows { + if row.Entity.RepositoryID != repository || row.Edge.RepositoryID != repository { + return graphprotocol.SymbolResponse{}, ErrGenerationChanged + } + key := nodeKey{repository, row.Entity.Fact.GetOccurrence()} + if row.Entity.Fact.GetOccurrence() == root.Fact.GetOccurrence() || seen[key] { + continue + } + seen[key] = true + if len(definition.Related) == limit { + definition.Truncated = true + break collect + } + row.Entity.RepositoryID, row.Edge.RepositoryID = root.RepositoryID, root.RepositoryID + related := graphprotocol.SymbolRelated{Entity: row.Entity, Edge: row.Edge} + if err = AddEntityQueryBytes(&usedBytes, related); err != nil { + return graphprotocol.SymbolResponse{}, err + } + definition.Related = append(definition.Related, related) + } + } + } + result.Definitions = append(result.Definitions, definition) + } + if err = scope.ready.current(ctx); err != nil { + return graphprotocol.SymbolResponse{}, err + } + return result, nil +} + +// SymbolImpact merges each distinct definition's impact radius (default depth +// 2, 1-10): entities in first-reached order, edges once per source, target and +// kind. +func (service *Service) SymbolImpact(ctx context.Context, request graphprotocol.SymbolImpactRequest) (graphprotocol.SymbolResponse, error) { + if service == nil { + return graphprotocol.SymbolResponse{}, ErrInvalidRequest + } + ctx, cancel := service.entityContext(ctx) + defer cancel() + depth := request.Depth + if depth == 0 { + depth = 2 + } + depth = min(max(depth, 1), 10) + scope, err := service.symbolDefinitions(ctx, request.Scope, request.Symbol, request.File) + if err != nil { + return graphprotocol.SymbolResponse{}, err + } + result := graphprotocol.SymbolResponse{Status: graphprotocol.StatusNotFound, FileFilter: scope.filter, Definitions: []graphprotocol.SymbolDefinition{}, Generations: scope.ready.publicGenerations()} + if len(scope.groups) > 0 { + result.Status = graphprotocol.StatusOK + } + if scope.truncated { + result.Partial = true + result.Boundaries = appendBoundary(result.Boundaries, "candidate_limit", 0) + } + for _, group := range scope.groups { + definition := graphprotocol.SymbolDefinition{Definitions: group} + entities := map[nodeKey]bool{} + type edgeKey struct { + source, target string + kind graphv2.EdgeKind + } + edges := map[edgeKey]bool{} + for _, root := range group { + radius, err := service.ImpactRadius(ctx, graphprotocol.EntityImpactRequest{Scope: request.Scope, Occurrence: root.Fact.GetOccurrence(), MaxDepth: &depth}) + if err != nil { + return graphprotocol.SymbolResponse{}, err + } + if !sameGenerations(radius.Generations, result.Generations) { + return graphprotocol.SymbolResponse{}, ErrGenerationChanged + } + if radius.Partial { + result.Partial = true + result.Boundaries = append(result.Boundaries, radius.Boundaries...) + } + for _, entity := range radius.Entities { + if key := (nodeKey{entity.RepositoryID, entity.Fact.GetOccurrence()}); !entities[key] { + entities[key] = true + definition.Entities = append(definition.Entities, entity) + } + } + for _, edge := range radius.Edges { + if key := (edgeKey{edge.Fact.GetSource(), edge.Fact.GetTarget(), edge.Fact.GetKind()}); !edges[key] { + edges[key] = true + definition.Edges = append(definition.Edges, edge) + } + } + } + result.Definitions = append(result.Definitions, definition) + } + if err = entityResponseSize(result); err != nil { + return graphprotocol.SymbolResponse{}, err + } + if err = scope.ready.current(ctx); err != nil { + return graphprotocol.SymbolResponse{}, err + } + return result, nil +} diff --git a/internal/graphquery/symbols_test.go b/internal/graphquery/symbols_test.go new file mode 100644 index 00000000..e61a48e7 --- /dev/null +++ b/internal/graphquery/symbols_test.go @@ -0,0 +1,101 @@ +package graphquery + +import ( + "errors" + "slices" + "testing" + + graphv2 "github.com/balcsida/graphnest/internal/graphartifact/v2" + "github.com/balcsida/graphnest/internal/graphprotocol" + "google.golang.org/protobuf/proto" +) + +// Synthetic cases transcribed from CodeGraph b9ca4b7's matchesSymbol and +// lastQualifierPart documentation (src/graph/named-symbol-flow.ts). +func TestMatchesSymbol(t *testing.T) { + node := func(kind, name, qualified, path string) *graphv2.Node { + return &graphv2.Node{Kind: kind, Name: name, QualifiedName: qualified, Path: proto.String(path)} + } + for _, test := range []struct { + name string + node *graphv2.Node + symbol string + want bool + }{ + {"simple name", node("function", "run", "run", "main.ts"), "run", true}, + {"other name", node("function", "run", "run", "main.ts"), "walk", false}, + {"file basename", node("file", "product-card.liquid", "product-card.liquid", "sections/product-card.liquid"), "product-card", true}, + {"basename only for files", node("function", "product-card.liquid", "x", "a.ts"), "product-card", false}, + {"dotted qualified", node("method", "request", "Session::request", "session.py"), "Session.request", true}, + {"colon qualified", node("method", "request", "Session::request", "session.py"), "Session::request", true}, + {"qualified wrong tail", node("method", "send", "Session::send", "session.py"), "Session.request", false}, + {"file path module", node("function", "run", "run", "src/configurator/stage_apply.rs"), "stage_apply::run", true}, + {"rust crate prefix", node("function", "run", "run", "src/configurator/stage_apply.rs"), "crate::configurator::stage_apply::run", true}, + {"only rust prefixes", node("function", "run", "other", "src/x.rs"), "crate::run", false}, + {"missing container", node("function", "run", "run", "src/other.rs"), "stage_apply::run", false}, + {"slash qualified", node("function", "stage_apply", "stage_apply", "configurator/stage_apply.rs"), "configurator/stage_apply", true}, + {"erlang arity", node("function", "fn", "mod::fn/3", "mod.erl"), "mod.fn/3", true}, + {"erlang wrong arity", node("function", "fn", "mod::fn/3", "mod.erl"), "mod.fn/2", false}, + {"arity-less node keeps slash", node("function", "fn", "mod::fn", "mod/fn.erl"), "mod/fn", true}, + } { + t.Run(test.name, func(t *testing.T) { + if got := matchesSymbol(test.node, test.symbol); got != test.want { + t.Fatalf("matchesSymbol(%q)=%v", test.symbol, got) + } + }) + } + for symbol, want := range map[string]string{"run": "run", "Session.request": "request", "a::b::c": "c", "mod::fn/3": "fn", "a/b/": "b", "/3": "3"} { + if got := lastQualifierPart(symbol); got != want { + t.Fatalf("lastQualifierPart(%q)=%q want %q", symbol, got, want) + } + } +} + +func TestGroupDefinitions(t *testing.T) { + entity := func(id, path, qualified string) graphprotocol.Entity { + return graphprotocol.Entity{ID: id, Fact: &graphv2.Node{Occurrence: id, QualifiedName: qualified, Path: proto.String(path)}} + } + matches := []graphprotocol.Entity{entity("a", "apps/one/user.service.ts", "UserService"), entity("b", "apps/two/user.service.ts", "UserService"), entity("c", "apps/one/user.service.ts", "UserService")} + ids := func(groups [][]graphprotocol.Entity) [][]string { + var out [][]string + for _, g := range groups { + var row []string + for _, e := range g { + row = append(row, e.ID) + } + out = append(out, row) + } + return out + } + for _, test := range []struct { + file string + want [][]string + filtered string + }{ + {"", [][]string{{"a", "c"}, {"b"}}, ""}, + {"apps/two/user.service.ts", [][]string{{"b"}}, "matched"}, + {"./two/user.service.ts", [][]string{{"b"}}, "matched"}, + {"missing.ts", [][]string{{"a", "c"}, {"b"}}, "unmatched"}, + } { + groups, filtered := groupDefinitions(matches, test.file) + if got := ids(groups); !slices.EqualFunc(got, test.want, slices.Equal) || filtered != test.filtered { + t.Fatalf("file %q: groups=%v filter=%q", test.file, got, filtered) + } + } +} + +func TestSymbolRequestsValidateBeforeQuerying(t *testing.T) { + service := &Service{} + for name, request := range map[string]graphprotocol.SymbolCallsRequest{ + "direction": {Symbol: "run", Direction: "both"}, + "empty symbol": {Direction: "incoming"}, + "invalid utf8": {Symbol: "\xff", Direction: "outgoing"}, + } { + if _, err := service.SymbolCalls(t.Context(), request); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("%s: %v", name, err) + } + } + if _, err := service.SymbolImpact(t.Context(), graphprotocol.SymbolImpactRequest{}); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("impact empty symbol: %v", err) + } +} diff --git a/internal/graphservice/discovery_api.go b/internal/graphservice/discovery_api.go index 10d60f0c..cd1d9d1e 100644 --- a/internal/graphservice/discovery_api.go +++ b/internal/graphservice/discovery_api.go @@ -116,7 +116,7 @@ func (s *Service) Capabilities(ctx context.Context, p authn.Principal, r api.Gra generation := files.Generations[0] result := CapabilitiesResponse{ Version: 1, QueryArtifactVersions: []int{1, 2}, UploadArtifactVersions: []int{1, 2}, - Workflows: []CapabilityWorkflow{{Name: "context", ArtifactVersion: 1}, {Name: "impact", ArtifactVersion: 1}, {Name: "trace", ArtifactVersion: 1}, {Name: "discover", ArtifactVersion: 2}, {Name: "explore", ArtifactVersion: 2}, {Name: "files", ArtifactVersion: 2}, {Name: "capabilities", ArtifactVersion: 2}}, + Workflows: []CapabilityWorkflow{{Name: "context", ArtifactVersion: 1}, {Name: "impact", ArtifactVersion: 1}, {Name: "trace", ArtifactVersion: 1}, {Name: "discover", ArtifactVersion: 2}, {Name: "explore", ArtifactVersion: 2}, {Name: "files", ArtifactVersion: 2}, {Name: "capabilities", ArtifactVersion: 2}, {Name: "callers", ArtifactVersion: 2}, {Name: "callees", ArtifactVersion: 2}, {Name: "impact_radius", ArtifactVersion: 2}}, Status: "ready", Freshness: "current", RepositoryID: i.selected.GitHubID, Repository: i.selected.Name, Branch: i.selected.Branch, CurrentIndexedCommit: i.selected.Commit, Generation: &generation, ProducerCapabilities: slices.Clone(generation.Capabilities), DiscoveryProjection: true, DiscoveryCoverage: probe.Coverage, diff --git a/internal/graphservice/discovery_api_test.go b/internal/graphservice/discovery_api_test.go index 7a965fb9..0e9cdcee 100644 --- a/internal/graphservice/discovery_api_test.go +++ b/internal/graphservice/discovery_api_test.go @@ -53,6 +53,15 @@ func TestCapabilitiesReportsCurrentV2GenerationAndUploadVersions(t *testing.T) { if len(got.QueryArtifactVersions) != 2 || got.QueryArtifactVersions[0] != 1 || got.QueryArtifactVersions[1] != 2 || !slices.Equal(got.UploadArtifactVersions, []int{1, 2}) { t.Fatalf("artifact versions=%+v/%+v", got.QueryArtifactVersions, got.UploadArtifactVersions) } + var workflows []string + for _, workflow := range got.Workflows { + workflows = append(workflows, workflow.Name) + } + for _, name := range []string{"callers", "callees", "impact_radius"} { + if !slices.Contains(workflows, name) { + t.Fatalf("workflows=%v missing %s", workflows, name) + } + } if !got.DiscoveryProjection || len(got.ProducerCapabilities) != 2 { t.Fatalf("availability=%+v", got) } diff --git a/internal/graphservice/openapi_wire_test.go b/internal/graphservice/openapi_wire_test.go index d64268e4..476325ba 100644 --- a/internal/graphservice/openapi_wire_test.go +++ b/internal/graphservice/openapi_wire_test.go @@ -50,6 +50,18 @@ func TestOpenAPIExplorationWireResponses(t *testing.T) { } } + symbols, stub := symbolFixture() + stub.generation.Producer = &graphv2.Producer{Name: "fixture", Version: "1"} + stub.generation.ContentHash = []byte("hash") + stub.generation.Capabilities = []string{} + stub.result.Generations = []graphprotocol.Generation{stub.generation} + stub.result.FileFilter = "matched" + callers, err := symbols.SymbolCallers(t.Context(), principalFor(101), api.GraphSymbolCallsRequest{Symbol: "hello"}) + if err != nil { + t.Fatal(err) + } + validate("symbol callers", "GraphSymbolResponse", callers) + service, backend, _ := exploreFixture() backend.generation.Producer = &graphv2.Producer{Name: "fixture", Version: "1"} backend.generation.ContentHash = []byte("hash") diff --git a/internal/graphservice/symbols.go b/internal/graphservice/symbols.go new file mode 100644 index 00000000..53f7744b --- /dev/null +++ b/internal/graphservice/symbols.go @@ -0,0 +1,80 @@ +package graphservice + +import ( + "context" + "slices" + "time" + + "github.com/balcsida/graphnest/internal/authn" + "github.com/balcsida/graphnest/internal/graphprotocol" + "github.com/balcsida/graphnest/pkg/api" +) + +type symbolBackend interface { + SymbolCalls(context.Context, graphprotocol.SymbolCallsRequest) (graphprotocol.SymbolResponse, error) + SymbolImpact(context.Context, graphprotocol.SymbolImpactRequest) (graphprotocol.SymbolResponse, error) +} + +// SymbolCallers lists what calls, imports, instantiates, navigates to, or +// references each definition of a name. +func (s *Service) SymbolCallers(ctx context.Context, p authn.Principal, r api.GraphSymbolCallsRequest) (graphprotocol.SymbolResponse, error) { + return s.symbolQuery(ctx, p, r.Repo, r.Branch, func(backend symbolBackend, scope graphprotocol.Scope) (graphprotocol.SymbolResponse, error) { + return backend.SymbolCalls(ctx, graphprotocol.SymbolCallsRequest{Scope: scope, Symbol: r.Symbol, File: r.File, Direction: "incoming", Limit: r.Limit}) + }) +} + +// SymbolCallees is the outgoing counterpart of SymbolCallers. +func (s *Service) SymbolCallees(ctx context.Context, p authn.Principal, r api.GraphSymbolCallsRequest) (graphprotocol.SymbolResponse, error) { + return s.symbolQuery(ctx, p, r.Repo, r.Branch, func(backend symbolBackend, scope graphprotocol.Scope) (graphprotocol.SymbolResponse, error) { + return backend.SymbolCalls(ctx, graphprotocol.SymbolCallsRequest{Scope: scope, Symbol: r.Symbol, File: r.File, Direction: "outgoing", Limit: r.Limit}) + }) +} + +// SymbolImpact is the impact radius of each definition of a name. +func (s *Service) SymbolImpact(ctx context.Context, p authn.Principal, r api.GraphSymbolImpactRequest) (graphprotocol.SymbolResponse, error) { + return s.symbolQuery(ctx, p, r.Repo, r.Branch, func(backend symbolBackend, scope graphprotocol.Scope) (graphprotocol.SymbolResponse, error) { + return backend.SymbolImpact(ctx, graphprotocol.SymbolImpactRequest{Scope: scope, Symbol: r.Symbol, File: r.File, Depth: r.Depth}) + }) +} + +func (s *Service) symbolQuery(ctx context.Context, p authn.Principal, repo api.GraphRepositorySelector, branch string, query func(symbolBackend, graphprotocol.Scope) (graphprotocol.SymbolResponse, error)) (graphprotocol.SymbolResponse, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + i, err := s.inspectionScope(ctx, p, repo, branch) + if err != nil { + return graphprotocol.SymbolResponse{}, err + } + backend, ok := s.Backend.(symbolBackend) + if !ok { + return graphprotocol.SymbolResponse{}, ErrGraphNotReady + } + result, err := query(backend, i.scope) + if err != nil { + return graphprotocol.SymbolResponse{}, err + } + if err := i.generations(result.Generations); err != nil { + return graphprotocol.SymbolResponse{}, err + } + for _, definition := range result.Definitions { + related := make([]graphprotocol.Entity, 0, len(definition.Related)) + edges := slices.Clone(definition.Edges) + for _, r := range definition.Related { + related = append(related, r.Entity) + edges = append(edges, r.Edge) + } + for _, edge := range edges { + if edge.RepositoryID != i.selected.GitHubID || edge.Fact == nil { + return graphprotocol.SymbolResponse{}, ErrGraphNotReady + } + } + for _, entities := range [][]graphprotocol.Entity{definition.Definitions, related, definition.Entities} { + if err := i.entities(entities); err != nil { + return graphprotocol.SymbolResponse{}, err + } + } + } + if err := s.finishInspection(ctx, p, i, result.Generations, result); err != nil { + return graphprotocol.SymbolResponse{}, err + } + return result, nil +} diff --git a/internal/graphservice/symbols_test.go b/internal/graphservice/symbols_test.go new file mode 100644 index 00000000..3fa99b5d --- /dev/null +++ b/internal/graphservice/symbols_test.go @@ -0,0 +1,88 @@ +package graphservice + +import ( + "context" + "errors" + "testing" + + "github.com/balcsida/graphnest/internal/authn" + graphv2 "github.com/balcsida/graphnest/internal/graphartifact/v2" + "github.com/balcsida/graphnest/internal/graphprotocol" + "github.com/balcsida/graphnest/pkg/api" +) + +type symbolBackendStub struct { + *inspectionBackend + calls []graphprotocol.SymbolCallsRequest + impact []graphprotocol.SymbolImpactRequest + result graphprotocol.SymbolResponse +} + +func (b *symbolBackendStub) SymbolCalls(_ context.Context, r graphprotocol.SymbolCallsRequest) (graphprotocol.SymbolResponse, error) { + b.calls = append(b.calls, r) + return b.result, nil +} + +func (b *symbolBackendStub) SymbolImpact(_ context.Context, r graphprotocol.SymbolImpactRequest) (graphprotocol.SymbolResponse, error) { + b.impact = append(b.impact, r) + return b.result, nil +} + +func symbolFixture() (*Service, *symbolBackendStub) { + s, b, _ := inspectionFixture() + stub := &symbolBackendStub{inspectionBackend: b, result: graphprotocol.SymbolResponse{ + Status: graphprotocol.StatusOK, Generations: []graphprotocol.Generation{b.generation}, + Definitions: []graphprotocol.SymbolDefinition{{Definitions: []graphprotocol.Entity{b.entity}, Related: []graphprotocol.SymbolRelated{{Entity: b.entity, Edge: graphprotocol.Evidence{ + RepositoryID: 101, SourceID: "caller", TargetID: "identity", Fact: &graphv2.Edge{Occurrence: "call:1", Source: "decl:2", Target: "decl:1", Kind: graphv2.EdgeKind_EDGE_KIND_CALLS}, + }}}}}, + }} + s.Backend = stub + return s, stub +} + +func TestSymbolQueriesUseSelectedScopeAndDirection(t *testing.T) { + s, stub := symbolFixture() + request := api.GraphSymbolCallsRequest{Repo: api.GraphRepositorySelector{ID: 101}, Symbol: "run", File: "main.ts", Limit: 3} + if _, err := s.SymbolCallers(t.Context(), principalFor(101), request); err != nil { + t.Fatal(err) + } + if _, err := s.SymbolCallees(t.Context(), principalFor(101), request); err != nil { + t.Fatal(err) + } + if _, err := s.SymbolImpact(t.Context(), principalFor(101), api.GraphSymbolImpactRequest{Repo: api.GraphRepositorySelector{ID: 101}, Symbol: "run", Depth: 4}); err != nil { + t.Fatal(err) + } + if len(stub.calls) != 2 || stub.calls[0].Direction != "incoming" || stub.calls[1].Direction != "outgoing" || stub.calls[0].Symbol != "run" || stub.calls[0].File != "main.ts" || stub.calls[0].Limit != 3 { + t.Fatalf("calls=%+v", stub.calls) + } + if scope := stub.calls[0].Scope; len(scope.Repositories) != 1 || scope.Repositories[0].GitHubID != 101 || scope.SelectedRepositoryID != 1 { + t.Fatalf("scope=%+v", scope) + } + if len(stub.impact) != 1 || stub.impact[0].Depth != 4 { + t.Fatalf("impact=%+v", stub.impact) + } +} + +func TestSymbolQueriesRejectForeignFactsAndStaleCredentials(t *testing.T) { + s, stub := symbolFixture() + request := api.GraphSymbolCallsRequest{Repo: api.GraphRepositorySelector{ID: 101}, Symbol: "run"} + stub.result.Definitions[0].Related[0].Entity.RepositoryID = 202 + if _, err := s.SymbolCallers(t.Context(), principalFor(101), request); !errors.Is(err, ErrGraphNotReady) { + t.Fatalf("foreign related entity: %v", err) + } + stub.result.Definitions[0].Related[0].Entity.RepositoryID = 101 + stub.result.Definitions[0].Related[0].Edge.RepositoryID = 202 + if _, err := s.SymbolCallers(t.Context(), principalFor(101), request); !errors.Is(err, ErrGraphNotReady) { + t.Fatalf("foreign edge: %v", err) + } + stub.result.Definitions[0].Related[0].Edge.RepositoryID = 101 + ctx := authn.WithFreshPrincipal(t.Context(), func(context.Context) (authn.Principal, error) { return authn.Principal{}, authn.ErrUnauthenticated }) + if _, err := s.SymbolCallers(ctx, principalFor(101), request); !errors.Is(err, authn.ErrUnauthenticated) { + t.Fatalf("revoked credential: %v", err) + } + calls := len(stub.calls) + request.Repo = api.GraphRepositorySelector{ID: 202} + if _, err := s.SymbolCallers(t.Context(), principalFor(101), request); !errors.Is(err, ErrRepositoryNotFound) || len(stub.calls) != calls { + t.Fatalf("invisible repository: %v backend calls=%d", err, len(stub.calls)-calls) + } +} diff --git a/internal/httpapi/graph_query.go b/internal/httpapi/graph_query.go index 31f0b299..e6faa6a2 100644 --- a/internal/httpapi/graph_query.go +++ b/internal/httpapi/graph_query.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/balcsida/graphnest/internal/authn" + "github.com/balcsida/graphnest/internal/graphprotocol" "github.com/balcsida/graphnest/internal/graphquery" "github.com/balcsida/graphnest/internal/graphservice" "github.com/balcsida/graphnest/pkg/api" @@ -60,6 +61,27 @@ func RegisterGraphQueries(mux *http.ServeMux, authenticator authn.Authenticator, } writeBoundedJSON(writer, response, maxResponseBytes) })))) + for path, query := range map[string]func(context.Context, authn.Principal, api.GraphSymbolCallsRequest) (graphprotocol.SymbolResponse, error){ + "/v1/graph/callers": service.SymbolCallers, + "/v1/graph/callees": service.SymbolCallees, + } { + mux.Handle(path, exactMethod(http.MethodPost, AuthenticateBearer(authenticator, jsonSCIPHandler(maxRequestBytes, func(writer http.ResponseWriter, request *http.Request, input api.GraphSymbolCallsRequest) { + response, err := query(request.Context(), PrincipalFromContext(request.Context()), input) + if err != nil { + writeGraphQueryError(writer, err) + return + } + writeBoundedJSON(writer, response, maxResponseBytes) + })))) + } + mux.Handle("/v1/graph/impact-radius", exactMethod(http.MethodPost, AuthenticateBearer(authenticator, jsonSCIPHandler(maxRequestBytes, func(writer http.ResponseWriter, request *http.Request, input api.GraphSymbolImpactRequest) { + response, err := service.SymbolImpact(request.Context(), PrincipalFromContext(request.Context()), input) + if err != nil { + writeGraphQueryError(writer, err) + return + } + writeBoundedJSON(writer, response, maxResponseBytes) + })))) mux.Handle("/v1/graph/capabilities", exactMethod(http.MethodPost, AuthenticateBearer(authenticator, jsonSCIPHandler(maxRequestBytes, func(writer http.ResponseWriter, request *http.Request, input api.GraphCapabilitiesRequest) { response, err := service.Capabilities(request.Context(), PrincipalFromContext(request.Context()), input) if err != nil { diff --git a/internal/httpapi/graph_query_test.go b/internal/httpapi/graph_query_test.go index 55d761ad..6934e7b7 100644 --- a/internal/httpapi/graph_query_test.go +++ b/internal/httpapi/graph_query_test.go @@ -33,6 +33,9 @@ func TestGraphQueryContracts(t *testing.T) { {"/v1/graph/explore", api.GraphExploreRequest{Query: "symbol"}}, {"/v1/graph/files", api.GraphFilesRequest{}}, {"/v1/graph/capabilities", api.GraphCapabilitiesRequest{}}, + {"/v1/graph/callers", api.GraphSymbolCallsRequest{Symbol: "a"}}, + {"/v1/graph/callees", api.GraphSymbolCallsRequest{Symbol: "a"}}, + {"/v1/graph/impact-radius", api.GraphSymbolImpactRequest{Symbol: "a"}}, } for _, route := range routes { t.Run(route.path, func(t *testing.T) { @@ -324,6 +327,15 @@ func (graphQueryEngine) IndexedFiles(_ context.Context, request graphprotocol.Fi total := int64(1) return graphprotocol.FilesResponse{Files: []graphprotocol.IndexedFile{{RepositoryID: 101, Fact: &graphv2.File{Path: "a.go"}}}, Generations: []graphprotocol.Generation{graphQueryGeneration(request.Scope)}, TotalFiles: &total}, nil } +func (graphQueryEngine) SymbolCalls(_ context.Context, request graphprotocol.SymbolCallsRequest) (graphprotocol.SymbolResponse, error) { + return graphQuerySymbols(request.Scope), nil +} +func (graphQueryEngine) SymbolImpact(_ context.Context, request graphprotocol.SymbolImpactRequest) (graphprotocol.SymbolResponse, error) { + return graphQuerySymbols(request.Scope), nil +} +func graphQuerySymbols(scope graphprotocol.Scope) graphprotocol.SymbolResponse { + return graphprotocol.SymbolResponse{Status: graphprotocol.StatusOK, Definitions: []graphprotocol.SymbolDefinition{{Definitions: []graphprotocol.Entity{graphQueryEntity()}}}, Generations: []graphprotocol.Generation{graphQueryGeneration(scope)}} +} func (graphQueryEngine) ValidateGenerations(context.Context, graphprotocol.Scope, []graphprotocol.Generation) error { return nil } diff --git a/internal/mcpserver/graph.go b/internal/mcpserver/graph.go index 735351a7..976d6576 100644 --- a/internal/mcpserver/graph.go +++ b/internal/mcpserver/graph.go @@ -40,6 +40,18 @@ func registerGraphTools(server *mcp.Server, service *graphservice.Service, maxOu response, err := service.ListFilesPublic(ctx, httpapi.PrincipalFromContext(ctx), input) return graphResult(response, err, maxOutputBytes) }) + mcp.AddTool(server, &mcp.Tool{Name: "graph_callers", Description: "List what calls, imports, instantiates, navigates to, or references each definition of a symbol name.", InputSchema: graphSymbolSchema("limit")}, func(ctx context.Context, _ *mcp.CallToolRequest, input api.GraphSymbolCallsRequest) (*mcp.CallToolResult, any, error) { + response, err := service.SymbolCallers(ctx, httpapi.PrincipalFromContext(ctx), input) + return graphResult(response, err, maxOutputBytes) + }) + mcp.AddTool(server, &mcp.Tool{Name: "graph_callees", Description: "List what each definition of a symbol name calls, imports, instantiates, navigates to, or references.", InputSchema: graphSymbolSchema("limit")}, func(ctx context.Context, _ *mcp.CallToolRequest, input api.GraphSymbolCallsRequest) (*mcp.CallToolResult, any, error) { + response, err := service.SymbolCallees(ctx, httpapi.PrincipalFromContext(ctx), input) + return graphResult(response, err, maxOutputBytes) + }) + mcp.AddTool(server, &mcp.Tool{Name: "graph_impact_radius", Description: "List the symbols affected by changing each definition of a symbol name.", InputSchema: graphSymbolSchema("depth")}, func(ctx context.Context, _ *mcp.CallToolRequest, input api.GraphSymbolImpactRequest) (*mcp.CallToolResult, any, error) { + response, err := service.SymbolImpact(ctx, httpapi.PrincipalFromContext(ctx), input) + return graphResult(response, err, maxOutputBytes) + }) mcp.AddTool(server, &mcp.Tool{Name: "graph_capabilities", Description: "Report graph query, upload, and selected generation capabilities.", InputSchema: graphCapabilitiesSchema()}, func(ctx context.Context, _ *mcp.CallToolRequest, input api.GraphCapabilitiesRequest) (*mcp.CallToolResult, any, error) { response, err := service.Capabilities(ctx, httpapi.PrincipalFromContext(ctx), input) return graphResult(response, err, maxOutputBytes) @@ -153,6 +165,21 @@ func graphFilesSchema() map[string]any { return map[string]any{"type": "object", "additionalProperties": false, "properties": properties} } +// graphSymbolSchema addresses definitions by name like CodeGraph's callers, +// callees and impact tools; bound is "limit" (default 20, 1-100) or "depth" +// (default 2, 1-10). Values outside the range are clamped. +func graphSymbolSchema(bound string) map[string]any { + properties := graphBaseProperties() + properties["symbol"] = map[string]any{"type": "string", "minLength": 1, "maxLength": 16384, "description": "name, or qualified name such as Class.method or module::fn"} + properties["file"] = map[string]any{"type": "string", "maxLength": 16384, "description": "narrow same-named definitions to this path or path suffix"} + if bound == "limit" { + properties["limit"] = map[string]any{"type": "integer", "default": 20, "description": "per definition; default: 20; clamped to 1-100"} + } else { + properties["depth"] = map[string]any{"type": "integer", "default": 2, "description": "default: 2; clamped to 1-10"} + } + return map[string]any{"type": "object", "additionalProperties": false, "required": []string{"symbol"}, "properties": properties} +} + func graphCapabilitiesSchema() map[string]any { return map[string]any{"type": "object", "additionalProperties": false, "properties": graphBaseProperties()} } diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index c24d4af7..c9615db3 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -61,13 +61,16 @@ func TestGraphMCPMatchesService(t *testing.T) { t.Fatal(err) } for name, description := range map[string]string{ - "context": "Inspect a symbol's incoming and outgoing code relationships.", - "impact": "Analyze the upstream or downstream impact of a code symbol.", - "trace": "Trace code relationships between two symbols.", - "graph_discover": "Find bounded entry points in an indexed graph.", - "explore": "Explore bounded graph facts and exact indexed source.", - "graph_files": "List bounded files from an indexed graph generation.", - "graph_capabilities": "Report graph query, upload, and selected generation capabilities.", + "context": "Inspect a symbol's incoming and outgoing code relationships.", + "impact": "Analyze the upstream or downstream impact of a code symbol.", + "trace": "Trace code relationships between two symbols.", + "graph_discover": "Find bounded entry points in an indexed graph.", + "explore": "Explore bounded graph facts and exact indexed source.", + "graph_files": "List bounded files from an indexed graph generation.", + "graph_capabilities": "Report graph query, upload, and selected generation capabilities.", + "graph_callers": "List what calls, imports, instantiates, navigates to, or references each definition of a symbol name.", + "graph_callees": "List what each definition of a symbol name calls, imports, instantiates, navigates to, or references.", + "graph_impact_radius": "List the symbols affected by changing each definition of a symbol name.", } { schema := repositoryToolSchema(t, tools.Tools, name) if schema["additionalProperties"] != false { diff --git a/internal/postgres/graph_entity.go b/internal/postgres/graph_entity.go index 55293750..fd895cb7 100644 --- a/internal/postgres/graph_entity.go +++ b/internal/postgres/graph_entity.go @@ -203,7 +203,12 @@ func (s *Store) EntityNeighbors(ctx context.Context, q graphquery.EntityNeighbor where u.repository_id=$1 and u.id=$2 and u.commit=$3 and u.schema_version=2 and e.` + parent + `_key=sha256($4::bytea) and e.` + parent + `=$4::bytea and e.kind=$5 and (e.confidence is null or e.confidence >= $6) - order by n.occurrence,e.occurrence limit $7` + order by ` + if q.ProducerOrder { + sql += `e.ordinal limit $7` + } else { + sql += `n.occurrence,e.occurrence limit $7` + } rows, err := s.pool.Query(ctx, sql, q.Snapshot.RepositoryID, q.Snapshot.UploadID, q.Snapshot.Commit, []byte(q.Occurrence), int16(relation.Kind), q.MinConfidence, q.Limit, graphquery.MaxEntityQueryBytes) if err != nil { return nil, err diff --git a/internal/postgres/graph_symbols_test.go b/internal/postgres/graph_symbols_test.go new file mode 100644 index 00000000..8a46d772 --- /dev/null +++ b/internal/postgres/graph_symbols_test.go @@ -0,0 +1,279 @@ +//go:build integration + +package postgres + +import ( + "encoding/json" + "fmt" + "os" + "slices" + "strings" + "testing" + + "github.com/balcsida/graphnest/internal/graphartifact" + graphv2 "github.com/balcsida/graphnest/internal/graphartifact/v2" + "github.com/balcsida/graphnest/internal/graphprotocol" + "github.com/balcsida/graphnest/internal/graphquery" +) + +// TestGraphSymbolToolsMatchCodeGraph compares name-addressed callers, callees +// and impact with the pinned CodeGraph MCP handlers' real answers. The +// renderer below reproduces only upstream's markdown layout, so equal text +// means equal definitions, grouping, neighbors, order and labels. +func TestGraphSymbolToolsMatchCodeGraph(t *testing.T) { + path := os.Getenv("GRAPHNEST_TEST_CODEGRAPH_V2_FIXTURE") + if path == "" { + t.Skip("set GRAPHNEST_TEST_CODEGRAPH_V2_FIXTURE to the exported real oracle artifact") + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + a, err := graphartifact.ParseV2(data, graphartifact.Limits{}) + if err != nil { + t.Fatal(err) + } + s, id := readyGraphStore(t, a.Commit) + if _, err := s.ReplaceGraphV2(t.Context(), id, GraphPublication{Publisher: "symbol-oracle"}, a); err != nil { + t.Fatal(err) + } + oracleData, err := os.ReadFile("../../test/fixtures/codegraph/library-expected.json") + if err != nil { + t.Fatal(err) + } + var oracle map[string]json.RawMessage + if err := json.Unmarshal(oracleData, &oracle); err != nil { + t.Fatal(err) + } + service := &graphquery.Service{Store: s} + scope := graphprotocol.Scope{SelectedRepositoryID: id, Repositories: []graphprotocol.RepositorySnapshot{{ID: id, GitHubID: 101, Commit: a.Commit}}} + for _, test := range []struct { + id, tool, symbol, file string + limit, depth int + // Impact walks shortest dependency depth (docs/graph-analysis.md), so + // it also reaches nodes the pinned depth-first walk omits. + corrected []string + }{ + {"mcp-callers-grouped", "callers", "normalize", "", 0, 0, nil}, + {"mcp-callers-file", "callers", "normalize", "core.ts", 0, 0, nil}, + {"mcp-callers-file-miss", "callers", "normalize", "missing.ts", 0, 0, nil}, + {"mcp-callers-limit", "callers", "normalize", "core.ts", 1, 0, nil}, + {"mcp-callers-qualified", "callers", "Service.greet", "", 0, 0, nil}, + {"mcp-callers-missing", "callers", "MissingFixtureSymbol987", "", 0, 0, nil}, + {"mcp-callees-single", "callees", "run", "", 0, 0, nil}, + {"mcp-callees-grouped", "callees", "greet", "", 0, 0, nil}, + {"mcp-impact-file", "impact", "normalize", "core.ts", 0, 0, []string{`"normalize"|consumer.ts|consumer.ts:1`, `"normalize"|consumer.ts|processGreeting:2`}}, + {"mcp-impact-depth", "impact", "normalize", "core.ts", 0, 1, nil}, + {"mcp-impact-grouped", "impact", "identity", "", 0, 0, nil}, + } { + t.Run(test.id, func(t *testing.T) { + var got string + if test.tool == "impact" { + result, err := service.SymbolImpact(t.Context(), graphprotocol.SymbolImpactRequest{Scope: scope, Symbol: test.symbol, File: test.file, Depth: test.depth}) + if err != nil { + t.Fatal(err) + } + got = renderImpact(test.symbol, test.file, result) + } else { + direction := map[string]string{"callers": "incoming", "callees": "outgoing"}[test.tool] + result, err := service.SymbolCalls(t.Context(), graphprotocol.SymbolCallsRequest{Scope: scope, Symbol: test.symbol, File: test.file, Direction: direction, Limit: test.limit}) + if err != nil { + t.Fatal(err) + } + got = renderCalls(test.tool, test.symbol, test.file, result) + } + var answer struct { + Content []struct{ Text string } `json:"content"` + } + if err := json.Unmarshal(oracle[test.id], &answer); err != nil || len(answer.Content) == 0 { + t.Fatalf("oracle %s: %v", test.id, err) + } + var want []string + for _, item := range answer.Content { + want = append(want, item.Text) + } + expected := strings.Join(want, "\n") + if test.tool == "impact" { + // Level-by-level order differs from the depth-first order. + wantSet := append(impactEntries(expected), test.corrected...) + slices.Sort(wantSet) + if gotSet := impactEntries(got); !slices.Equal(gotSet, wantSet) { + t.Fatalf("GraphNest %v\nwant %v\n\nGraphNest:\n%s\n\nCodeGraph:\n%s", gotSet, wantSet, got, expected) + } + return + } + if canonicalSections(got) != canonicalSections(expected) { + t.Fatalf("GraphNest:\n%s\n\nCodeGraph:\n%s", got, expected) + } + }) + } +} + +func renderCalls(tool, symbol, file string, r graphprotocol.SymbolResponse) string { + if r.Status == graphprotocol.StatusNotFound { + return fmt.Sprintf("Symbol %q not found in the codebase", symbol) + } + title := map[string]string{"callers": "Callers", "callees": "Callees"}[tool] + filterNote := unmatchedFilterNote(symbol, file, r) + if len(r.Definitions) == 1 { + definition := r.Definitions[0] + note := aggregationNote(symbol, r) + if len(definition.Related) == 0 { + return fmt.Sprintf("No %s found for %q", tool, symbol) + note + filterNote + } + lines := []string{fmt.Sprintf("**%s of %s (%d found)**", title, symbol, len(definition.Related)), ""} + for _, related := range definition.Related { + lines = append(lines, relatedLine(related)) + } + return strings.Join(lines, "\n") + note + filterNote + } + lines := []string{fmt.Sprintf("**%s of %s — %d distinct definitions (narrow with `file`)**", title, symbol, len(r.Definitions))} + for _, definition := range r.Definitions { + head := definition.Definitions[0].Fact + lines = append(lines, "", fmt.Sprintf("**%s** (%s) — %s%s", head.GetQualifiedName(), head.GetKind(), head.GetPath(), lineSuffix(head))) + if len(definition.Related) == 0 { + lines = append(lines, fmt.Sprintf("- (no %s)", tool)) + } + for _, related := range definition.Related { + lines = append(lines, relatedLine(related)) + } + } + return strings.Join(lines, "\n") + filterNote +} + +func renderImpact(symbol, file string, r graphprotocol.SymbolResponse) string { + if r.Status == graphprotocol.StatusNotFound { + return fmt.Sprintf("Symbol %q not found in the codebase", symbol) + } + filterNote := unmatchedFilterNote(symbol, file, r) + if len(r.Definitions) == 1 { + return impactSection(symbol, r.Definitions[0]) + aggregationNote(symbol, r) + filterNote + } + sections := []string{fmt.Sprintf("**Impact of %s — %d distinct definitions (each with its own blast radius; narrow with `file`)**", symbol, len(r.Definitions))} + for _, definition := range r.Definitions { + head := definition.Definitions[0].Fact + sections = append(sections, "", impactSection(fmt.Sprintf("%s (%s%s)", head.GetQualifiedName(), head.GetPath(), lineSuffix(head)), definition)) + } + return strings.Join(sections, "\n") + filterNote +} + +func impactSection(label string, definition graphprotocol.SymbolDefinition) string { + lines := []string{fmt.Sprintf("**Impact: %q affects %d symbols**", label, len(definition.Entities)), ""} + var files []string + byFile := map[string][]string{} + for _, entity := range definition.Entities { + path := entity.Fact.GetPath() + if _, ok := byFile[path]; !ok { + files = append(files, path) + } + byFile[path] = append(byFile[path], fmt.Sprintf("%s:%d", entity.Fact.GetName(), startLine(entity.Fact))) + } + for _, path := range files { + lines = append(lines, fmt.Sprintf("**%s:**", path), strings.Join(byFile[path], ", "), "") + } + return strings.Join(lines, "\n") +} + +// aggregationNote mirrors findAllSymbols' note, suppressed when file narrowed. +func aggregationNote(symbol string, r graphprotocol.SymbolResponse) string { + matches := r.Definitions[0].Definitions + if len(matches) < 2 || r.FileFilter == "matched" { + return "" + } + var locations []string + for _, match := range matches { + locations = append(locations, fmt.Sprintf("%s at %s:%d", match.Fact.GetKind(), match.Fact.GetPath(), startLine(match.Fact))) + } + return fmt.Sprintf("\n\n> **Note:** Aggregated results across %d symbols named %q: %s", len(matches), symbol, strings.Join(locations, ", ")) +} + +func unmatchedFilterNote(symbol, file string, r graphprotocol.SymbolResponse) string { + if r.FileFilter != "unmatched" { + return "" + } + return fmt.Sprintf("\n\n> **Note:** no definition of %q matches file %q — showing all definitions instead.", symbol, file) +} + +func relatedLine(related graphprotocol.SymbolRelated) string { + node, edge := related.Entity.Fact, related.Edge.Fact + label := "" + switch kind := strings.ToLower(strings.TrimPrefix(edge.GetKind().String(), "EDGE_KIND_")); { + case kind == "calls": + case callbackRegistration(edge): + label = "callback registration" + case kind == "instantiates": + label = "instantiation" + case kind == "imports": + label = "import" + case kind == "references": + label = "reference" + default: + label = kind + } + if label != "" { + label = " — via " + label + } + return fmt.Sprintf("- %s (%s) - %s%s%s", node.GetName(), node.GetKind(), node.GetPath(), lineSuffix(node), label) +} + +func callbackRegistration(edge *graphv2.Edge) bool { + for _, extension := range edge.GetExtensions() { + var metadata struct { + FnRef bool `json:"fnRef"` + } + if extension.GetNamespace() == "codegraph.metadata" && json.Unmarshal(extension.GetJson(), &metadata) == nil && metadata.FnRef { + return true + } + } + return false +} + +// v2 positions are zero-based; CodeGraph prints one-based start lines. +func lineSuffix(node *graphv2.Node) string { + if start := node.GetLocation().GetStart(); start.Line != nil { + return fmt.Sprintf(":%d", start.GetLine()+1) + } + return "" +} + +func startLine(node *graphv2.Node) int32 { return node.GetLocation().GetStart().GetLine() + 1 } + +// canonicalSections sorts the per-definition sections of a multi-definition +// answer. Upstream orders same-named definitions by FTS5 BM25 score; GraphNest +// orders them by generated flag, path and line (documented difference). The +// title, each section's exact text and the trailing note still must match. +func canonicalSections(text string) string { + if !strings.Contains(text, "distinct definitions") { + return text + } + body, note, _ := strings.Cut(text, "\n\n> **Note:**") + parts := strings.Split(body, "\n\n") + if strings.HasPrefix(parts[0], "**Impact of") { + // Impact sections are separated by a blank line and end in their own blank line. + parts = strings.Split(body, "\n\n\n") + } + title, sections := parts[0], parts[1:] + slices.Sort(sections) + return title + "\n\n" + strings.Join(sections, "\n\n") + "\n\n> **Note:**" + note +} + +// impactEntries flattens impact answers to sorted "section|file|name:line" +// entries; sections are labelled by their heading. +func impactEntries(text string) []string { + var entries []string + section, file := "", "" + for _, line := range strings.Split(text, "\n") { + switch { + case strings.HasPrefix(line, "**Impact: "): + section, _, _ = strings.Cut(strings.TrimPrefix(line, "**Impact: "), " affects") + case strings.HasPrefix(line, "**") && strings.HasSuffix(line, ":**"): + file = strings.TrimSuffix(strings.TrimPrefix(line, "**"), ":**") + case line != "" && file != "" && !strings.HasPrefix(line, ">"): + for _, node := range strings.Split(line, ", ") { + entries = append(entries, section+"|"+file+"|"+node) + } + } + } + slices.Sort(entries) + return entries +} diff --git a/pkg/api/graph_symbols.go b/pkg/api/graph_symbols.go new file mode 100644 index 00000000..94793b06 --- /dev/null +++ b/pkg/api/graph_symbols.go @@ -0,0 +1,22 @@ +package api + +// GraphSymbolCallsRequest lists the callers or callees of every definition of +// a symbol name. File narrows same-named definitions by path or path suffix. +// Limit bounds each definition's list (default 20, clamped to 1-100). +type GraphSymbolCallsRequest struct { + Repo GraphRepositorySelector `json:"repo,omitzero"` + Branch string `json:"branch,omitempty"` + Symbol string `json:"symbol"` + File string `json:"file,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// GraphSymbolImpactRequest is the impact radius of every definition of a +// symbol name. Depth defaults to 2 and is clamped to 1-10. +type GraphSymbolImpactRequest struct { + Repo GraphRepositorySelector `json:"repo,omitzero"` + Branch string `json:"branch,omitempty"` + Symbol string `json:"symbol"` + File string `json:"file,omitempty"` + Depth int `json:"depth,omitempty"` +} diff --git a/test/fixtures/codegraph/baseline.json b/test/fixtures/codegraph/baseline.json index aae08a43..2a54cc74 100644 --- a/test/fixtures/codegraph/baseline.json +++ b/test/fixtures/codegraph/baseline.json @@ -1,13 +1,24 @@ { - "index_ms": 799.504667, + "index_ms": 507.1895830000001, "library_getCallers_ms": { - "p50": 0.024959000000080778, - "p95": 0.031874999999899956, + "p50": 0.02554099999997561, + "p95": 0.03483300000016243, "samples": 100 }, + "machine": { + "architecture": "arm64", + "cpu_model": "Apple M3 Max", + "logical_cpus": 14, + "node": "v24.13.0", + "os_release": "25.6.0", + "os_version": "Darwin Kernel Version 25.6.0: Tue Aug 18 17:51:35 PDT 2026; root:xnu-12377.161.15.700.19~2/RELEASE_ARM64_T6031", + "platform": "darwin", + "sqlite": "3.50.4", + "v8": "13.6.233.17-node.37" + }, "platform": "Darwin arm64", "result": { - "durationMs": 743, + "durationMs": 447, "edgesCreated": 93, "errors": [], "filesDiscovered": 13, @@ -20,8 +31,9 @@ "runtime": "v24.13.0", "scope": "pinned portable CodeGraph indexing, library getCallers, and separate direct SQLite call query; not GraphNest or browser latency", "sqlite_warm_call_query_ms": { - "p50": 0.019625, - "p95": 0.022917, + "p50": 0.019667, + "p95": 0.02125, "samples": 100 - } + }, + "workflow_timings": {} } diff --git a/test/fixtures/codegraph/library-expected.json b/test/fixtures/codegraph/library-expected.json index cd146c19..abd51085 100644 --- a/test/fixtures/codegraph/library-expected.json +++ b/test/fixtures/codegraph/library-expected.json @@ -1987,6 +1987,70 @@ "score": 3.570200490862841 } ], + "mcp-callees-grouped": { + "content": [ + { + "text": "**Callees of greet \u2014 4 distinct definitions (narrow with `file`)**\n\n**Parent::greet** (method) \u2014 model.swift:3\n- greet (method) - model.swift:4\n\n**Child::greet** (method) \u2014 model.swift:4\n- (no callees)\n\n**Base::greet** (method) \u2014 core.ts:2\n- greet (method) - core.ts:8\n\n**Service::greet** (method) \u2014 core.ts:8\n- normalize (function) - core.ts:3", + "type": "text" + } + ] + }, + "mcp-callees-single": { + "content": [ + { + "text": "**Callees of run (3 found)**\n\n- greet (method) - core.ts:8\n- normalize (function) - core.ts:3\n- Service (class) - core.ts:6 \u2014 via instantiation", + "type": "text" + } + ] + }, + "mcp-callers-file": { + "content": [ + { + "text": "**Callers of normalize (6 found)**\n\n- Widget.vue (file) - Widget.vue:1\n- greet (method) - core.ts:8\n- run (function) - main.ts:2\n- unicodeCaller (function) - unicode.ts:2\n- main.ts (file) - main.ts:1 \u2014 via import\n- unicode.ts (file) - unicode.ts:1 \u2014 via import", + "type": "text" + } + ] + }, + "mcp-callers-file-miss": { + "content": [ + { + "text": "**Callers of normalize \u2014 3 distinct definitions (narrow with `file`)**\n\n**Fixture::Model::normalize** (method) \u2014 model.rb:3\n- (no callers)\n\n**normalize** (function) \u2014 core.ts:3\n- Widget.vue (file) - Widget.vue:1\n- greet (method) - core.ts:8\n- run (function) - main.ts:2\n- unicodeCaller (function) - unicode.ts:2\n- main.ts (file) - main.ts:1 \u2014 via import\n- unicode.ts (file) - unicode.ts:1 \u2014 via import\n\n**fixture.models::Model::normalize** (method) \u2014 Model.java:3\n- (no callers)\n\n> **Note:** no definition of \"normalize\" matches file \"missing.ts\" \u2014 showing all definitions instead.", + "type": "text" + } + ] + }, + "mcp-callers-grouped": { + "content": [ + { + "text": "**Callers of normalize \u2014 3 distinct definitions (narrow with `file`)**\n\n**Fixture::Model::normalize** (method) \u2014 model.rb:3\n- (no callers)\n\n**normalize** (function) \u2014 core.ts:3\n- Widget.vue (file) - Widget.vue:1\n- greet (method) - core.ts:8\n- run (function) - main.ts:2\n- unicodeCaller (function) - unicode.ts:2\n- main.ts (file) - main.ts:1 \u2014 via import\n- unicode.ts (file) - unicode.ts:1 \u2014 via import\n\n**fixture.models::Model::normalize** (method) \u2014 Model.java:3\n- (no callers)", + "type": "text" + } + ] + }, + "mcp-callers-limit": { + "content": [ + { + "text": "**Callers of normalize (1 found)**\n\n- Widget.vue (file) - Widget.vue:1", + "type": "text" + } + ] + }, + "mcp-callers-missing": { + "content": [ + { + "text": "Symbol \"MissingFixtureSymbol987\" not found in the codebase", + "type": "text" + } + ] + }, + "mcp-callers-qualified": { + "content": [ + { + "text": "**Callers of Service.greet (2 found)**\n\n- run (function) - main.ts:2\n- greet (method) - core.ts:2", + "type": "text" + } + ] + }, "mcp-explore-source": { "content": [ { @@ -2003,6 +2067,30 @@ } ] }, + "mcp-impact-depth": { + "content": [ + { + "text": "**Impact: \"normalize\" affects 7 symbols**\n\n**core.ts:**\nnormalize:3, greet:8\n\n**Widget.vue:**\nWidget.vue:1\n\n**main.ts:**\nrun:2, main.ts:1\n\n**unicode.ts:**\nunicodeCaller:2, unicode.ts:1\n", + "type": "text" + } + ] + }, + "mcp-impact-file": { + "content": [ + { + "text": "**Impact: \"normalize\" affects 8 symbols**\n\n**core.ts:**\nnormalize:3, greet:8, greet:2\n\n**Widget.vue:**\nWidget.vue:1\n\n**main.ts:**\nrun:2, main.ts:1\n\n**unicode.ts:**\nunicodeCaller:2, unicode.ts:1\n", + "type": "text" + } + ] + }, + "mcp-impact-grouped": { + "content": [ + { + "text": "**Impact of identity \u2014 2 distinct definitions (each with its own blast radius; narrow with `file`)**\n\n**Impact: \"identity (core.ts:15)\" affects 1 symbols**\n\n**core.ts:**\nidentity:15\n\n\n**Impact: \"identity (model.rs:5)\" affects 1 symbols**\n\n**model.rs:**\nidentity:5\n", + "type": "text" + } + ] + }, "ui-deadcode": { "bounded": false, "candidates": 20, diff --git a/test/fixtures/codegraph/manifest.json b/test/fixtures/codegraph/manifest.json index 786a9ece..d8dfe106 100644 --- a/test/fixtures/codegraph/manifest.json +++ b/test/fixtures/codegraph/manifest.json @@ -69,8 +69,19 @@ "lib-getImpactRadius", "lib-getTypeHierarchy", "lib-searchNodes", + "mcp-callees-grouped", + "mcp-callees-single", + "mcp-callers-file", + "mcp-callers-file-miss", + "mcp-callers-grouped", + "mcp-callers-limit", + "mcp-callers-missing", + "mcp-callers-qualified", "mcp-explore-source", "mcp-explore-unmatched-fallback", + "mcp-impact-depth", + "mcp-impact-file", + "mcp-impact-grouped", "ui-deadcode", "ui-entrypoints", "ui-file-public-members", @@ -99,7 +110,7 @@ "sanitation": "source mtimes and SQLite timestamp columns zero; temporary source root replaced by /fixture; backup checkpoint and VACUUM; oracle timestamps/timings zero or Unix epoch; trail author fixed to GraphNest fixture; no graph facts added", "sha256": { "expected.json": "fa1929a78cc8d4dc00cc65d66f2b43e2e4767596247ff8cdc7b5f3553bd4dfd3", - "library-expected.json": "b836bd1a569ba8b4cb2b4afa8057716fe693593ac8554e478dfeb10d99968676", + "library-expected.json": "e459ee27495c766903fc386dbb889b309808d7fe493702810c543f48809fd1f5", "reference.db": "c7a83a0aceb204e592bbd35dd8fdf5cd7231adde940fdda69777fd40eb4e1e43", "schema.sql": "7827ac9c08b04629e9427aa167e86ec23888b45dac2f89e5f139136b4221b693", "source/Model.java": "02a16031c03ee9f1902dc68d31850f7c5e3270e8deaf54d79c748a5e8350f0cf", diff --git a/test/fixtures/codegraph/workflow-baseline.json b/test/fixtures/codegraph/workflow-baseline.json index 91049ae5..1639876c 100644 --- a/test/fixtures/codegraph/workflow-baseline.json +++ b/test/fixtures/codegraph/workflow-baseline.json @@ -12,7 +12,7 @@ }, "harness_sha256": { "test/parity/generate_reference.py": "31ac0cdf9c10db300dd1feff24de33a29ac86e774ee0ff947d5845c03143775b", - "test/parity/reference.mjs": "ef780468541982569e3876b85fb03b869900e8fa49ef787ee318e78110044a98" + "test/parity/reference.mjs": "feba00721232b474181d6b26b52a643fd2760f7745bd76712281025b27d4dae1" }, "machine": { "architecture": "arm64", @@ -20,7 +20,7 @@ "logical_cpus": 14, "node": "v24.13.0", "os_release": "25.6.0", - "os_version": "Darwin Kernel Version 25.6.0: Fri Jul 31 19:18:53 PDT 2026; root:xnu-12377.161.14~5/RELEASE_ARM64_T6031", + "os_version": "Darwin Kernel Version 25.6.0: Tue Aug 18 17:51:35 PDT 2026; root:xnu-12377.161.15.700.19~2/RELEASE_ARM64_T6031", "platform": "darwin", "sqlite": "3.50.4", "v8": "13.6.233.17-node.37" @@ -56,7 +56,7 @@ "repository": "https://github.com/colbymchenry/codegraph", "version": "1.6.0" }, - "reference_answers_sha256": "b836bd1a569ba8b4cb2b4afa8057716fe693593ac8554e478dfeb10d99968676", + "reference_answers_sha256": "e459ee27495c766903fc386dbb889b309808d7fe493702810c543f48809fd1f5", "reference_db_sha256": "c7a83a0aceb204e592bbd35dd8fdf5cd7231adde940fdda69777fd40eb4e1e43", "schema_version": 9, "scope": "five in-process runs on one warm portable CodeGraph; query plus JSON serialization; not GraphNest or transport/browser latency", @@ -94,8 +94,8 @@ "nodeId": "function:2e0f1da852db2702796c16967fca2efb" }, "handler": "same open CodeGraph; no session state", - "median_p50_ms": 0.027417, - "median_p95_ms": 0.033375, + "median_p50_ms": 0.029208, + "median_p95_ms": 0.034667, "result_budget": { "maxDepth": 1, "note": "all direct callers; this library method has no result-limit argument", @@ -103,563 +103,563 @@ }, "runs": [ { - "p50_ms": 0.030125, - "p95_ms": 0.03525, - "process_max_rss_bytes": 3700948992, - "process_rss_bytes": 3625959424, + "p50_ms": 0.030666, + "p95_ms": 0.036959, + "process_max_rss_bytes": 3098296320, + "process_rss_bytes": 3093037056, "response_bytes": { "max": 3413, "min": 3413 }, "samples_ms": [ + 0.030042, + 0.036125, + 0.033833, + 0.034625, + 0.033958, + 0.034666, + 0.034667, + 0.029416, + 0.030333, + 0.031041, + 0.031708, + 0.033625, + 0.028708, + 0.030875, + 0.038625, + 0.03, + 0.032375, + 0.040292, + 0.031, + 0.029958, + 0.029833, + 0.03, + 0.031667, + 0.031625, + 0.028583, + 0.029417, + 0.031875, 0.030292, + 0.031958, + 0.029875, + 0.028709, + 0.03025, + 0.029958, + 0.029666, + 0.031041, + 0.029833, + 0.029583, + 0.031666, 0.032333, - 0.031375, - 0.0355, - 0.030959, - 0.032042, - 0.031167, - 0.031542, - 0.031208, - 0.029, - 0.029459, - 0.031042, - 0.029208, - 0.031166, - 0.031083, - 0.031833, - 0.029417, - 0.030625, - 0.0295, - 0.033875, - 0.030542, - 0.032416, - 0.028584, - 0.029709, - 0.029625, - 0.0315, - 0.029166, - 0.03, + 0.029792, + 0.029792, 0.029417, + 0.030917, + 0.029833, + 0.0285, 0.030125, - 0.031333, - 0.030625, - 0.029125, - 0.031209, - 0.029541, - 0.031167, - 0.029333, + 0.02925, + 0.029291, + 0.029208, + 0.030333, + 0.031, + 0.0305, + 0.028042, + 0.031458, + 0.02925, + 0.034291, + 0.0295, + 0.030875, + 0.029667, 0.031209, - 0.0285, - 0.0345, - 0.030042, - 0.032542, + 0.032042, 0.029417, - 0.0325, - 0.028291, - 0.028458, - 0.029333, - 0.029083, - 0.029625, - 0.031083, + 0.030792, 0.02825, - 0.033, - 0.031208, - 0.029458, - 0.02925, - 0.031583, - 0.028708, - 0.03225, - 0.03525, - 0.03125, - 0.037459, - 0.030709, - 0.031583, - 0.030584, 0.029, + 0.032209, + 0.031041, + 0.029917, 0.02925, - 0.031084, - 0.03175, - 0.029708, - 0.029792, - 0.029167, + 0.027916, 0.029417, - 0.028958, - 0.031333, - 0.032458, + 0.031625, + 0.029333, + 0.030667, 0.0305, - 0.02975, - 0.029125, - 0.028167, - 0.033375, - 0.02925, - 0.028458, + 0.030666, + 0.029833, + 0.031, + 0.029459, + 0.029041, 0.028125, - 0.029541, - 0.027417, - 0.031458, - 0.027958, - 0.027917, - 0.027833, - 0.026833, - 0.030375, - 0.031791, - 0.028292, - 0.028625, - 0.035833, - 0.035708, - 0.02975, - 0.032334, - 0.027958, - 0.035333 + 0.029083, + 0.031291, + 0.128875, + 0.044292, + 0.036959, + 0.031333, + 0.032041, + 0.031334, + 0.032542, + 0.032375, + 0.030959, + 0.029208, + 0.034334, + 0.03125, + 0.033292, + 0.039, + 0.029291, + 0.031667, + 0.030833 ] }, { - "p50_ms": 0.029666, - "p95_ms": 0.033958, - "process_max_rss_bytes": 3700948992, - "process_rss_bytes": 3627335680, + "p50_ms": 0.030583, + "p95_ms": 0.038167, + "process_max_rss_bytes": 3098296320, + "process_rss_bytes": 3095330816, "response_bytes": { "max": 3413, "min": 3413 }, "samples_ms": [ - 0.028209, - 0.029458, - 0.029917, - 0.032291, - 0.0295, - 0.027583, - 0.026792, - 0.028459, - 0.033083, - 0.028792, - 0.031, - 0.028916, - 0.029708, - 0.028083, - 0.033958, + 0.030334, + 0.031709, + 0.030167, + 0.031833, 0.03, - 0.033041, - 0.0285, - 0.030667, - 0.029875, - 0.029791, - 0.031334, - 0.0415, - 0.029, - 0.029875, - 0.03225, - 0.031292, - 0.02925, - 0.031625, - 0.028625, - 0.02875, - 0.029667, - 0.029083, + 0.029833, + 0.030416, + 0.030166, + 0.030209, + 0.031458, + 0.031042, 0.029792, - 0.031041, - 0.028167, - 0.031375, - 0.029, - 0.02875, - 0.030708, - 0.03025, + 0.029583, 0.031042, - 0.031083, - 0.029333, - 0.029375, - 0.028708, - 0.030083, - 0.038125, - 0.032458, - 0.028417, - 0.041875, + 0.043583, + 0.030542, + 0.161167, + 0.068042, + 0.043209, + 0.035541, + 0.034333, + 0.038167, + 0.0325, + 0.031667, + 0.031, + 0.030958, + 0.031875, + 0.030791, + 0.031041, + 0.032625, + 0.031167, + 0.029083, + 0.035167, + 0.03175, + 0.030583, + 0.031125, + 0.029458, + 0.030625, + 0.03225, + 0.030208, + 0.029792, + 0.034292, + 0.029083, + 0.031125, + 0.032209, + 0.03, + 0.029958, 0.028917, - 0.029375, - 0.029333, - 0.028792, - 0.032083, - 0.030708, - 0.028333, - 0.031542, + 0.029833, + 0.031708, + 0.032208, 0.030166, + 0.0315, + 0.028583, + 0.030583, + 0.031625, + 0.031, + 0.030834, + 0.039417, + 0.030292, 0.030542, - 0.02875, - 0.0285, - 0.028375, - 0.03075, - 0.028667, + 0.030209, + 0.032041, + 0.029875, + 0.03, + 0.033334, + 0.029625, + 0.029583, + 0.033917, + 0.033666, + 0.030333, 0.02975, - 0.028209, - 0.02725, - 0.0285, - 0.033208, - 0.028958, - 0.028042, - 0.027625, - 0.02825, - 0.028291, - 0.030084, - 0.028375, - 0.027875, - 0.029333, - 0.028208, - 0.0305, - 0.032958, - 0.029709, - 0.027958, - 0.031333, - 0.031125, - 0.030458, 0.029666, - 0.0305, - 0.030666, + 0.031084, + 0.0335, + 0.029875, + 0.0315, + 0.030166, 0.029417, - 0.029125, - 0.037334, - 0.035625, - 0.032375, - 0.02925, - 0.0275, 0.03025, - 0.028875 + 0.037583, + 0.030334, + 0.029375, + 0.02925, + 0.0295, + 0.029334, + 0.030625, + 0.030583, + 0.029959, + 0.03075, + 0.029209, + 0.029167, + 0.030166, + 0.029, + 0.030541, + 0.033833, + 0.029709, + 0.030583, + 0.030334, + 0.029542 ] }, { - "p50_ms": 0.027375, - "p95_ms": 0.030917, - "process_max_rss_bytes": 3700948992, - "process_rss_bytes": 3627630592, + "p50_ms": 0.029208, + "p95_ms": 0.0335, + "process_max_rss_bytes": 3098296320, + "process_rss_bytes": 3097149440, "response_bytes": { "max": 3413, "min": 3413 }, "samples_ms": [ - 0.028208, - 0.780917, - 0.033167, - 0.028834, - 0.028583, - 0.030917, - 0.0285, - 0.027625, - 0.027458, - 0.027292, - 0.028209, - 0.027959, - 0.028208, - 0.027333, - 0.026875, - 0.027375, - 0.027041, - 0.02725, - 0.027125, - 0.026791, - 0.02625, - 0.026292, - 0.028666, + 0.030666, + 0.029125, + 0.030958, + 0.029458, + 0.0335, + 0.03075, 0.02925, - 0.029625, - 0.026667, - 0.026417, - 0.026416, - 0.026625, - 0.026916, - 0.027291, - 0.031209, - 0.03025, - 0.028083, - 0.028167, - 0.027042, - 0.026667, - 0.02825, - 0.027375, - 0.026917, - 0.027542, - 0.026834, - 0.026708, - 0.034792, - 0.029041, - 0.027125, - 0.026958, - 0.027458, - 0.026792, - 0.02725, - 0.027292, - 0.027375, - 0.027625, - 0.027167, - 0.02675, - 0.027041, - 0.027041, - 0.027209, - 0.026833, - 0.026834, - 0.0265, - 0.027208, - 0.026959, - 0.0265, - 0.026875, - 0.027083, - 0.027041, - 0.02825, - 0.026542, - 0.026541, - 0.026833, - 0.026875, - 0.032125, - 0.027708, - 0.02775, - 0.026791, + 0.030208, + 0.030125, 0.027875, - 0.027042, + 0.0295, + 0.0315, + 0.032375, + 0.029292, + 0.029125, + 0.028708, + 0.032041, + 0.031292, + 0.028708, + 0.029208, + 0.027542, + 0.029542, + 0.029875, + 0.033459, + 0.032708, + 0.02975, 0.030208, - 0.027834, - 0.028041, - 0.026834, - 0.028042, - 0.027583, 0.028375, + 0.032416, + 0.03175, + 0.029458, + 0.028667, + 0.028875, + 0.028875, + 0.029083, + 0.03, + 0.031458, + 0.029209, + 0.027583, + 0.029375, + 0.036375, + 0.032209, + 0.029625, + 0.027667, + 0.037917, + 0.028541, + 0.02975, + 0.029916, + 0.031084, + 0.027792, + 0.029167, 0.02875, - 0.029875, - 0.026875, - 0.028167, + 0.028291, + 0.032083, 0.028792, + 0.027542, + 0.031041, + 0.029541, + 0.029625, + 0.033542, + 0.02775, + 0.03225, 0.028625, - 0.030791, + 0.028584, + 0.03, + 0.030208, + 0.027584, + 0.029833, + 0.027708, + 0.02725, + 0.029959, + 0.029, + 0.029708, + 0.028, + 0.02875, + 0.027375, 0.028125, - 0.027042, - 0.028417, - 0.028417, + 0.029708, + 0.027333, + 0.027834, + 0.029708, + 0.0355, + 0.02875, + 0.02875, + 0.028667, + 0.027375, + 0.028, + 0.0275, + 0.028792, + 0.028917, 0.027458, - 0.029458, - 0.027167, - 0.027792 + 0.027417, + 0.027667, + 0.029167, + 0.028125, + 0.032667, + 0.087625, + 0.027667, + 0.027708, + 0.027375 ] }, { - "p50_ms": 0.027417, - "p95_ms": 0.033375, - "process_max_rss_bytes": 3700948992, - "process_rss_bytes": 3628974080, + "p50_ms": 0.027666, + "p95_ms": 0.030917, + "process_max_rss_bytes": 3098296320, + "process_rss_bytes": 3098034176, "response_bytes": { "max": 3413, "min": 3413 }, "samples_ms": [ - 0.027833, - 0.027375, - 0.027583, - 0.026, - 0.026042, + 0.028875, + 0.027083, + 0.029292, + 0.027, + 0.027291, + 0.027125, + 0.027125, + 0.029292, + 0.029125, + 0.027458, + 0.02725, + 0.028833, + 0.027666, + 0.029541, + 0.029209, + 0.0305, + 0.0275, + 0.028167, 0.027542, - 0.027917, - 0.028083, - 0.027292, - 0.026, - 0.026375, + 0.02775, + 0.029958, + 0.027209, + 0.029041, + 0.029333, + 0.02775, 0.028083, - 0.027167, - 0.027417, - 0.026125, - 0.026667, - 0.0275, + 0.030583, + 0.027, + 0.027084, + 0.027333, + 0.027208, + 0.029875, + 0.031042, + 0.028666, + 0.029333, 0.027334, - 0.029125, - 0.02725, - 0.026417, - 0.027292, + 0.027625, + 0.027458, + 0.029958, + 0.028916, + 0.027083, + 0.027625, + 0.029041, + 0.035916, + 0.036125, + 0.02875, + 0.028792, + 0.034958, + 0.0285, 0.027542, - 0.028167, - 0.028084, - 0.027666, - 0.027375, - 0.027667, - 0.028542, - 0.026458, + 0.028791, + 0.027291, + 0.027291, 0.032, - 0.026083, - 0.027708, - 0.028125, - 0.027458, - 0.028209, - 0.027542, - 0.027583, - 0.028166, - 0.027667, - 0.030959, - 0.028625, - 0.026083, - 0.027625, - 0.027375, - 0.027792, - 0.027666, - 0.027416, - 0.026333, 0.027167, - 0.0285, - 0.032125, - 0.033375, - 0.038083, - 0.0345, - 0.02825, + 0.029292, + 0.030917, + 0.027417, + 0.026708, + 0.029125, + 0.027459, + 0.027042, + 0.028875, + 0.029, + 0.029542, + 0.026916, + 0.026708, + 0.027333, + 0.030041, 0.027333, + 0.027291, + 0.029542, + 0.027458, + 0.027042, + 0.030416, + 0.03025, + 0.026917, 0.027209, - 0.027417, - 0.026042, - 0.028958, - 0.027667, - 0.027583, - 0.028292, - 0.031417, - 0.031625, - 0.035208, - 0.028916, - 0.026667, - 0.050667, - 0.025667, - 0.026375, - 0.02675, - 0.026167, - 0.02625, - 0.026958, - 0.025542, - 0.026792, - 0.027917, - 0.026959, - 0.026333, - 0.025375, - 0.031209, - 0.027, + 0.02725, + 0.030708, + 0.029167, 0.027208, - 0.032791, - 0.040209, - 0.030083, - 0.028834, - 0.026041, - 0.026375, - 0.026833, - 0.026417, - 0.026625, - 0.025584, - 0.0265, - 0.026792, - 0.028167, - 0.026584, - 0.026375 + 0.027125, + 0.027375, + 0.027375, + 0.0275, + 0.029583, + 0.028833, + 0.027083, + 0.02675, + 0.026708, + 0.028334, + 0.029042, + 0.027125, + 0.027125, + 0.030792, + 0.02725, + 0.028083, + 0.028792, + 0.026792 ] }, { - "p50_ms": 0.027167, - "p95_ms": 0.033375, - "process_max_rss_bytes": 3700948992, - "process_rss_bytes": 3630301184, + "p50_ms": 0.028958, + "p95_ms": 0.034667, + "process_max_rss_bytes": 3099459584, + "process_rss_bytes": 3099459584, "response_bytes": { "max": 3413, "min": 3413 }, "samples_ms": [ - 0.027042, - 0.025958, + 0.027125, + 0.027333, + 0.027333, + 0.027834, + 0.026958, + 0.028625, + 0.029, + 0.027416, + 0.031083, + 0.027084, + 0.029209, + 0.034083, + 0.028375, + 0.027917, + 0.029667, 0.027417, - 0.026917, - 0.027625, - 0.025084, - 0.026584, - 0.026875, - 0.026458, - 0.026458, - 0.025208, - 0.033209, - 0.027458, - 0.0265, - 0.042958, - 0.028667, - 0.029833, + 0.027, + 0.029, + 0.028791, + 0.027583, + 0.028958, 0.027292, - 0.027167, - 0.027417, - 0.026208, - 0.029625, - 0.025791, - 0.02725, + 0.028833, + 0.028916, + 0.027291, + 0.027709, + 0.029333, 0.027417, + 0.027875, + 0.035791, + 0.032875, + 0.028375, 0.027417, - 0.027584, - 0.026125, - 0.027667, - 0.027375, - 0.027167, - 0.026958, - 0.026625, - 0.026208, - 0.030958, - 0.033375, - 0.033333, - 0.035417, - 0.033042, - 0.034958, - 0.027084, - 0.03425, - 0.027625, - 0.031042, - 0.026209, - 0.026792, - 0.026584, - 0.027167, - 0.026834, 0.027625, - 0.025708, - 0.027375, - 0.027208, - 0.027958, - 0.026625, - 0.026125, - 0.026958, - 0.027042, - 0.027083, - 0.026083, - 0.028417, - 0.025583, - 0.026875, - 0.026958, + 0.028083, + 0.037917, + 0.027792, 0.027125, - 0.026666, - 0.025833, - 0.027083, - 0.027666, + 0.028833, + 0.031083, + 0.027084, + 0.028334, + 0.036542, + 0.030583, + 0.027417, + 0.031083, + 0.033666, + 0.030958, + 0.028167, + 0.027708, 0.026917, - 0.026709, + 0.027167, + 0.027167, + 0.030625, + 0.031417, + 0.0295, + 0.032541, + 0.030667, + 0.027667, + 0.028666, + 0.027583, + 0.028625, + 0.02875, + 0.027, + 0.027167, + 0.030167, + 0.027542, + 0.027334, + 0.032458, + 0.032541, + 0.034667, 0.028583, - 0.026083, - 0.027708, - 0.030083, - 0.026708, - 0.026791, - 0.025792, - 0.0275, - 0.026958, - 0.028, - 0.029666, - 0.02925, 0.027042, - 0.027583, - 0.034958, + 0.032083, + 0.030625, + 0.029833, + 0.031625, + 0.029417, + 0.031542, + 0.031208, + 0.0305, + 0.03175, + 0.0285, + 0.028959, + 0.027667, + 0.033417, + 0.031667, + 0.03925, + 0.040625, + 0.028959, + 0.031417, + 0.031459, + 0.0305, + 0.029791, + 0.030167, 0.0325, - 0.031167, - 0.028167, - 0.025667, - 0.027167, - 0.027541, - 0.027417, - 0.027209, - 0.032459, - 0.0265, - 0.02675, - 0.027333, - 0.0265, - 0.028083 + 0.029583, + 0.030625, + 0.029208, + 0.032125 ] } ] @@ -671,8 +671,8 @@ "query": "processGreeting" }, "handler": "one retained ToolHandler across all warmups and runs; execute called without ExploreSessionState, query pool or watcher", - "median_p50_ms": 2.708, - "median_p95_ms": 4.1655, + "median_p50_ms": 2.878083, + "median_p95_ms": 4.555208, "result_budget": { "maxFiles": 12, "outputBudget": { @@ -690,563 +690,563 @@ }, "runs": [ { - "p50_ms": 2.858375, - "p95_ms": 4.302709, - "process_max_rss_bytes": 3700948992, - "process_rss_bytes": 514670592, + "p50_ms": 3.207791, + "p95_ms": 4.818333, + "process_max_rss_bytes": 3106045952, + "process_rss_bytes": 368459776, "response_bytes": { "max": 2272, "min": 2272 }, "samples_ms": [ - 3.132625, - 3.088708, - 3.09125, - 59.016042, - 3.706375, - 3.379583, - 3.132542, - 3.193042, - 3.023041, - 3.060916, - 4.302709, - 2.985833, - 2.915916, - 2.836083, - 2.932625, - 2.797709, - 3.036208, - 3.215209, - 3.071625, - 3.038375, - 3.048166, - 3.110833, - 3.030834, - 4.480625, - 2.930083, - 2.947583, - 2.858375, - 2.862625, - 2.92725, - 3.025459, - 3.032583, - 2.9815, - 2.948291, - 2.987209, - 2.884542, - 4.253, - 3.014792, - 2.929792, - 2.937, - 2.987417, - 2.79525, - 2.758875, - 2.746708, - 2.766917, - 2.832959, - 2.867583, - 2.803333, - 2.778667, - 2.808042, - 4.0775, - 2.830417, - 2.904542, - 2.725417, - 2.762625, - 2.817792, - 2.752625, - 2.849125, - 2.746458, - 2.744458, - 2.752708, - 2.741708, - 2.712042, - 4.304208, - 2.787333, - 2.767792, - 2.875708, - 2.770167, - 2.716041, - 2.780541, - 2.7865, - 2.715375, - 2.749584, - 2.684333, - 2.722, - 2.769792, - 4.46525, - 3.036625, - 2.869416, - 2.796667, - 2.744708, - 2.722125, - 2.747625, - 2.722083, - 2.68675, - 2.695292, - 2.770083, - 2.885042, - 2.771542, - 2.756833, - 4.521416, - 3.069042, - 2.9105, - 2.83075, - 2.711875, - 2.81225, - 2.707583, - 2.695625, - 2.706708, - 2.92625, - 2.750958 + 3.88675, + 3.82975, + 3.692625, + 3.914625, + 3.672041, + 3.991833, + 3.523375, + 3.403208, + 3.64675, + 3.775708, + 3.628833, + 4.881708, + 3.533792, + 3.516666, + 3.728958, + 3.246583, + 3.155042, + 3.228875, + 3.091875, + 3.136083, + 3.280542, + 3.309625, + 3.795459, + 5.295666, + 3.655083, + 3.235542, + 3.185084, + 3.306583, + 3.160833, + 3.503375, + 3.469375, + 3.479875, + 3.5485, + 3.679167, + 3.212041, + 3.209208, + 4.799375, + 4.440417, + 3.180833, + 3.057, + 3.003542, + 2.909792, + 2.952667, + 2.959125, + 3.040667, + 3.232042, + 3.30725, + 3.134708, + 3.010375, + 4.818333, + 3.373458, + 3.074416, + 3.442167, + 3.443417, + 3.073583, + 3.480708, + 3.30425, + 3.101458, + 3.258083, + 3.264125, + 3.128375, + 3.171666, + 3.05225, + 5.099875, + 3.162083, + 3.152792, + 3.16, + 3.017833, + 2.956083, + 3.295375, + 2.997292, + 3.0525, + 3.207791, + 3.163625, + 2.956958, + 3.004541, + 5.125542, + 3.069875, + 2.943542, + 3.021583, + 2.910042, + 3.071584, + 2.968792, + 2.829875, + 5.262625, + 3.342584, + 3.124458, + 2.961791, + 2.911708, + 2.970833, + 4.655875, + 3.454875, + 3.002083, + 2.947917, + 2.926041, + 3.151833, + 3.006791, + 2.896959, + 2.868625, + 3.015708 ] }, { - "p50_ms": 2.708, - "p95_ms": 4.1655, - "process_max_rss_bytes": 3700948992, - "process_rss_bytes": 516390912, + "p50_ms": 2.894792, + "p95_ms": 4.456667, + "process_max_rss_bytes": 3106045952, + "process_rss_bytes": 373211136, "response_bytes": { "max": 2272, "min": 2272 }, "samples_ms": [ - 3.110459, - 3.069792, - 2.965708, - 2.927209, - 3.300625, - 3.008916, - 2.909584, - 2.8275, - 2.720083, - 2.660292, - 2.663166, - 2.623375, - 2.622667, - 4.223833, - 2.62775, - 2.777916, - 2.714291, - 2.612959, - 2.621917, - 2.612708, - 2.654625, - 2.64, - 2.809167, - 2.755958, - 2.880167, - 2.805708, - 2.638875, - 4.360084, - 3.087459, - 2.814958, - 2.708833, - 2.669375, - 2.682291, - 2.624584, - 2.619667, - 2.623875, - 2.699792, - 2.618875, - 2.629125, - 2.704834, - 2.767791, - 4.1655, - 2.788375, - 2.765959, - 2.676083, - 2.656375, - 2.727291, - 2.625833, - 2.639, - 2.596209, - 2.609458, - 2.637167, - 2.718667, - 2.674833, - 2.708, - 2.617833, - 4.076208, - 2.700708, - 2.723958, - 2.624125, - 2.693958, - 2.741958, - 2.657583, - 2.664167, - 2.697708, - 2.705792, - 2.645167, - 2.755917, + 4.874583, + 2.952291, + 2.9565, + 2.911125, + 3.070042, + 2.95625, + 3.409583, + 3.320917, + 2.90775, + 3.179042, + 3.033, + 2.847333, + 2.856959, + 2.894792, + 4.573625, + 2.915791, + 2.832, + 2.912292, + 3.212625, + 3.083417, 2.886875, - 2.611959, - 4.494167, - 2.911083, - 2.706875, - 2.663959, - 2.636208, - 2.684083, - 2.768292, - 2.686583, - 2.690708, - 2.780375, - 2.609625, - 2.6805, - 2.628, - 2.602, - 4.309959, - 3.014, - 2.937292, - 2.901459, - 2.918583, - 2.903291, - 2.914166, - 2.900792, - 2.750542, - 2.816458, - 2.874625, - 2.767041, - 2.840459, - 3.192875, - 2.670292, - 5.194041 + 2.840375, + 2.924, + 3.00175, + 2.82125, + 2.851375, + 2.821167, + 2.7955, + 4.456667, + 2.928417, + 2.928375, + 2.895292, + 2.881958, + 2.766916, + 2.752709, + 2.753167, + 2.730958, + 2.757583, + 2.78825, + 3.075334, + 2.906916, + 2.825208, + 2.826042, + 4.767417, + 2.899166, + 3.050917, + 3.287291, + 2.988167, + 2.989292, + 2.913875, + 2.785542, + 3.093959, + 3.3295, + 2.9675, + 2.874125, + 2.802334, + 2.862542, + 4.732166, + 3.199167, + 3.083166, + 3.028584, + 2.94725, + 2.818458, + 3.048667, + 3.103416, + 3.078209, + 3.103542, + 2.864042, + 2.838833, + 2.773041, + 2.802583, + 4.594417, + 2.849167, + 2.816666, + 2.882875, + 2.822333, + 2.823125, + 2.779792, + 2.772292, + 2.858208, + 3.006167, + 2.984792, + 2.865666, + 2.797334, + 2.790209, + 4.41225, + 2.822458, + 2.734958, + 2.745916, + 3.439125, + 2.971208, + 2.937167, + 2.84375, + 2.766083, + 2.797833, + 2.732292, + 2.774708, + 2.8005, + 2.656917, + 2.700541 ] }, { - "p50_ms": 2.844791, - "p95_ms": 4.238125, - "process_max_rss_bytes": 3700948992, - "process_rss_bytes": 517079040, + "p50_ms": 2.804625, + "p95_ms": 4.555208, + "process_max_rss_bytes": 3106045952, + "process_rss_bytes": 377946112, "response_bytes": { "max": 2272, "min": 2272 }, "samples_ms": [ - 3.034542, - 2.890333, - 2.858208, - 2.757875, - 2.958875, - 2.938083, - 2.870458, - 2.733667, - 5.603791, - 3.111375, - 3.025666, - 3.483875, - 3.048292, - 2.74475, - 2.793917, - 3.734042, - 3.203291, - 2.918416, - 2.710458, - 3.326208, - 5.789833, - 3.378375, - 3.195417, - 2.971792, - 2.841375, - 3.3225, - 3.171708, - 2.632791, - 3.424916, - 3.321584, - 3.206, - 3.177542, - 3.041333, - 2.916291, - 4.361667, - 2.817542, - 2.688708, - 2.949166, - 3.795708, - 3.178792, - 3.000333, - 2.881625, - 2.925167, - 2.87875, - 2.861041, - 2.816834, - 2.913583, - 2.99775, - 2.844791, - 5.047416, - 2.986708, - 2.875208, - 2.926917, - 2.718667, - 2.765959, - 2.796833, - 2.824542, - 2.76575, - 2.718709, - 2.697875, - 2.7045, - 2.657125, - 2.64975, - 4.238125, - 3.056708, - 2.932833, - 2.90225, - 2.711833, - 2.675708, - 2.70225, - 2.791166, - 2.706, - 2.740833, - 2.692041, - 2.665125, - 2.727916, - 2.800375, - 2.670292, - 4.114708, - 2.667583, - 2.659375, - 2.764166, - 2.93375, - 2.718625, - 2.614292, - 2.612541, - 2.548583, - 2.57075, - 2.610542, - 2.62525, - 2.61, - 2.597791, - 4.274708, - 2.929083, - 2.813834, - 2.676333, - 2.672375, - 2.63975, - 2.601166, - 2.623792 + 3.101417, + 3.048417, + 2.804625, + 2.753542, + 3.190709, + 3.009333, + 2.873125, + 2.800208, + 3.212041, + 4.9535, + 2.907042, + 2.894167, + 2.77425, + 2.739042, + 2.79425, + 3.091834, + 2.80325, + 2.734875, + 2.989875, + 3.119875, + 2.932417, + 2.915125, + 2.888625, + 2.755208, + 4.530709, + 2.822417, + 2.769375, + 2.953875, + 2.828541, + 2.829, + 2.740459, + 2.750875, + 2.756875, + 2.703125, + 2.820417, + 2.814667, + 2.765333, + 2.762333, + 2.747292, + 4.57425, + 2.7815, + 2.78175, + 2.771083, + 2.720166, + 2.946458, + 2.836, + 2.745584, + 2.777541, + 2.741666, + 2.957, + 2.717125, + 2.637, + 2.693625, + 4.555208, + 2.774292, + 2.717708, + 2.895459, + 3.279334, + 2.863125, + 2.834333, + 2.768125, + 2.728042, + 2.745417, + 2.997291, + 2.771042, + 2.730958, + 2.755458, + 2.787959, + 4.606792, + 2.7065, + 2.643375, + 2.730083, + 2.815208, + 2.788167, + 2.672792, + 2.748292, + 2.710833, + 2.736791, + 2.945792, + 3.118458, + 2.888166, + 2.967459, + 3.218292, + 4.730375, + 2.856083, + 3.054083, + 3.010833, + 2.905584, + 2.809, + 3.007458, + 2.675792, + 2.727542, + 2.702834, + 2.701583, + 2.737, + 2.712667, + 2.975041, + 4.831833, + 2.888083, + 2.842375 ] }, { - "p50_ms": 2.637084, - "p95_ms": 4.106083, - "process_max_rss_bytes": 3700948992, - "process_rss_bytes": 517160960, + "p50_ms": 2.853458, + "p95_ms": 4.588875, + "process_max_rss_bytes": 3106045952, + "process_rss_bytes": 382517248, "response_bytes": { "max": 2272, "min": 2272 }, "samples_ms": [ - 2.610833, - 2.66525, - 4.237125, - 2.610667, - 2.623667, - 2.684167, - 2.572625, - 2.600583, - 2.760208, - 2.699666, - 2.602167, - 2.57625, - 2.573834, - 2.6485, - 2.632916, - 2.58925, - 4.106083, - 2.572458, - 2.572125, - 2.562125, - 2.62125, - 2.62325, - 2.610167, - 2.74625, - 2.637084, - 2.564792, - 2.677125, - 2.696875, - 2.602, - 2.596958, - 2.588375, - 4.41325, - 2.744958, - 2.695375, - 2.668834, - 2.64325, - 2.598208, - 2.688458, - 2.70875, - 2.6965, - 2.65825, - 2.639042, - 2.654958, - 2.570666, - 2.644208, - 4.208666, - 2.656167, - 2.709208, - 2.634667, - 2.591458, - 2.688667, - 2.800292, - 2.662042, - 2.577417, - 2.583625, - 2.646417, - 2.680083, - 2.740125, - 2.757292, - 4.254958, - 2.6235, - 2.534792, - 2.648542, - 2.580916, - 2.559334, - 2.569417, - 2.601417, - 2.547292, - 2.572209, - 2.679041, - 2.564334, - 2.596791, - 2.555625, - 2.558375, - 4.317291, - 2.974167, - 2.683375, - 2.743833, - 2.733417, - 2.670542, - 2.669833, - 2.727791, - 2.733875, - 2.614083, - 2.597417, - 2.600792, - 2.57725, - 2.687291, - 4.102458, - 2.771916, - 2.593334, - 2.640833, - 2.576208, - 2.64525, - 2.619625, - 2.635792, - 2.54775, - 2.575083, - 2.59075, - 2.800209 + 2.701208, + 2.625542, + 2.658083, + 2.680375, + 2.822625, + 2.778917, + 3.057416, + 4.717375, + 2.85525, + 2.798333, + 3.325, + 2.883542, + 2.83925, + 2.828333, + 2.952333, + 3.263541, + 2.751292, + 3.030917, + 2.945625, + 2.867083, + 2.849042, + 4.692542, + 3.077333, + 3.020417, + 2.947125, + 2.948958, + 2.927083, + 2.960417, + 3.0155, + 2.84375, + 2.769416, + 3.082417, + 2.923333, + 2.832291, + 2.737625, + 3.228417, + 4.885833, + 3.146959, + 2.871833, + 3.054083, + 2.849291, + 2.780208, + 3.023, + 2.91225, + 2.796125, + 2.785917, + 2.938542, + 2.763416, + 2.758958, + 2.68375, + 4.404333, + 2.847583, + 2.896666, + 2.809334, + 2.933042, + 2.733917, + 2.721625, + 2.716292, + 2.810833, + 2.876167, + 2.71, + 3.054875, + 2.872166, + 2.905041, + 2.809417, + 4.602125, + 3.289834, + 2.912042, + 2.834709, + 2.980709, + 2.786917, + 2.713, + 2.699667, + 2.853458, + 2.804125, + 2.740458, + 2.643584, + 3.011583, + 2.966667, + 4.588875, + 2.835167, + 2.765083, + 2.868041, + 2.723417, + 2.691708, + 2.715334, + 2.762875, + 2.69725, + 3.260625, + 3.178666, + 5.306542, + 3.133333, + 2.850375, + 2.819542, + 2.853708, + 2.867958, + 2.795, + 2.921208, + 2.794792, + 2.778625 ] }, { - "p50_ms": 2.586416, - "p95_ms": 3.993459, - "process_max_rss_bytes": 3700948992, - "process_rss_bytes": 517275648, + "p50_ms": 2.878083, + "p95_ms": 4.528292, + "process_max_rss_bytes": 3106045952, + "process_rss_bytes": 382550016, "response_bytes": { "max": 2272, "min": 2272 }, "samples_ms": [ - 2.626458, - 2.570959, - 2.593375, - 2.561541, - 2.60225, - 2.564208, - 2.564833, - 2.733791, - 2.562542, - 2.586416, - 2.571917, - 2.622125, - 4.07825, - 2.654791, - 2.569708, - 2.59175, - 2.617833, - 2.57675, - 2.547208, - 2.602166, - 2.648125, - 2.566167, - 2.554958, - 2.547917, - 2.552083, - 2.555417, - 2.5285, - 4.020792, - 2.526958, - 2.539958, - 2.5305, - 2.554625, - 2.676917, - 2.64575, - 2.72625, - 2.544875, - 2.533416, - 2.538209, - 2.584334, - 2.646875, - 2.564875, - 3.963875, - 2.557916, - 2.578542, - 2.655833, - 2.642042, - 2.622083, - 2.567958, - 2.607291, - 2.54075, - 2.531459, - 2.603208, - 2.556875, - 2.564042, - 2.571042, - 2.542833, - 4.01375, - 2.675083, - 2.574834, - 2.586292, - 2.561792, - 2.53425, - 2.527167, - 2.554208, - 2.640666, - 2.552875, - 2.557916, - 2.547209, - 2.534208, - 2.550416, - 4.103791, - 2.922875, - 2.762958, - 2.722459, - 2.650375, - 2.613833, - 2.666917, - 2.673708, - 2.712958, - 2.756625, - 2.739666, - 2.781459, - 2.807292, - 2.942292, - 2.776375, - 4.087208, - 2.654959, - 2.566333, - 2.598625, - 2.645417, - 2.576291, - 2.560959, - 2.589916, - 2.545792, - 2.626667, - 2.608167, - 2.588292, - 2.598542, - 2.543541, - 3.993459 + 4.867042, + 2.930542, + 2.852875, + 2.829041, + 2.801458, + 2.721667, + 2.75675, + 2.932917, + 3.076083, + 2.85875, + 2.821708, + 3.083292, + 3.038125, + 2.783, + 4.880583, + 3.037708, + 2.973458, + 3.21125, + 2.991792, + 2.842042, + 2.80725, + 2.773833, + 2.735, + 2.681542, + 2.786416, + 2.740459, + 2.866708, + 2.946084, + 3.076291, + 4.626209, + 2.804375, + 2.772458, + 2.844125, + 2.789416, + 2.737917, + 2.914208, + 3.157167, + 3.172292, + 2.910625, + 2.867041, + 2.857042, + 3.066834, + 2.878083, + 4.528292, + 3.435333, + 3.006666, + 2.885334, + 3.057083, + 2.95475, + 2.833625, + 2.77575, + 2.750666, + 2.728208, + 2.762541, + 2.739708, + 2.707792, + 2.771667, + 4.438917, + 3.036958, + 2.875833, + 2.825458, + 2.761, + 3.381708, + 2.935, + 2.758333, + 2.829417, + 2.82625, + 2.8, + 2.983375, + 3.346458, + 2.943791, + 2.919375, + 4.974375, + 2.825083, + 2.811334, + 3.269208, + 3.185375, + 3.004667, + 2.998833, + 2.997458, + 2.838709, + 2.793417, + 3.167417, + 3.369667, + 3.012917, + 3.011042, + 4.734833, + 2.888666, + 2.827292, + 2.848042, + 3.211875, + 2.896708, + 2.752917, + 2.88825, + 3.021958, + 2.858417, + 2.830917, + 2.81225, + 2.984167, + 2.728875 ] } ] @@ -1258,570 +1258,570 @@ "to": "run" }, "handler": "same open CodeGraph and upstream source/parser caches; no session state", - "median_p50_ms": 0.453459, - "median_p95_ms": 0.494666, + "median_p50_ms": 0.491458, + "median_p95_ms": 0.62475, "result_budget": { "maxFlows": 4 }, "runs": [ { - "p50_ms": 0.468292, - "p95_ms": 0.50325, - "process_max_rss_bytes": 3700948992, - "process_rss_bytes": 518651904, + "p50_ms": 0.508541, + "p95_ms": 0.639083, + "process_max_rss_bytes": 3106045952, + "process_rss_bytes": 383795200, "response_bytes": { "max": 2877, "min": 2877 }, "samples_ms": [ - 0.52225, - 0.499709, - 0.494917, - 0.482125, - 0.482083, - 0.46625, - 0.465792, - 0.469, - 0.463625, - 0.463833, - 0.50325, - 0.469542, - 0.493041, - 0.48275, - 0.480292, - 0.466041, - 0.468625, - 0.462667, - 0.46475, - 0.47225, - 0.513458, - 0.467125, - 0.469417, - 0.463167, - 0.471125, - 0.46275, - 0.465916, - 0.475417, - 0.464958, - 0.496791, - 0.470917, - 0.460208, - 0.468958, - 0.474209, - 0.508709, - 0.476583, - 0.479, + 0.5415, + 0.520166, + 0.695958, + 2.298666, + 0.562792, + 0.522417, + 0.508541, 0.50225, - 0.487042, - 0.468292, - 0.4695, - 0.467333, - 0.46425, - 0.463959, - 0.462209, - 0.462542, - 0.525833, - 0.485917, - 0.468416, - 0.470417, - 0.457208, - 0.467625, - 0.484375, - 0.476458, - 0.459041, - 0.470084, - 0.463958, - 0.491083, - 0.465666, - 0.475459, - 0.479542, - 0.458666, - 0.47, - 0.467458, - 0.486167, - 0.458542, - 0.473458, - 0.461709, - 0.464292, - 0.464333, - 0.456583, - 0.493333, - 0.481417, - 0.475458, - 0.487542, - 0.459959, - 0.464458, - 1.457708, - 0.457875, - 0.465084, - 0.483334, - 0.459792, - 0.46225, - 0.460958, - 0.454208, - 0.454583, - 0.457084, - 0.454458, - 0.456708, - 0.459458, - 0.453209, - 0.46475, - 0.468583, - 0.482875, - 0.47975, - 0.460417, - 0.463917, - 0.469125, - 0.459792, - 0.4595 + 0.498209, + 0.498542, + 0.528166, + 0.559834, + 0.521167, + 0.521208, + 0.506875, + 0.498625, + 0.518667, + 0.525167, + 0.786667, + 0.631708, + 0.552833, + 0.535, + 0.525292, + 0.5085, + 0.508125, + 0.517167, + 0.520042, + 0.525083, + 0.513209, + 0.528875, + 0.51625, + 0.513041, + 0.498833, + 0.504583, + 0.501, + 0.499666, + 0.494625, + 0.493625, + 0.57525, + 0.5755, + 0.538833, + 0.530167, + 0.509541, + 0.523959, + 0.602875, + 0.523209, + 0.498458, + 0.500083, + 0.49375, + 0.606959, + 0.659208, + 0.561791, + 0.557791, + 0.535083, + 0.513458, + 0.506125, + 0.508625, + 0.514667, + 0.509209, + 0.503958, + 0.51925, + 0.501958, + 0.496, + 0.639083, + 0.507625, + 0.505625, + 0.500791, + 0.487459, + 0.505791, + 0.501667, + 0.495292, + 0.488041, + 0.509584, + 0.506208, + 0.49575, + 0.497042, + 0.48525, + 0.485083, + 0.488709, + 0.484292, + 0.541042, + 0.517, + 0.510625, + 0.51075, + 0.500208, + 0.490875, + 1.852375, + 0.604584, + 0.491125, + 0.494583, + 0.493792, + 0.481, + 0.503, + 0.491958, + 0.485542, + 0.490708, + 0.487625, + 0.49025, + 0.490625, + 0.492917 ] }, { - "p50_ms": 0.46225, - "p95_ms": 0.498708, - "process_max_rss_bytes": 3700948992, - "process_rss_bytes": 518815744, + "p50_ms": 0.501875, + "p95_ms": 0.637791, + "process_max_rss_bytes": 3106045952, + "process_rss_bytes": 383827968, "response_bytes": { "max": 2877, "min": 2877 }, "samples_ms": [ - 0.472333, - 0.468583, - 0.514875, 0.497209, - 0.511334, - 0.616541, - 0.491333, - 0.471666, - 0.457833, - 0.457917, - 0.482291, - 0.494166, - 0.463333, - 0.4625, - 0.455666, + 0.482375, + 0.503791, + 0.501875, + 0.529125, + 0.662875, + 0.515167, + 0.512083, + 0.48975, + 0.492208, + 0.477709, + 0.487125, 0.487375, - 0.491208, - 0.464833, - 0.464666, - 0.465375, - 0.478459, - 0.474083, - 0.468625, - 0.484, - 0.505792, - 0.462875, - 0.47175, - 0.472125, - 0.47925, - 0.468334, - 0.462292, - 0.456333, - 0.447959, - 0.466542, - 0.448958, - 0.456, - 0.452958, - 0.449625, - 0.464333, - 0.46075, - 0.454667, - 0.459708, - 0.453666, - 0.450875, - 0.455959, - 0.448542, - 0.467416, - 0.461417, - 0.454208, - 0.476959, - 0.498708, - 0.453458, - 0.451, - 0.455416, - 0.455291, - 1.4705, - 0.465125, - 0.455208, - 0.461042, - 0.466042, - 0.458833, - 0.464542, - 0.489167, - 0.446417, - 0.454542, - 0.448625, - 0.481667, - 0.493209, - 0.466833, - 0.478792, - 0.484042, - 0.474083, - 0.461791, - 0.451833, - 0.46075, - 0.458209, - 0.44875, - 0.445583, - 0.453583, - 0.451417, - 0.475292, - 0.465334, - 0.449291, - 0.4615, - 0.4525, - 0.449042, - 0.454125, - 0.449833, - 0.46225, - 0.458125, - 0.448584, - 0.4475, - 0.44825, - 0.450208, - 0.484542, - 0.467042, - 0.47825, - 0.452417, - 0.46975, - 0.449542 + 0.501, + 0.501958, + 0.510042, + 0.482042, + 0.479209, + 0.65525, + 0.617458, + 0.564625, + 0.513708, + 0.607125, + 0.58125, + 0.552083, + 0.522334, + 0.509542, + 0.503625, + 0.504667, + 0.507208, + 0.498666, + 0.493375, + 0.505292, + 0.4935, + 0.494833, + 0.506125, + 0.500833, + 0.750708, + 0.543167, + 0.504292, + 0.494834, + 0.483208, + 0.479666, + 0.492083, + 0.493791, + 0.489334, + 0.485417, + 0.602292, + 0.637791, + 0.567041, + 0.548042, + 0.515834, + 0.50275, + 0.496417, + 0.495084, + 0.489125, + 0.812667, + 0.607958, + 0.511917, + 0.505542, + 0.487791, + 0.496, + 0.50675, + 0.491416, + 1.705708, + 0.58475, + 0.547, + 0.509542, + 0.493708, + 0.532083, + 0.49175, + 0.563458, + 0.569417, + 0.582125, + 0.563291, + 0.500041, + 0.506125, + 0.572, + 0.501334, + 0.484625, + 0.483458, + 0.484709, + 0.475792, + 0.4785, + 0.482125, + 0.487084, + 0.475959, + 0.487375, + 0.53775, + 0.548291, + 0.50725, + 0.498792, + 0.499625, + 0.493834, + 0.534958, + 0.499917, + 0.501125, + 0.488958, + 0.499375, + 0.489709 ] }, { - "p50_ms": 0.450542, - "p95_ms": 0.494666, - "process_max_rss_bytes": 3700948992, - "process_rss_bytes": 518881280, + "p50_ms": 0.491458, + "p95_ms": 0.62475, + "process_max_rss_bytes": 3106045952, + "process_rss_bytes": 383893504, "response_bytes": { "max": 2877, "min": 2877 }, "samples_ms": [ - 0.471417, - 0.469625, - 0.462042, - 0.480042, - 0.445708, - 0.445625, - 0.455459, - 0.470166, - 0.4465, - 0.46, - 0.450667, - 0.452166, - 0.467834, + 0.487584, + 0.48825, + 0.488125, + 0.512, + 0.498958, + 0.480875, + 0.486958, + 0.5065, + 0.55, + 0.690459, + 0.603958, + 0.499583, + 0.508583, + 0.507792, + 0.488375, + 0.503291, + 0.478833, + 0.478875, + 0.489375, + 0.482333, + 0.482958, + 0.497667, 0.494666, - 0.448708, - 0.462584, - 0.455333, - 0.448125, - 0.447084, - 0.453125, - 0.461, - 0.480333, - 0.474875, - 0.597334, - 0.488417, - 0.448666, - 0.44575, - 0.466125, - 0.450916, - 0.474167, - 0.451875, - 0.460417, - 0.442959, - 1.500959, - 0.482584, - 0.477375, - 0.477584, - 0.513167, - 0.564, - 0.515084, - 0.459167, - 0.443916, - 0.444708, - 0.462958, - 0.453042, - 0.443334, - 0.441708, - 0.436833, - 0.452042, - 0.452959, - 0.447334, - 0.448625, - 0.44075, - 0.445416, - 0.449083, - 0.442375, - 0.47425, - 0.4745, - 0.453792, - 0.443709, - 0.441167, - 0.437625, - 0.4555, - 0.449084, - 0.456292, - 0.449125, - 0.462916, - 0.45025, - 0.448833, - 0.447, - 0.446958, - 0.456583, - 0.450542, - 0.490292, - 0.465833, - 0.444583, - 0.4565, - 0.44175, - 0.441125, - 0.437792, - 0.443583, - 0.452792, - 0.469542, - 0.45425, - 0.435417, - 0.441167, - 0.435791, - 0.440542, - 0.435042, - 0.438125, - 0.468417, - 0.443541, - 0.444917, - 0.4365, - 0.447292, - 0.43425, - 0.441833, - 0.440625, - 0.442416, - 0.4375 + 0.662458, + 0.485917, + 0.460375, + 0.464125, + 0.469125, + 0.57425, + 0.684875, + 0.526083, + 0.491458, + 0.492709, + 0.476541, + 0.499458, + 0.500084, + 0.504875, + 0.503042, + 0.590875, + 0.549875, + 0.476542, + 0.463375, + 0.476167, + 1.677125, + 0.490209, + 0.588583, + 0.676084, + 0.513875, + 0.545167, + 0.497458, + 0.480833, + 0.471709, + 0.469333, + 0.484083, + 0.473416, + 0.482, + 0.492875, + 0.474542, + 0.474292, + 0.46975, + 0.486791, + 0.483375, + 0.501792, + 0.477709, + 0.478875, + 0.62475, + 0.612167, + 0.5035, + 0.493167, + 0.486667, + 0.484, + 0.482209, + 0.517875, + 0.508834, + 0.479833, + 0.479458, + 0.475333, + 0.481167, + 0.471209, + 0.476708, + 0.528375, + 0.586209, + 0.517709, + 0.503834, + 0.485875, + 0.497292, + 0.574292, + 0.613416, + 0.523, + 0.578542, + 0.529291, + 0.501292, + 0.474583, + 0.486792, + 0.484334, + 0.4825, + 0.475708, + 0.516042, + 0.493, + 0.474333 ] }, { - "p50_ms": 0.453459, - "p95_ms": 0.4945, - "process_max_rss_bytes": 3700948992, - "process_rss_bytes": 518995968, + "p50_ms": 0.474166, + "p95_ms": 0.538083, + "process_max_rss_bytes": 3106045952, + "process_rss_bytes": 384073728, "response_bytes": { "max": 2877, "min": 2877 }, "samples_ms": [ - 0.440667, - 0.443834, - 0.476625, - 0.46425, - 0.458875, - 0.461833, - 0.456292, - 0.459625, - 0.447083, - 0.457584, - 0.44425, - 0.480458, - 1.654625, - 0.546542, - 0.4815, - 0.468125, - 0.432459, - 0.4735, - 0.432209, - 0.43025, - 0.417916, - 0.430667, - 0.449042, - 0.452, - 0.446375, - 0.451833, - 0.451167, - 0.444084, - 0.460208, - 0.4945, - 0.45375, 0.46, - 0.448417, - 0.443416, - 0.441125, - 0.444667, - 0.451917, - 0.45775, - 0.479292, - 0.501458, - 0.456583, - 0.453292, - 0.472708, - 0.454791, - 0.441875, - 0.448959, - 0.458959, - 0.443625, - 0.44125, - 0.464291, - 0.47375, - 0.475333, - 0.448, - 0.457375, - 0.444208, - 0.440875, - 0.444709, - 0.450708, + 0.487375, + 0.476959, + 0.466334, + 0.473542, + 0.461875, + 0.463583, + 0.4995, + 0.470916, + 0.4595, + 0.464666, + 0.46425, + 0.476542, + 0.46925, + 0.463083, + 0.471375, + 0.591916, + 0.78625, + 0.538083, + 0.513291, + 0.492542, + 1.634709, + 0.49375, + 0.476125, + 0.480875, + 0.475875, + 0.46375, + 0.471458, + 0.486959, + 0.474667, + 0.472417, + 0.486875, + 0.471625, + 0.476291, + 0.649542, + 0.502834, + 0.486375, + 0.493208, + 0.49675, + 0.51975, + 0.477542, + 0.478042, + 0.490958, + 0.49075, + 0.480042, + 0.464417, + 0.461167, + 0.46525, + 0.467041, + 0.484125, + 0.490792, + 0.488625, + 0.475792, + 0.476542, + 0.468583, + 0.470667, + 0.462791, + 0.471083, + 0.46675, + 0.468083, + 0.46975, + 0.466083, + 0.470875, + 0.684833, + 0.513834, + 0.487417, + 0.496916, + 0.4975, + 0.476209, + 0.477958, + 0.484334, + 0.469, + 0.467333, + 0.498542, + 0.47275, + 0.481292, + 0.483333, + 0.481125, + 0.465167, + 0.484959, + 0.478375, + 0.4605, + 0.462459, + 0.468291, + 0.466666, + 0.462042, + 0.465833, 0.464125, - 0.457583, - 0.460875, - 0.435833, - 0.440708, - 0.453459, - 0.440083, - 0.437083, - 0.460708, - 0.471583, - 0.461, - 0.4565, - 0.473708, - 0.442459, - 0.446333, - 0.44, - 0.44125, - 0.493583, - 0.461041, - 0.477667, - 0.465458, - 0.479083, - 0.455708, - 0.465916, - 0.445875, - 0.438917, - 0.442375, - 0.442292, - 0.441709, - 0.449375, - 0.43525, - 0.438125, - 0.459958, - 0.468875, - 0.443625, - 0.495375, - 0.485583, - 0.456791, - 1.690583, - 0.454625, - 0.439458, - 0.445834 + 0.469834, + 0.469125, + 0.494333, + 0.468542, + 0.474166, + 0.462417, + 0.474584, + 0.458041, + 0.4615, + 0.4625, + 0.4685, + 0.45925 ] }, { - "p50_ms": 0.440834, - "p95_ms": 0.4785, - "process_max_rss_bytes": 3700948992, - "process_rss_bytes": 519045120, + "p50_ms": 0.46775, + "p95_ms": 0.536, + "process_max_rss_bytes": 3106045952, + "process_rss_bytes": 384122880, "response_bytes": { "max": 2877, "min": 2877 }, "samples_ms": [ - 0.435125, - 0.436083, - 0.43325, - 0.438334, - 0.47325, - 0.442459, - 0.4565, - 0.451709, - 0.443208, - 0.45, - 0.444208, - 0.453042, - 0.441042, - 0.442166, - 0.437416, - 0.4375, - 0.434166, - 0.435584, - 0.459208, - 0.432584, - 0.431958, - 0.446417, - 0.463708, - 0.450959, - 0.442458, - 0.442958, - 0.460125, - 0.432667, - 0.434917, - 0.438791, - 0.441042, - 0.4745, - 0.435334, - 0.4375, - 0.457125, - 0.450083, - 0.431542, - 0.457, - 0.440834, - 0.43875, - 0.430333, - 0.435459, - 0.466042, - 0.450708, - 0.439375, - 0.445, - 0.456625, - 0.441834, - 0.44425, - 0.456584, - 0.441709, - 0.434709, - 0.449334, - 0.430625, - 0.435625, - 0.435, - 0.439625, - 0.446791, - 0.438375, - 0.436334, - 0.463917, - 0.440834, - 0.431083, - 0.44375, - 0.455041, - 0.433542, - 0.452292, - 0.432083, - 0.431375, - 0.444708, - 0.456708, - 0.437791, - 0.433667, - 0.427834, - 0.436417, - 1.616959, - 0.523083, - 0.4785, - 0.436875, - 0.426375, - 0.444083, - 0.490125, - 0.473166, - 0.490917, - 0.500041, - 0.447083, - 0.471583, + 1.84275, + 0.536, + 0.486625, + 0.474958, + 0.470625, + 0.468209, + 0.466833, + 0.46075, + 0.465125, + 0.458917, + 0.463958, + 0.459375, + 0.459833, + 0.489042, + 0.492208, + 0.488834, + 0.466125, + 0.462125, + 0.466791, + 0.464792, + 0.463583, + 0.5055, + 0.508875, + 0.4965, + 0.535666, + 0.603584, + 0.50925, + 0.478875, + 0.484583, + 0.475167, + 0.491125, + 0.501708, + 0.46775, + 0.471166, + 0.478458, + 0.463333, + 0.45525, + 0.476541, + 0.469166, + 0.460584, + 0.458709, + 0.47825, + 0.467541, 0.455958, - 0.440375, - 0.436417, - 0.432167, - 0.431542, - 0.432916, - 0.4375, - 0.447459, - 0.433541, - 0.433584, - 0.430625, - 0.432625, - 0.430834 + 0.456917, + 0.4555, + 0.462, + 0.464, + 0.462333, + 0.463916, + 0.453167, + 0.4585, + 0.455, + 0.451584, + 0.454459, + 0.448583, + 0.457708, + 0.448875, + 0.459875, + 0.465375, + 0.469084, + 0.462875, + 0.474375, + 0.592833, + 0.669084, + 0.502542, + 0.50275, + 0.474334, + 0.474541, + 0.474375, + 0.485333, + 0.467042, + 0.468292, + 0.475791, + 0.47425, + 0.490375, + 0.464, + 0.474375, + 0.461875, + 0.460375, + 0.458916, + 0.480625, + 0.478334, + 0.512, + 1.731375, + 0.527333, + 0.498709, + 0.4675, + 0.463042, + 0.459125, + 0.462458, + 0.461333, + 0.45925, + 0.456541, + 0.464333, + 0.474083, + 0.458042, + 0.473791, + 0.531167, + 0.500709 ] } ] diff --git a/test/integration/graph_symbols_test.go b/test/integration/graph_symbols_test.go new file mode 100644 index 00000000..98da71e4 --- /dev/null +++ b/test/integration/graph_symbols_test.go @@ -0,0 +1,89 @@ +//go:build integration + +package integration + +import ( + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "testing" + + "github.com/balcsida/graphnest/internal/authn" + "github.com/balcsida/graphnest/internal/graphartifact" + "github.com/balcsida/graphnest/internal/graphquery" + "github.com/balcsida/graphnest/internal/graphservice" + "github.com/balcsida/graphnest/internal/httpapi" + "github.com/balcsida/graphnest/internal/mcpserver" + "github.com/balcsida/graphnest/internal/postgres" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// TestGraphSymbolToolsRESTAndMCP serves the real CodeGraph fixture from +// PostgreSQL and checks that REST and MCP return the same name-addressed +// answers, and that a repository outside the caller's grant stays hidden. +func TestGraphSymbolToolsRESTAndMCP(t *testing.T) { + path := os.Getenv("GRAPHNEST_TEST_CODEGRAPH_V2_FIXTURE") + if path == "" { + t.Skip("set GRAPHNEST_TEST_CODEGRAPH_V2_FIXTURE to the exported real oracle artifact") + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + artifact, err := graphartifact.ParseV2(data, graphartifact.Limits{}) + if err != nil { + t.Fatal(err) + } + h := newPostgresHarness(t) + repositoryID := h.seedRepository(t, 10, 101) + setGraphCommit(t, h, repositoryID, artifact.Commit) + if _, err := h.store.ReplaceGraphV2(t.Context(), repositoryID, postgres.GraphPublication{Publisher: "symbol-contract"}, artifact); err != nil { + t.Fatal(err) + } + h.seedRepository(t, 10, 202) + authenticator := authn.NewStatic(map[string]authn.Principal{"user": {InstallationID: 10, RepositoryIDs: []int64{101}}}) + service := &graphservice.Service{Store: h.store, Backend: &graphquery.Service{Store: h.store}, Limits: graphservice.Limits{MaxResponseBytes: 256 << 10}} + mux := http.NewServeMux() + httpapi.RegisterGraphQueries(mux, authenticator, service, 64<<10, 256<<10) + mux.Handle("/mcp", httpapi.AuthenticateBearer(authenticator, mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return mcpserver.NewWithLimits(mcpserver.Services{Graph: service}, mcpserver.Limits{MaxOutputBytes: 256 << 10, GraphMaxOutputBytes: 256 << 10}) + }, nil))) + server := httptest.NewServer(mux) + defer server.Close() + + for _, test := range []struct { + rest, tool string + input map[string]any + related int + }{ + {"callers", "graph_callers", map[string]any{"repo": 101, "symbol": "normalize", "file": "core.ts"}, 6}, + {"callees", "graph_callees", map[string]any{"repo": "acme/repo-101", "symbol": "run"}, 3}, + {"impact-radius", "graph_impact_radius", map[string]any{"repo": 101, "symbol": "normalize", "file": "core.ts", "depth": 1}, 0}, + } { + rest := graphREST(t, server, test.rest, test.input) + if mcpValue := graphMCP(t, server, test.tool, test.input); !reflect.DeepEqual(rest, mcpValue) { + t.Fatalf("%s REST/MCP mismatch:\nREST: %#v\nMCP: %#v", test.rest, rest, mcpValue) + } + result := rest.(map[string]any) + definitions := result["definitions"].([]any) + if result["status"] != "ok" || len(definitions) != 1 { + t.Fatalf("%s=%#v", test.rest, result) + } + definition := definitions[0].(map[string]any) + related, _ := definition["related"].([]any) + entities, _ := definition["entities"].([]any) + if test.related > 0 && len(related) != test.related || test.rest == "impact-radius" && len(entities) != 7 { + t.Fatalf("%s definition=%#v", test.rest, definition) + } + } + request := httptest.NewRequest(http.MethodPost, "/v1/graph/callers", strings.NewReader(`{"repo":202,"symbol":"normalize"}`)) + request.Header.Set("Authorization", "Bearer user") + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + mux.ServeHTTP(recorder, request) + if recorder.Code != http.StatusNotFound { + t.Fatalf("unauthorized repository=%d %s", recorder.Code, recorder.Body.String()) + } +} diff --git a/test/parity/README.md b/test/parity/README.md index 4c156dbb..9450502c 100644 --- a/test/parity/README.md +++ b/test/parity/README.md @@ -56,7 +56,7 @@ is exercised by real `searchNodes`/`findRelevantContext` answers. Hashes cover t database, source configuration, sources, schema and expected answers. SQLite physical layout is not the determinism contract; complete logical rows are. -`expected.json` records ordered SQL facts. `library-expected.json` records 41 +`expected.json` records ordered SQL facts. `library-expected.json` records 52 actual producer query/workflow answers. The manifest lists their exact task IDs. The first 13 cover library search, callers/callees, call graph, hierarchy, usage, impact, path, dependencies, context and source reads. The additional answers run @@ -65,6 +65,7 @@ trail services directly; they do not simulate those implementations. | Workflow | Oracle task IDs and independently checked evidence | | --- | --- | +| Symbol tools | `mcp-callers-*`, `mcp-callees-*` and `mcp-impact-*` run the upstream `codegraph_callers`, `codegraph_callees` and `codegraph_impact` handlers. They cover grouping of same-named definitions (`normalize`, `greet`, `identity`), `file` narrowing and its no-match fallback, qualified names (`Service.greet`), limits, depth, an absent symbol, and `via instantiation`/`via import` labels. | | Explore | `mcp-explore-source` includes verbatim `processGreeting` source. `mcp-explore-unmatched-fallback` records the pinned engine's unrelated fallback sources for an absent symbol; it is not a correct-match or no-results claim. | | Conditional flow and steps | `ui-flow-branch` records the `enabled` guard at consumer.ts:4; `ui-flow-missing` and `ui-flow-invalid` preserve absence/refusal. `ui-steps-branch` records the program fork; `ui-steps-screen` records conditional screen traversal. | | Navigation and maps | `ui-screens-navigation` records `/` to `/details` with the `enabled` guard. `ui-map-modules` records concrete cross-module imports. | diff --git a/test/parity/reference.mjs b/test/parity/reference.mjs index 4ebdfa44..129abad4 100644 --- a/test/parity/reference.mjs +++ b/test/parity/reference.mjs @@ -63,6 +63,32 @@ try { assert.deepEqual(graph.getNodesByName('MissingFixtureSymbol987'), []); assert.ok(!answers['mcp-explore-source'].isError); assert.ok(answers['mcp-explore-source'].content.some(item => item.text?.includes("return 'skipped'"))); + // Symbol-addressed MCP tools: name resolution, per-definition grouping, file narrowing and bounds. + const mcp = { + 'mcp-callers-grouped': ['codegraph_callers', { symbol: 'normalize' }], + 'mcp-callers-file': ['codegraph_callers', { symbol: 'normalize', file: 'core.ts' }], + 'mcp-callers-file-miss': ['codegraph_callers', { symbol: 'normalize', file: 'missing.ts' }], + 'mcp-callers-limit': ['codegraph_callers', { symbol: 'normalize', file: 'core.ts', limit: 1 }], + 'mcp-callers-qualified': ['codegraph_callers', { symbol: 'Service.greet' }], + 'mcp-callers-missing': ['codegraph_callers', { symbol: 'MissingFixtureSymbol987' }], + 'mcp-callees-single': ['codegraph_callees', { symbol: 'run' }], + 'mcp-callees-grouped': ['codegraph_callees', { symbol: 'greet' }], + 'mcp-impact-file': ['codegraph_impact', { symbol: 'normalize', file: 'core.ts' }], + 'mcp-impact-depth': ['codegraph_impact', { symbol: 'normalize', file: 'core.ts', depth: 1 }], + 'mcp-impact-grouped': ['codegraph_impact', { symbol: 'identity' }], + }; + for (const [id, [name, args]] of Object.entries(mcp)) { + answers[id] = await tool.execute(name, { ...args, projectPath: root }); + assert.ok(!answers[id].isError, id); + } + const text = id => answers[id].content.map(item => item.text).join('\n'); + assert.match(text('mcp-callers-grouped'), /3 distinct definitions/); + assert.match(text('mcp-callers-file'), /greet \(method\) - core\.ts:8/); + assert.match(text('mcp-callers-file-miss'), /no definition of "normalize" matches file "missing\.ts"/); + assert.match(text('mcp-callers-qualified'), /Callers of Service\.greet/); + assert.match(text('mcp-callers-missing'), /not found in the codebase/); + assert.match(text('mcp-callees-single'), /normalize \(function\) - core\.ts:3/); + assert.match(text('mcp-impact-grouped'), /2 distinct definitions/); answers['ui-flow-branch'] = await buildFlow(graph, root, params({ from: 'processGreeting', to: 'run' })); answers['ui-flow-missing'] = await buildFlow(graph, root, params({ from: 'processGreeting', to: 'MissingFixtureSymbol987' })); answers['ui-flow-invalid'] = await refusal(() => buildFlow(graph, root, params({ from: 'run', to: 'run' })), 'bad-request');