ast-mcp-server 0.9.2 → 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 +16 -0
- package/README.md +21 -14
- package/SECURITY.md +1 -1
- package/dist/batch/schema.js +2 -0
- package/dist/server.js +2 -0
- package/dist/services/agent-targets.js +1 -0
- package/dist/services/call-spines.js +101 -0
- package/dist/services/context-builder.js +83 -24
- package/dist/services/explore-presentation.js +160 -0
- package/dist/services/project.js +5 -2
- package/dist/services/public-errors.js +7 -1
- package/dist/services/relationships.js +104 -0
- package/dist/services/test-candidates.js +15 -8
- package/dist/tools/explore.js +73 -2
- package/dist/tools/find_test_candidates.js +124 -0
- package/dist/tools/get_impact.js +3 -30
- package/dist/tools/relationship-schema.js +31 -0
- package/docs/support.md +13 -13
- package/package.json +2 -2
- package/skills/structural-code-editing/SKILL.md +6 -4
- package/skills/structural-code-editing/releases.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
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.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
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
|
+
|
|
9
25
|
## [0.9.2] — local release candidate, pending release (2026-08-16)
|
|
10
26
|
|
|
11
27
|
### Fixed
|
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
|
|
29
|
-
| ------------------------------ |
|
|
30
|
-
| Read a bounded file | `ast_get_file`
|
|
31
|
-
| Explore bounded context | `ast_explore`
|
|
32
|
-
| Understand a file | `ast_get_outline`
|
|
33
|
-
| Inspect one declaration | `ast_get_symbol_source`
|
|
34
|
-
| Find usages across the project | `ast_find_references`
|
|
35
|
-
| Understand symbol impact | `ast_get_impact`
|
|
36
|
-
|
|
|
37
|
-
|
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
package/dist/batch/schema.js
CHANGED
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);
|
|
@@ -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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
+
}
|
package/dist/services/project.js
CHANGED
|
@@ -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: "
|
|
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);
|
|
@@ -13,6 +13,7 @@ export const PUBLIC_ERROR_CODES = Object.freeze([
|
|
|
13
13
|
"NOT_FOUND",
|
|
14
14
|
"AMBIGUOUS_TARGET",
|
|
15
15
|
"STALE_WORKSPACE",
|
|
16
|
+
"INCOMPLETE_EVIDENCE",
|
|
16
17
|
"MUTATION_BLOCKED",
|
|
17
18
|
"CONFLICT",
|
|
18
19
|
"INTERNAL_ERROR",
|
|
@@ -35,6 +36,7 @@ const FIXED_PUBLIC_MESSAGES = Object.freeze({
|
|
|
35
36
|
NOT_FOUND: "The requested target was not found.",
|
|
36
37
|
AMBIGUOUS_TARGET: "The requested target is ambiguous.",
|
|
37
38
|
STALE_WORKSPACE: "The workspace changed. Retry the operation.",
|
|
39
|
+
INCOMPLETE_EVIDENCE: "The required compiler evidence is incomplete.",
|
|
38
40
|
MUTATION_BLOCKED: "The mutation is blocked.",
|
|
39
41
|
CONFLICT: "The operation conflicts with current state.",
|
|
40
42
|
INTERNAL_ERROR: INTERNAL_ERROR_MESSAGE,
|
|
@@ -70,6 +72,10 @@ const LEGACY_MESSAGE_RULES = Object.freeze([
|
|
|
70
72
|
code: "STALE_WORKSPACE",
|
|
71
73
|
pattern: /^(?:Workspace changed\b|Project (?:configuration|source) changed during synchronization\.)/,
|
|
72
74
|
},
|
|
75
|
+
{
|
|
76
|
+
code: "INCOMPLETE_EVIDENCE",
|
|
77
|
+
pattern: /^Compiler-backed impact evidence is incomplete\.$/,
|
|
78
|
+
},
|
|
73
79
|
{
|
|
74
80
|
code: "CONFLICT",
|
|
75
81
|
pattern: /^(?:Conflict:|Plan hash mismatch\b|Persisted plan integrity mismatch\b|Operation id collision\b|Applied receipt conflict\b|Postimage recovery conflict\b|Operation target already exists\b|Another AST apply holds\b)/,
|
|
@@ -80,7 +86,7 @@ const LEGACY_MESSAGE_RULES = Object.freeze([
|
|
|
80
86
|
},
|
|
81
87
|
{
|
|
82
88
|
code: "INVALID_INPUT",
|
|
83
|
-
pattern: /^(?:File path traversal is not allowed\.|Windows absolute paths are not valid on this host\.|Source file resolves outside the configured project root\.|Unsupported (?:UTF-16|non-UTF-8) source encoding\b|Persisted (?:operation|applied operation|prepared operation|project_root|original hash|updated hash|affected_files|diagnostic error delta|blocked status)\b|Scaffold file_path must\b|.{0,1024} is not a valid TypeScript identifier\.|.{0,1024} must (?:be|not)\b|.{0,1024} requires\b|.{0,1024} exceeds \d+ characters\.)/,
|
|
89
|
+
pattern: /^(?:File path traversal is not allowed\.|Windows absolute paths are not valid on this host\.|Source file resolves outside the configured project root\.|Unsupported (?:UTF-16|non-UTF-8) source encoding\b|Persisted (?:operation|applied operation|prepared operation|project_root|original hash|updated hash|affected_files|diagnostic error delta|blocked status)\b|Scaffold file_path must\b|.{0,1024} is not a valid TypeScript identifier\.|.{0,1024} must (?:be|not|contain)\b|.{0,1024} requires\b|.{0,1024} exceeds \d+ characters\.)/,
|
|
84
90
|
},
|
|
85
91
|
]);
|
|
86
92
|
export class PublicOperationalError extends Error {
|