ast-mcp-server 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,16 +6,42 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [0.9.1] — local release candidate, pending release (2026-08-16)
9
+ ## [0.10.0] — local release candidate, pending release (2026-08-19)
10
+
11
+ ### Added
12
+
13
+ - Added deterministic atomic `ast_explore` clusters, categorized omission metadata, opt-in compiler-authoritative call spines, and registered-handler parity across MCP and the batch CLI.
14
+ - Added read-only `ast_find_test_candidates` across MCP and `ast-tool run`, with incoming compiler authority, fail-closed incomplete evidence, whole-candidate pagination, and lossless final JSON/TOON parity.
10
15
 
11
16
  ### Fixed
12
17
 
13
- - Public-registry verification now creates and normalizes its harness-owned explicit canary cache root to owner-private `0700`, independent of the runner umask, before exercising the package's fail-closed SQLite storage boundary.
18
+ - Oversized `ast_explore` call-spine aggregates are omitted atomically instead of failing valid requests.
19
+ - SQLite capability fallback now preserves fresh compiler-backed reads while retaining failed memory-backed index observability. Stable capability and invalid-root fallbacks retry only after restart or policy change; transient storage failures continue to recover automatically.
20
+
21
+ ### Release status
22
+
23
+ - This local candidate is pending its exact-tree release matrix, CI, and the repository's separately authorized delivery sequence. It has not been published to npm, assigned to `next` or `latest`, tagged in Git, or released on GitHub; registry and GitHub readbacks remain authoritative.
24
+
25
+ ## [0.9.2] — local release candidate, pending release (2026-08-16)
26
+
27
+ ### Fixed
28
+
29
+ - Public-registry verification now derives both the consumer report and the release-preflight exact-key validator from one canonical SQLite-default gate contract, preventing duplicated fixtures from masking producer/validator drift while preserving fail-closed rejection of missing or unexpected gates.
14
30
 
15
31
  ### Release status
16
32
 
17
33
  - This local recovery candidate is pending exact-tree CI and the repository's separately authorized delivery sequence. It has not been published to npm, assigned to `next` or `latest`, tagged in Git, or released on GitHub; registry and GitHub readbacks remain authoritative.
18
34
 
35
+ ## [0.9.1] — published to `next`, verification failed, not promoted (2026-08-16)
36
+
37
+ ### Fixed
38
+
39
+ - Public-registry verification now creates and normalizes its harness-owned explicit canary cache root to owner-private `0700`, independent of the runner umask, before exercising the package's fail-closed SQLite storage boundary.
40
+
41
+ ### Release status
42
+
43
+ - npm published these immutable bytes under `next` with provenance and the expected Git commit. The public registry consumer passed every package, provenance, SQLite, mutation, and setup gate, including the corrected owner-private canary root; final release preflight then rejected the report because its duplicated exact-key schema still described the earlier memory-default gate set. Version `0.9.1` was never promoted to `latest`, tagged in Git, or released on GitHub.
44
+
19
45
  ## [0.9.0] — published to `next`, verification failed, not promoted (2026-08-16)
20
46
 
21
47
  ### Added
package/README.md CHANGED
@@ -25,16 +25,17 @@ Coding agents often fall back to two generic operations: read files as plain tex
25
25
 
26
26
  This MCP server gives the agent structural code tools in addition to generic file reads and writes. Under the hood, `ts-morph` uses the TypeScript compiler project model, so the server can reason about declarations and references as code rather than undifferentiated text.
27
27
 
28
- | Need | Structural operation | Returned scope |
29
- | ------------------------------ | ------------------------- | ---------------------------------------------------------- |
30
- | Read a bounded file | `ast_get_file` | Exact selected source lines, hashes, and bounded freshness |
31
- | Explore bounded context | `ast_explore` | Ranked selectors plus optional source and references |
32
- | Understand a file | `ast_get_outline` | Signatures without implementation bodies |
33
- | Inspect one declaration | `ast_get_symbol_source` | Exact source for one function, method, class, or type |
34
- | Find usages across the project | `ast_find_references` | Compiler-resolved reference locations |
35
- | Understand symbol impact | `ast_get_impact` | Bounded direct/transitive compiler-backed relationships |
36
- | Rename a symbol everywhere | `ast_rename_symbol` | A reviewed project-wide rename plan |
37
- | Change one implementation | `ast_replace_symbol_body` | A body-only plan that preserves the declaration |
28
+ | Need | Structural operation | Returned scope |
29
+ | ------------------------------ | -------------------------- | ----------------------------------------------------------- |
30
+ | Read a bounded file | `ast_get_file` | Exact selected source lines, hashes, and bounded freshness |
31
+ | Explore bounded context | `ast_explore` | Ranked selectors plus optional source and references |
32
+ | Understand a file | `ast_get_outline` | Signatures without implementation bodies |
33
+ | Inspect one declaration | `ast_get_symbol_source` | Exact source for one function, method, class, or type |
34
+ | Find usages across the project | `ast_find_references` | Compiler-resolved reference locations |
35
+ | Understand symbol impact | `ast_get_impact` | Bounded direct/transitive compiler-backed relationships |
36
+ | Select affected tests | `ast_find_test_candidates` | Whole candidate proofs from incoming compiler relationships |
37
+ | Rename a symbol everywhere | `ast_rename_symbol` | A reviewed project-wide rename plan |
38
+ | Change one implementation | `ast_replace_symbol_body` | A body-only plan that preserves the declaration |
38
39
 
39
40
  Reads can start with a bounded file slice, a compact outline, or exact source only for the declaration that needs inspection. Mutations are prepared in memory first, compared against baseline diagnostics, and returned as immutable, hash-bound plans. Nothing is written until the caller reviews and explicitly applies the plan.
40
41
 
@@ -46,6 +47,7 @@ Reads can start with a bounded file slice, a compact outline, or exact source on
46
47
  - Use `ast_get_outline` for a compact body-free view of a known file without source lines.
47
48
  - Use `ast_get_symbol_source` when one declaration or implementation is the required evidence.
48
49
  - Use `ast_get_impact` when the exact symbol is known and bounded direct/transitive compiler relationships are needed; it is read-only evidence, not a mutation plan.
50
+ - Use `ast_find_test_candidates` when an exact symbol should map to conservative test candidates. It forces incoming compiler traversal, returns complete relationship paths, and never executes tests.
49
51
 
50
52
  `snapshot_state: "fresh"` means that the returned file bytes match the synchronized compiler snapshot. The separate `freshness` object describes the project/session state and preserves causes such as source changes or watcher failure. Neither field means that the project has zero TypeScript diagnostics; use `ast_get_diagnostics` for compiler errors and warnings.
51
53
 
@@ -62,7 +64,7 @@ The server exposes evidence labels instead of collapsing every result into an un
62
64
 
63
65
  Freshness is orthogonal to TypeScript diagnostics. `fresh` means the evidence matches the synchronized snapshot; `pending`, `rebuilding`, `stale`, or `degraded` means the response must not be presented as current compiler evidence. Read tools expose the state, causes (`source_change`, `config_change`, `index_failure`, `watcher_failure`, or `compiler_rebuild`), and bounded `checked_at` timestamp. `ast_get_impact` refuses non-fresh compiler relationships. `ast_explore` returns the state together with `completeness`, `unresolved`, `budget`, and `truncation` metadata rather than silently dropping evidence.
64
66
 
65
- All reads are budgeted. Callers control pagination and, where applicable, `max_bytes`, `reference_limit`, `max_depth`, `max_nodes`, and `max_edges`; responses report the effective limits and whether a record, byte, depth, edge, invocation, or serialization limit truncated the result. A truncated or unresolved result is incomplete evidence, not an empty negative result. The internal test-candidate resolver follows the same rule: it accepts only fresh, exact compiler-backed impact, emits direct/transitive evidence and bounded relationship IDs, and never executes tests or guesses from filenames alone.
67
+ All reads are budgeted. Callers control pagination and, where applicable, `max_bytes`, `reference_limit`, `max_depth`, `max_nodes`, and `max_edges`; responses report the effective limits and whether a record, byte, depth, edge, invocation, or serialization limit truncated the result. A truncated or unresolved result is incomplete evidence, not an empty negative result. `ast_find_test_candidates` follows the same rule: it accepts only fresh, exact compiler-backed impact, emits direct/transitive evidence and bounded relationship IDs, and never executes tests or guesses from filenames alone. Only a complete authoritative traversal may return `candidates: []` with `proven_empty: true`.
66
68
 
67
69
  ## Why this helps
68
70
 
@@ -82,11 +84,11 @@ The included batch benchmark records a 50% reduction in model round-trips and a
82
84
 
83
85
  ## Supported environment and trust boundary
84
86
 
85
- The local `0.9.1` release candidate requires Node.js `>=22.13.0`; its evidence matrix targets exact Node.js 22.13.0 and the current Node.js 24 line. Published v0.8.1 retains its immutable historical Node.js 22.5.0/24 evidence. Managed setup-file publication additionally requires GNU coreutils 9.7 `mv` supporting `--update=none-fail`, `--exchange`, `--no-copy`, and `--no-target-directory`, GNU coreutils `ln -L -T`, procfs descriptor paths at `/proc/self/fd`, and `O_DIRECTORY`/`O_NOFOLLOW`. Other Linux architectures or systems without those filesystem primitives, macOS, and Windows remain unverified.
87
+ The local `0.10.0` release candidate requires Node.js `>=22.13.0`; its evidence matrix targets exact Node.js 22.13.0 and the current Node.js 24 line. Published v0.8.1 retains its immutable historical Node.js 22.5.0/24 evidence. Managed setup-file publication additionally requires GNU coreutils 9.7 `mv` supporting `--update=none-fail`, `--exchange`, `--no-copy`, and `--no-target-directory`, GNU coreutils `ln -L -T`, procfs descriptor paths at `/proc/self/fd`, and `O_DIRECTORY`/`O_NOFOLLOW`. Other Linux architectures or systems without those filesystem primitives, macOS, and Windows remain unverified.
86
88
 
87
89
  This is a local stdio server. It runs with the invoking user's filesystem permissions, and clients may request any `project_root` that user can access. It does not provide HTTP authentication, sandboxing, tenant isolation, or a remote-service security boundary. Remote, untrusted, and multi-tenant operation is unsupported.
88
90
 
89
- In the local `0.9.1` release candidate, an absent `AST_SYMBOL_INDEX_PERSISTENCE` or explicit `enabled` selects the private SQLite symbol-index cache. `disabled` is the immediate memory-only rollback. `canary` requires an explicit absolute normalized `AST_SYMBOL_INDEX_CACHE_ROOT`. Invalid policy or storage fails closed to compiler-authoritative memory reads with bounded path-free status.
91
+ In the local `0.10.0` release candidate, an absent `AST_SYMBOL_INDEX_PERSISTENCE` or explicit `enabled` selects the private SQLite symbol-index cache. `disabled` is the immediate memory-only rollback. `canary` requires an explicit absolute normalized `AST_SYMBOL_INDEX_CACHE_ROOT`. Invalid policy or storage fails closed to compiler-authoritative memory reads with bounded path-free status.
90
92
 
91
93
  The default cache root is selected from `AST_SYMBOL_INDEX_CACHE_ROOT`, then `XDG_CACHE_HOME`, then `HOME`. Inspect or clear only derived cache artifacts through the bounded CLI:
92
94
 
@@ -298,6 +300,7 @@ Project-scoped tools accept `project_root`, either the project directory or an e
298
300
  | `ast_search_symbols` | Paginated structural symbol discovery | No |
299
301
  | `ast_find_references` | Compiler-resolved references with bounded context | No |
300
302
  | `ast_get_impact` | Bounded incoming/outgoing compiler-backed impact evidence | No |
303
+ | `ast_find_test_candidates` | Paginated affected tests with atomic compiler relationship proofs | No |
301
304
  | `ast_get_diagnostics` | Project- or file-scoped TypeScript diagnostics | No |
302
305
  | `ast_rename_symbol` | Prepare a project-wide rename | No |
303
306
  | `ast_replace_symbol_body` | Prepare a body-only replacement while preserving the signature | No |
@@ -307,7 +310,9 @@ Project-scoped tools accept `project_root`, either the project directory or an e
307
310
 
308
311
  Read results use project-relative paths, deterministic ordering, structured MCP output, and pagination where result sets can grow with the project.
309
312
 
310
- `ast_explore` supports query, exact file, and exact symbol routes. Its default `summary` profile returns bounded reusable selectors; `context` adds selected source and `full` adds compiler references. Every response reports freshness, completeness, truncation, unresolved selectors, record limits, and a serialized byte budget. Use the primitive tools when a single exact operation is clearer or when preparing a mutation.
313
+ `ast_explore` supports query, exact file, and exact symbol routes. Its default `summary` profile returns bounded reusable selectors; `context` adds selected source and `full` adds compiler references. Whole symbol clusters are admitted under the caller's `max_bytes` ceiling, so source, reference records, and call paths are never sliced. `omissions` classifies withheld components as `budget`, `incomplete`, or `untrusted`, and any requested omission keeps completeness false.
314
+
315
+ Exact `file_path` plus `symbol_path` requests may opt into bounded static `call_spines`. Only fresh, exact, compiler-resolved invocation sites qualify; generic references, dynamic dispatch, and runtime behavior are not inferred. Absence of `call_spines` performs no call traversal. Every response still reports freshness, completeness, truncation, unresolved selectors, record limits, and canonical serialized-byte accounting. Use the primitive tools when a single exact operation is clearer or when preparing a mutation. See [ADR 0013](docs/adr/0013-ast-explore-presentation.md).
311
316
 
312
317
  Symbol search is relevance-ranked and defaults to at most 20 `summary` records containing `file`, a directly reusable `selector`, `kind`, and body-free `signature`. Request `detail: "selectors"` for routing coordinates only, or `detail: "full", limit: 100` for the v0.4.0 fields/page. References default to `detail: "locations"`; request `detail: "context"` only when the bounded source line is needed.
313
318
 
@@ -357,6 +362,8 @@ Example search-to-source pipeline:
357
362
 
358
363
  A `$ref` is an RFC 6901 JSON Pointer rooted at prior step results. References cannot point forward. If `emit` is omitted, only the final step result is returned; intermediate results remain inside the process.
359
364
 
365
+ `ast_find_test_candidates` and `ast_explore` are admitted as read steps. The batch runner injects the pipeline `project_root`, rejects a conflicting step root, and invokes the same registered MCP implementation. Candidate relationship proofs and exploration clusters remain whole; final JSON and TOON differ only in serialization, not logical evidence.
366
+
360
367
  ### Bounded foreach
361
368
 
362
369
  ```json
package/SECURITY.md CHANGED
@@ -11,7 +11,7 @@ Security fixes are provided for the exact version currently identified by the np
11
11
  | Earlier npm versions | No |
12
12
  | Unpublished release candidates | No |
13
13
 
14
- This project supports trusted single-user local stdio use; the supported operating-system and architecture boundary remains Linux x64 only. The local `0.9.1` release candidate requires Node.js `>=22.13.0`, with evidence targeted at exact Node.js 22.13.0 and the governed Node.js 24 major. Published v0.8.1 retains its immutable Node.js 22.5.0/24 release matrix. Managed setup mutation requires GNU coreutils 9.7 `mv --update=none-fail --exchange --no-copy --no-target-directory`, GNU coreutils `ln -L -T`, and procfs descriptor-relative paths at `/proc/self/fd` with `O_DIRECTORY`/`O_NOFOLLOW`. The candidate's default SQLite cache also relies on Linux ownership, mode, no-follow, link-count and inode-identity checks; it does not defend against a malicious same-UID process. Other Linux architectures and systems without those primitives are unverified, as are macOS and Windows. Remote, untrusted, and multi-tenant use is outside the supported security boundary.
14
+ This project supports trusted single-user local stdio use; the supported operating-system and architecture boundary remains Linux x64 only. The local `0.10.0` release candidate requires Node.js `>=22.13.0`, with evidence targeted at exact Node.js 22.13.0 and the governed Node.js 24 major. Published v0.8.1 retains its immutable Node.js 22.5.0/24 release matrix. Managed setup mutation requires GNU coreutils 9.7 `mv --update=none-fail --exchange --no-copy --no-target-directory`, GNU coreutils `ln -L -T`, and procfs descriptor-relative paths at `/proc/self/fd` with `O_DIRECTORY`/`O_NOFOLLOW`. The candidate's default SQLite cache also relies on Linux ownership, mode, no-follow, link-count and inode-identity checks; it does not defend against a malicious same-UID process. Other Linux architectures and systems without those primitives are unverified, as are macOS and Windows. Remote, untrusted, and multi-tenant use is outside the supported security boundary.
15
15
 
16
16
  ## Reporting a vulnerability
17
17
 
@@ -14,6 +14,8 @@ export const READ_BATCH_TOOLS = [
14
14
  "ast_get_symbol_source",
15
15
  "ast_search_symbols",
16
16
  "ast_find_references",
17
+ "ast_find_test_candidates",
18
+ "ast_explore",
17
19
  "ast_get_diagnostics",
18
20
  ];
19
21
  export const PREPARE_BATCH_TOOLS = [
package/dist/server.js CHANGED
@@ -3,6 +3,7 @@ import { McpServer, } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { PublicOperationalError } from "./services/public-errors.js";
4
4
  import { registerApplyOperation } from "./tools/apply_operation.js";
5
5
  import { registerFindReferences } from "./tools/find_references.js";
6
+ import { registerFindTestCandidates } from "./tools/find_test_candidates.js";
6
7
  import { registerGetDiagnostics } from "./tools/get_diagnostics.js";
7
8
  import { registerGetFile } from "./tools/get_file.js";
8
9
  import { registerGetImpact } from "./tools/get_impact.js";
@@ -47,6 +48,7 @@ export function createServer(options = {}) {
47
48
  registerSearchSymbols(server);
48
49
  registerFindReferences(server);
49
50
  registerGetImpact(server);
51
+ registerFindTestCandidates(server);
50
52
  registerGetDiagnostics(server);
51
53
  registerGetFile(server);
52
54
  registerRenameSymbol(server);
@@ -5,6 +5,7 @@ const EXPECTED_TOOLS = [
5
5
  "ast_get_symbol_source",
6
6
  "ast_search_symbols",
7
7
  "ast_find_references",
8
+ "ast_find_test_candidates",
8
9
  "ast_get_diagnostics",
9
10
  "ast_rename_symbol",
10
11
  "ast_replace_symbol_body",
@@ -0,0 +1,101 @@
1
+ const endpointKey = (endpoint) => [endpoint.file, endpoint.symbol_path, endpoint.selector].join("\u0000");
2
+ const sequenceKey = (values) => JSON.stringify(values);
3
+ const exactCall = (edge) => edge.kind === "call" &&
4
+ edge.provenance === "compiler" &&
5
+ edge.confidence === "exact" &&
6
+ edge.resolution === "resolved" &&
7
+ edge.freshness.state === "fresh" &&
8
+ edge.compiler_authoritative;
9
+ function assertLimit(value, minimum, name) {
10
+ if (!Number.isSafeInteger(value) || value < minimum)
11
+ throw new Error(`Call spine ${name} is invalid.`);
12
+ return value;
13
+ }
14
+ export function planCallSpines(root, edges, options) {
15
+ const maxDepth = assertLimit(options.max_depth, 0, "max_depth");
16
+ const maxNodes = assertLimit(options.max_nodes, 1, "max_nodes");
17
+ const maxEdges = assertLimit(options.max_edges, 1, "max_edges");
18
+ const accepted = edges.filter(exactCall);
19
+ const freshness = options.freshness?.state ?? edges[0]?.freshness.state;
20
+ const authorityState = freshness !== "fresh" || accepted.length !== edges.length
21
+ ? "untrusted"
22
+ : options.discovery_complete === false
23
+ ? "incomplete"
24
+ : "authoritative";
25
+ const reasons = new Set();
26
+ const rootKey = endpointKey(root);
27
+ const initial = { endpoint: root, endpoints: [root], relationshipIds: [], depth: 0 };
28
+ const records = new Map([[rootKey, initial]]);
29
+ const observedEdges = new Set();
30
+ let layer = [initial];
31
+ while (layer.length > 0) {
32
+ const candidates = new Map();
33
+ for (const current of layer.sort((left, right) => endpointKey(left.endpoint).localeCompare(endpointKey(right.endpoint)))) {
34
+ const incident = accepted
35
+ .filter((edge) => options.direction === "outgoing"
36
+ ? endpointKey(edge.source) === endpointKey(current.endpoint)
37
+ : endpointKey(edge.target) === endpointKey(current.endpoint))
38
+ .sort((left, right) => left.relationship_id.localeCompare(right.relationship_id));
39
+ for (const edge of incident) {
40
+ if (!observedEdges.has(edge.relationship_id) && observedEdges.size >= maxEdges) {
41
+ reasons.add("edge_limit");
42
+ continue;
43
+ }
44
+ observedEdges.add(edge.relationship_id);
45
+ const next = options.direction === "outgoing" ? edge.target : edge.source;
46
+ const key = endpointKey(next);
47
+ if (current.endpoints.some((endpoint) => endpointKey(endpoint) === key) || records.has(key))
48
+ continue;
49
+ if (current.depth >= maxDepth) {
50
+ reasons.add("depth_limit");
51
+ continue;
52
+ }
53
+ const outgoing = options.direction === "outgoing";
54
+ const candidate = {
55
+ endpoint: next,
56
+ endpoints: outgoing ? [...current.endpoints, next] : [next, ...current.endpoints],
57
+ relationshipIds: outgoing
58
+ ? [...current.relationshipIds, edge.relationship_id]
59
+ : [edge.relationship_id, ...current.relationshipIds],
60
+ depth: current.depth + 1,
61
+ };
62
+ const previous = candidates.get(key);
63
+ if (!previous ||
64
+ sequenceKey(candidate.relationshipIds) < sequenceKey(previous.relationshipIds))
65
+ candidates.set(key, candidate);
66
+ }
67
+ }
68
+ const ordered = [...candidates.values()].sort((left, right) => endpointKey(left.endpoint).localeCompare(endpointKey(right.endpoint)));
69
+ const available = maxNodes - records.size;
70
+ if (ordered.length > available)
71
+ reasons.add("node_limit");
72
+ layer = ordered.slice(0, Math.max(0, available));
73
+ for (const record of layer)
74
+ records.set(endpointKey(record.endpoint), record);
75
+ }
76
+ const paths = [...records.values()]
77
+ .filter((record) => record.depth > 0)
78
+ .sort((left, right) => left.depth - right.depth ||
79
+ endpointKey(left.endpoint).localeCompare(endpointKey(right.endpoint)))
80
+ .map((record) => ({
81
+ endpoint: record.endpoint,
82
+ endpoints: record.endpoints,
83
+ relationship_ids: record.relationshipIds,
84
+ }));
85
+ const incomplete = authorityState !== "authoritative" || reasons.size > 0;
86
+ return {
87
+ root,
88
+ direction: options.direction,
89
+ paths,
90
+ visited: {
91
+ nodes: records.size,
92
+ edges: observedEdges.size,
93
+ max_depth: Math.max(0, ...paths.map((path) => path.relationship_ids.length)),
94
+ },
95
+ budget: { max_depth: maxDepth, max_nodes: maxNodes, max_edges: maxEdges },
96
+ incomplete,
97
+ truncation_reasons: ["depth_limit", "node_limit", "edge_limit"].filter((reason) => reasons.has(reason)),
98
+ authority_state: authorityState,
99
+ empty_proven: paths.length === 0 && !incomplete,
100
+ };
101
+ }
@@ -1,15 +1,20 @@
1
- import { Buffer } from "node:buffer";
2
1
  import path from "node:path";
3
2
  import { buildFileOutline, nodeSourceWithLocation } from "./outline.js";
4
3
  import { paginate } from "./pagination.js";
4
+ import { resolveImpactRoot } from "./impact.js";
5
+ import { planCallSpines } from "./call-spines.js";
5
6
  import { isCooperativeInterruption, NO_REQUEST_CONTEXT, } from "./request-context.js";
6
7
  import { findDeclarationByName, getSourceFileOrThrow, reportSymbolIndexFailure, } from "./project.js";
7
8
  import { collectSymbolReferences } from "./references.js";
9
+ import { collectCompilerCallRelationships } from "./relationships.js";
10
+ import { presentExploreClusters } from "./explore-presentation.js";
8
11
  import { searchProjectSymbolsPage, searchProjectSymbolsPageWithIndex, sourceFileSymbols, symbolMatchRank, } from "./symbols.js";
9
12
  export const EXPLORE_DEFAULT_LIMIT = 20;
10
13
  export const EXPLORE_DEFAULT_REFERENCE_LIMIT = 20;
11
14
  export const EXPLORE_DEFAULT_MAX_BYTES = 64 * 1024;
12
15
  export const EXPLORE_MAX_BYTES = 1024 * 1024;
16
+ export const EXPLORE_DEFAULT_OMISSION_DETAIL_LIMIT = 20;
17
+ export const EXPLORE_MAX_OMISSION_DETAIL_LIMIT = 100;
13
18
  function matchesQuery(record, query) {
14
19
  const normalizedQuery = query.toLowerCase();
15
20
  return (record.name.toLowerCase().includes(normalizedQuery) ||
@@ -130,9 +135,6 @@ function projectSymbol(detail, record) {
130
135
  signature: record.signature,
131
136
  };
132
137
  }
133
- function jsonBytes(value) {
134
- return Buffer.byteLength(JSON.stringify(value), "utf8");
135
- }
136
138
  function effectiveExpansion(request) {
137
139
  return {
138
140
  source: request.includeSource ?? (request.detail === "context" || request.detail === "full"),
@@ -148,6 +150,27 @@ export async function buildExploreContext(context, request, requestContext = NO_
148
150
  const expansion = effectiveExpansion(request);
149
151
  const unresolved = [];
150
152
  const evidence = [];
153
+ let callSpines;
154
+ if (request.callSpines) {
155
+ if (!request.filePath || !request.symbolPath || routed.route !== "symbol") {
156
+ throw new Error("Call spines require an exact file_path and symbol_path.");
157
+ }
158
+ const freshness = {
159
+ state: effectiveContext.status.state,
160
+ causes: effectiveContext.status.causes,
161
+ checked_at: effectiveContext.status.lastSuccessfulSyncAt,
162
+ };
163
+ const root = resolveImpactRoot(effectiveContext.project, effectiveContext.projectRoot, { file_path: request.filePath, symbol_path: request.symbolPath }, requestContext);
164
+ const projected = collectCompilerCallRelationships(effectiveContext.project, effectiveContext.projectRoot, freshness, { max_edges: request.callSpines.maxEdges }, requestContext);
165
+ callSpines = planCallSpines(root, projected.edges, {
166
+ direction: request.callSpines.direction,
167
+ max_depth: request.callSpines.maxDepth,
168
+ max_nodes: request.callSpines.maxNodes,
169
+ max_edges: request.callSpines.maxEdges,
170
+ discovery_complete: !projected.incomplete,
171
+ freshness,
172
+ });
173
+ }
151
174
  for (const record of page.items) {
152
175
  requestContext.checkpoint();
153
176
  let sourceFile;
@@ -190,30 +213,66 @@ export async function buildExploreContext(context, request, requestContext = NO_
190
213
  }
191
214
  }
192
215
  const symbols = page.items.map((record) => projectSymbol(request.detail, record));
193
- let returnedSymbols = [...symbols];
194
- let returnedEvidence = [...evidence];
195
- let reason = page.has_more ? "record_limit" : null;
196
- let result = createResult(effectiveContext, request, routed.route, routed.file, routed.symbol, returnedSymbols, returnedEvidence, page.total, unresolved, reason);
197
- while (jsonBytes(result) > request.maxBytes &&
198
- (returnedEvidence.length > 0 || returnedSymbols.length > 0)) {
199
- requestContext.checkpoint();
200
- reason = "byte_limit";
201
- if (returnedEvidence.length > 0) {
202
- returnedEvidence = returnedEvidence.slice(0, -1);
216
+ const initialResult = createResult(effectiveContext, request, routed.route, routed.file, routed.symbol, symbols, evidence, page.total, unresolved, page.has_more ? "record_limit" : null);
217
+ const evidenceBySelector = new Map(evidence.map((item) => [item.selector, item]));
218
+ const omissions = unresolved.map((item) => ({
219
+ subject: item.selector,
220
+ category: "incomplete",
221
+ component: item.reason === "source_unresolved" ? "source" : "references",
222
+ reason: item.reason,
223
+ }));
224
+ for (const item of evidence) {
225
+ if (item.references?.has_more) {
226
+ omissions.push({
227
+ subject: item.selector,
228
+ category: "budget",
229
+ component: "references",
230
+ reason: "reference_limit",
231
+ });
203
232
  }
204
- else {
205
- returnedSymbols = returnedSymbols.slice(0, -1);
206
- const selectors = new Set(returnedSymbols.map((symbol) => symbol.selector));
207
- returnedEvidence = returnedEvidence.filter((item) => selectors.has(item.selector));
233
+ }
234
+ if (callSpines?.authority_state === "untrusted") {
235
+ omissions.push({
236
+ subject: callSpines.root.selector,
237
+ category: "untrusted",
238
+ component: "call_spine",
239
+ reason: "non_exact_evidence",
240
+ });
241
+ }
242
+ else if (callSpines?.authority_state === "incomplete") {
243
+ omissions.push({
244
+ subject: callSpines.root.selector,
245
+ category: "incomplete",
246
+ component: "call_spine",
247
+ reason: "call_discovery_incomplete",
248
+ });
249
+ }
250
+ if (callSpines) {
251
+ for (const reason of callSpines.truncation_reasons) {
252
+ omissions.push({
253
+ subject: callSpines.root.selector,
254
+ category: "budget",
255
+ component: "call_spine",
256
+ reason,
257
+ });
208
258
  }
209
- result = createResult(effectiveContext, request, routed.route, routed.file, routed.symbol, returnedSymbols, returnedEvidence, page.total, unresolved, reason);
210
259
  }
211
260
  requestContext.checkpoint();
212
- const usedBytes = jsonBytes(result);
213
- return {
214
- ...result,
215
- budget: { ...result.budget, used_bytes: usedBytes },
216
- };
261
+ return presentExploreClusters({
262
+ base: initialResult,
263
+ clusters: symbols.map((symbol) => ({
264
+ symbol,
265
+ evidence: evidenceBySelector.get(symbol.selector),
266
+ })),
267
+ offset: request.offset,
268
+ limit: request.limit,
269
+ total: page.total,
270
+ maxBytes: request.maxBytes,
271
+ omissions,
272
+ omissionDetailLimit: request.omissionDetailLimit ?? EXPLORE_DEFAULT_OMISSION_DETAIL_LIMIT,
273
+ callSpines,
274
+ unresolved,
275
+ });
217
276
  }
218
277
  function createResult(context, request, route, file, symbol, symbols, evidence, total, unresolved, reason) {
219
278
  const hasMore = request.offset + symbols.length < total;
@@ -0,0 +1,160 @@
1
+ import { Buffer } from "node:buffer";
2
+ export const EXPLORE_OMISSION_REASONS = [
3
+ "byte_limit",
4
+ "reference_limit",
5
+ "source_unresolved",
6
+ "references_unresolved",
7
+ "non_exact_evidence",
8
+ "call_discovery_incomplete",
9
+ "depth_limit",
10
+ "node_limit",
11
+ "edge_limit",
12
+ ];
13
+ function jsonBytes(value) {
14
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
15
+ }
16
+ function summarize(omissions, detailLimit) {
17
+ const details = [...omissions].sort((left, right) => [left.subject, left.category, left.component, left.reason]
18
+ .join("\0")
19
+ .localeCompare([right.subject, right.category, right.component, right.reason].join("\0")));
20
+ const countMap = new Map();
21
+ for (const item of omissions) {
22
+ const key = `${item.category}\0${item.component}`;
23
+ countMap.set(key, (countMap.get(key) ?? 0) + 1);
24
+ }
25
+ const counts = [...countMap]
26
+ .sort(([left], [right]) => left.localeCompare(right))
27
+ .map(([key, count]) => {
28
+ const [category, component] = key.split("\0");
29
+ return { category, component, count };
30
+ });
31
+ return {
32
+ counts,
33
+ details: details.slice(0, detailLimit),
34
+ total: omissions.length,
35
+ has_more: details.length > detailLimit,
36
+ };
37
+ }
38
+ function selectorOnly(symbol) {
39
+ return { file: symbol.file, selector: symbol.selector, kind: symbol.kind };
40
+ }
41
+ function omittedClusterParts(symbol, evidence) {
42
+ const component = (value) => ({
43
+ subject: symbol.selector,
44
+ category: "budget",
45
+ component: value,
46
+ reason: "byte_limit",
47
+ });
48
+ return [
49
+ ...(symbol.signature === undefined ? [] : [component("signature")]),
50
+ ...(evidence?.source === undefined ? [] : [component("source")]),
51
+ ...(evidence?.references === undefined ? [] : [component("references")]),
52
+ ];
53
+ }
54
+ export function presentExploreClusters(input) {
55
+ const symbols = [];
56
+ const evidence = [];
57
+ let omissions = [...input.omissions];
58
+ let consumed = 0;
59
+ let byteLimited = false;
60
+ const build = (detailLimit, includeCallSpines) => {
61
+ const callSpinesOmitted = input.callSpines !== undefined && !includeCallSpines;
62
+ const effectiveOmissions = callSpinesOmitted
63
+ ? [
64
+ ...omissions,
65
+ {
66
+ subject: input.callSpines.root.selector,
67
+ category: "budget",
68
+ component: "call_spine",
69
+ reason: "byte_limit",
70
+ },
71
+ ]
72
+ : omissions;
73
+ const spinesComplete = input.callSpines === undefined
74
+ ? undefined
75
+ : includeCallSpines && !input.callSpines.incomplete;
76
+ const hasMore = input.offset + consumed < input.total;
77
+ const evidenceComplete = effectiveOmissions.length === 0 &&
78
+ (input.unresolved?.length ?? 0) === 0 &&
79
+ spinesComplete !== false;
80
+ const reason = byteLimited || callSpinesOmitted ? "byte_limit" : hasMore ? "record_limit" : null;
81
+ const result = {
82
+ ...input.base,
83
+ ...(includeCallSpines && input.callSpines ? { call_spines: input.callSpines } : {}),
84
+ symbols,
85
+ evidence,
86
+ offset: input.offset,
87
+ limit: input.limit,
88
+ total: input.total,
89
+ has_more: hasMore,
90
+ next_offset: hasMore ? input.offset + consumed : null,
91
+ omissions: summarize(effectiveOmissions, detailLimit),
92
+ completeness: {
93
+ complete: !hasMore && evidenceComplete,
94
+ symbols_complete: !hasMore,
95
+ evidence_complete: evidenceComplete,
96
+ ...(spinesComplete === undefined ? {} : { spines_complete: spinesComplete }),
97
+ unresolved: input.unresolved ?? [],
98
+ },
99
+ budget: {
100
+ max_records: input.limit,
101
+ max_bytes: input.maxBytes,
102
+ max_depth: null,
103
+ max_edges: null,
104
+ max_invocations: 1,
105
+ used_bytes: 0,
106
+ },
107
+ truncation: { truncated: reason !== null, reason },
108
+ };
109
+ let usedBytes = 0;
110
+ let fixed = result;
111
+ for (let index = 0; index < 10; index += 1) {
112
+ fixed = { ...result, budget: { ...result.budget, used_bytes: usedBytes } };
113
+ const next = jsonBytes(fixed);
114
+ if (next === usedBytes)
115
+ return fixed;
116
+ usedBytes = next;
117
+ }
118
+ return { ...fixed, budget: { ...fixed.budget, used_bytes: jsonBytes(fixed) } };
119
+ };
120
+ const fittingResult = () => {
121
+ for (let detailLimit = input.omissionDetailLimit; detailLimit >= 0; detailLimit -= 1) {
122
+ if (input.callSpines) {
123
+ const included = build(detailLimit, true);
124
+ if (included.budget.used_bytes <= input.maxBytes)
125
+ return included;
126
+ }
127
+ const omitted = build(detailLimit, false);
128
+ if (omitted.budget.used_bytes <= input.maxBytes)
129
+ return omitted;
130
+ }
131
+ return null;
132
+ };
133
+ for (const cluster of input.clusters) {
134
+ const omissionsBeforeCluster = omissions;
135
+ symbols.push(cluster.symbol);
136
+ if (cluster.evidence)
137
+ evidence.push(cluster.evidence);
138
+ consumed += 1;
139
+ if (fittingResult())
140
+ continue;
141
+ symbols[symbols.length - 1] = selectorOnly(cluster.symbol);
142
+ if (cluster.evidence)
143
+ evidence.pop();
144
+ omissions = [...omissions, ...omittedClusterParts(cluster.symbol, cluster.evidence)];
145
+ byteLimited = true;
146
+ if (fittingResult())
147
+ continue;
148
+ if (symbols.length > 1) {
149
+ symbols.pop();
150
+ consumed -= 1;
151
+ omissions = omissionsBeforeCluster;
152
+ break;
153
+ }
154
+ throw new Error(`max_bytes cannot fit selector ${cluster.symbol.selector}.`);
155
+ }
156
+ const result = fittingResult();
157
+ if (!result)
158
+ throw new Error("max_bytes cannot fit the ast_explore result shell.");
159
+ return result;
160
+ }
@@ -62,8 +62,7 @@ function fallbackToMemory(session, reason) {
62
62
  session.context = {
63
63
  ...session.context,
64
64
  status: transitionProjectStatus(session.context.status, {
65
- type: "index_failed",
66
- error: boundedReason,
65
+ type: "index_disabled",
67
66
  }),
68
67
  symbolIndex: new InMemorySymbolIndex(),
69
68
  symbolIndexBackend: "memory",
@@ -124,6 +123,10 @@ async function ensureSessionSymbolIndex(session, requestContext) {
124
123
  symbolIndexObservability: createInitialSymbolIndexRuntimeObservability(policy),
125
124
  };
126
125
  }
126
+ if (session.context.symbolIndexFallbackReason === "capability_unavailable" ||
127
+ (session.context.symbolIndexFallbackReason !== null && policy.backend === "memory")) {
128
+ return;
129
+ }
127
130
  if (policy.backend === "memory") {
128
131
  if (policy.mode === "enabled" || policy.mode === "canary") {
129
132
  fallbackToMemory(session, policy.reason);