ast-mcp-server 0.5.0 → 0.6.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 +28 -0
- package/README.md +20 -3
- package/dist/server.js +6 -0
- package/dist/services/context-builder.js +234 -0
- package/dist/services/file-snapshot.js +96 -0
- package/dist/services/project-status.js +736 -0
- package/dist/services/project.js +87 -13
- package/dist/services/read-contracts.js +159 -0
- package/dist/services/references.js +48 -0
- package/dist/services/symbols.js +59 -0
- package/dist/services/workspace.js +12 -6
- package/dist/tools/explore.js +174 -0
- package/dist/tools/find_references.js +7 -44
- package/dist/tools/get_file.js +95 -0
- package/dist/tools/get_project_status.js +68 -0
- package/dist/tools/search_symbols.js +5 -54
- package/package.json +2 -1
- package/skills/structural-code-editing/SKILL.md +12 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.6.0] - 2026-08-05
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Read-only `ast_get_file` with bounded exact source lines, SHA-256 byte hashes, compiler snapshot state, and `symbols_only` body-free output.
|
|
12
|
+
- Project-scoped file snapshot validation with UTF-8 checks, traversal rejection, symlink containment checks, and ambiguous-path failures.
|
|
13
|
+
- Read-only `ast_explore` with query/file/symbol routing, progressive source and compiler-reference evidence, freshness/completeness metadata, unresolved selectors, and serialized byte budgets.
|
|
14
|
+
- A deterministic context workflow benchmark comparing full-file, primitive, and composed exploration workflows.
|
|
15
|
+
|
|
16
|
+
## [0.5.1] - 2026-08-05
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Bounded JSON-safe project status contracts and the read-only `ast_get_project_status` tool.
|
|
21
|
+
- Session freshness metadata with separate source, config, and canonical fingerprints.
|
|
22
|
+
- Serialized operation-queue accounting and status integration without changing reviewed mutation semantics.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Source synchronization verifies stability across refresh, snapshot, refresh, and verification before reporting fresh state.
|
|
27
|
+
- Phase 1 keeps the symbol index disabled while exposing explicit stale/degraded recovery state.
|
|
28
|
+
|
|
29
|
+
### Security
|
|
30
|
+
|
|
31
|
+
- Status projections redact identities, absolute paths, credentials, and multi-token `Authorization` values at both transition and projection boundaries.
|
|
32
|
+
- Noncanonical external fingerprints are converted to bounded opaque SHA-256 digests before projection.
|
|
33
|
+
|
|
7
34
|
## [0.5.0] - 2026-08-03
|
|
8
35
|
|
|
9
36
|
### Added
|
|
@@ -66,6 +93,7 @@ Initial public release.
|
|
|
66
93
|
- Apply rejects stale workspaces, changed configs/sources, mismatched plan hashes, unsafe plan files, conflicting MCP registrations, and conflicting skill content.
|
|
67
94
|
- Inputs, outputs, operation stores, subprocesses, batch fan-out, plan lifetime, and filesystem access are bounded.
|
|
68
95
|
|
|
96
|
+
[0.5.1]: https://github.com/yailPeralta/ast-mcp-server/releases/tag/v0.5.1
|
|
69
97
|
[0.5.0]: https://github.com/yailPeralta/ast-mcp-server/releases/tag/v0.5.0
|
|
70
98
|
[0.4.0]: https://github.com/yailPeralta/ast-mcp-server/releases/tag/v0.4.0
|
|
71
99
|
[0.3.0]: https://github.com/yailPeralta/ast-mcp-server/releases/tag/v0.3.0
|
package/README.md
CHANGED
|
@@ -23,13 +23,25 @@ This MCP server gives the agent structural code tools in addition to generic fil
|
|
|
23
23
|
|
|
24
24
|
| Need | Structural operation | Returned scope |
|
|
25
25
|
| ------------------------------ | ------------------------- | ----------------------------------------------------- |
|
|
26
|
+
| Read a bounded file | `ast_get_file` | Exact selected source lines, hash, and snapshot state |
|
|
27
|
+
| Explore bounded context | `ast_explore` | Ranked selectors plus optional source and references |
|
|
26
28
|
| Understand a file | `ast_get_outline` | Signatures without implementation bodies |
|
|
27
29
|
| Inspect one declaration | `ast_get_symbol_source` | Exact source for one function, method, class, or type |
|
|
28
30
|
| Find usages across the project | `ast_find_references` | Compiler-resolved reference locations |
|
|
29
31
|
| Rename a symbol everywhere | `ast_rename_symbol` | A reviewed project-wide rename plan |
|
|
30
32
|
| Change one implementation | `ast_replace_symbol_body` | A body-only plan that preserves the declaration |
|
|
31
33
|
|
|
32
|
-
Reads can start with a compact outline
|
|
34
|
+
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.
|
|
35
|
+
|
|
36
|
+
### Choosing a read tool
|
|
37
|
+
|
|
38
|
+
- Use `ast_get_file` when the file path is known and the agent needs exact source lines. It is read-only, uses zero-based `offset` and bounded `limit`, returns one-based line records, a SHA-256 byte hash, and `snapshot_state`.
|
|
39
|
+
- Use `ast_get_file` with `symbols_only: true` when only selectors and body-free signatures are needed from one known file.
|
|
40
|
+
- Use `ast_explore` when the question spans discovery and evidence. Its default summary is bounded; use `detail: "context"` for selected source and `detail: "full"` for source plus compiler references.
|
|
41
|
+
- Use `ast_get_outline` for a compact body-free view of a known file without source lines.
|
|
42
|
+
- Use `ast_get_symbol_source` when one declaration or implementation is the required evidence.
|
|
43
|
+
|
|
44
|
+
`snapshot_state: "fresh"` means that the returned file bytes match the synchronized compiler snapshot. It does not mean that the project has zero TypeScript diagnostics; use `ast_get_diagnostics` for compiler errors and warnings.
|
|
33
45
|
|
|
34
46
|
## Why this helps
|
|
35
47
|
|
|
@@ -39,7 +51,7 @@ Reads can start with a compact outline and fetch exact source only when needed.
|
|
|
39
51
|
|
|
40
52
|
AST-aware editing is not a proof that a change is semantically correct. The safety comes from combining structural selection with diagnostics, exact previews, reviewed hashes, freshness checks, and fail-closed apply semantics.
|
|
41
53
|
|
|
42
|
-
The included batch benchmark records a 50% reduction in model round-trips and a 94.67% reduction in serialized context for its search-to-source scenario. The result-shaping corpus records a 68.80% reduction in aggregate model-facing TOON tokens while preserving declared selectors/reference coordinates with the same six logical calls. The separate format benchmark records 25.87% across its eligible collection corpus. These are reproducible local `o200k_base` estimates, not universal token, billing, cache, or latency claims.
|
|
54
|
+
The included batch benchmark records a 50% reduction in model round-trips and a 94.67% reduction in serialized context for its search-to-source scenario. The result-shaping corpus records a 68.80% reduction in aggregate model-facing TOON tokens while preserving declared selectors/reference coordinates with the same six logical calls. The separate format benchmark records 25.87% across its eligible collection corpus. The context workflow benchmark verifies evidence preservation and call bounds for full-file, primitive, and `ast_explore` workflows. These are reproducible local `o200k_base` estimates, not universal token, billing, cache, or latency claims.
|
|
43
55
|
|
|
44
56
|
## Requirements
|
|
45
57
|
|
|
@@ -217,6 +229,9 @@ Project-scoped tools accept `project_root`, either the project directory or an e
|
|
|
217
229
|
| Tool | Purpose | Mutates files |
|
|
218
230
|
| --------------------------- | -------------------------------------------------------------------- | ------------- |
|
|
219
231
|
| `ast_list_files` | Paginated, project-relative source file inventory | No |
|
|
232
|
+
| `ast_get_project_status` | Read-only compiler, freshness, index, and operation status | No |
|
|
233
|
+
| `ast_explore` | Bounded composed selectors, source evidence, and references | No |
|
|
234
|
+
| `ast_get_file` | Bounded exact source lines, byte hash, and snapshot state | No |
|
|
220
235
|
| `ast_get_outline` | Body-free declaration signatures; detailed symbol metadata is opt-in | No |
|
|
221
236
|
| `ast_get_symbol_source` | Exact source for one declaration | No |
|
|
222
237
|
| `ast_search_symbols` | Paginated structural symbol discovery | No |
|
|
@@ -230,6 +245,8 @@ Project-scoped tools accept `project_root`, either the project directory or an e
|
|
|
230
245
|
|
|
231
246
|
Read results use project-relative paths, deterministic ordering, structured MCP output, and pagination where result sets can grow with the project.
|
|
232
247
|
|
|
248
|
+
`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.
|
|
249
|
+
|
|
233
250
|
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.
|
|
234
251
|
|
|
235
252
|
### Optional TOON results
|
|
@@ -392,7 +409,7 @@ The batch benchmark compares two separate client calls with one batch invocation
|
|
|
392
409
|
|
|
393
410
|
The format benchmark runs real tools against this repository plus deterministic reference/diagnostic fixtures. It checks JSON→TOON→value equality, UTF-8 bytes, `gpt-tokenizer` `o200k_base` estimates, encode/decode latency, the actual MCP envelope, tool metadata, and negative controls. Its checked result is `benchmark/results/self-formats.json`; local tokenizer estimates do not establish provider-side billing or cache savings. See `benchmark/README.md` for methodology and limitations.
|
|
394
411
|
|
|
395
|
-
The result-shaping benchmark compares the v0.4.0-compatible `full/100/context` profiles with the new public defaults across exact-name, exact-path, prefix, broad-substring, and multi-file-reference tasks. It fails on missing evidence, extra
|
|
412
|
+
The result-shaping benchmark compares the v0.4.0-compatible `full/100/context` profiles with the new public defaults across exact-name, exact-path, prefix, broad-substring, and multi-file-reference tasks. It fails on missing evidence, extra required calls, fewer than the benchmark's required minimum tool surface, or less than 35% aggregate TOON token reduction. Its checked result is `benchmark/results/self-result-shapes.json`.
|
|
396
413
|
|
|
397
414
|
## Scope
|
|
398
415
|
|
package/dist/server.js
CHANGED
|
@@ -3,14 +3,17 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { registerApplyOperation } from "./tools/apply_operation.js";
|
|
4
4
|
import { registerFindReferences } from "./tools/find_references.js";
|
|
5
5
|
import { registerGetDiagnostics } from "./tools/get_diagnostics.js";
|
|
6
|
+
import { registerGetFile } from "./tools/get_file.js";
|
|
6
7
|
import { registerGetOperationPreview } from "./tools/get_operation_preview.js";
|
|
7
8
|
import { registerGetOutline } from "./tools/get_outline.js";
|
|
9
|
+
import { registerGetProjectStatus } from "./tools/get_project_status.js";
|
|
8
10
|
import { registerGetSymbolSource } from "./tools/get_symbol_source.js";
|
|
9
11
|
import { registerListFiles } from "./tools/list_files.js";
|
|
10
12
|
import { registerRenameSymbol } from "./tools/rename_symbol.js";
|
|
11
13
|
import { registerReplaceSymbolBody } from "./tools/replace_symbol_body.js";
|
|
12
14
|
import { registerScaffoldClass } from "./tools/scaffold_class.js";
|
|
13
15
|
import { registerSearchSymbols } from "./tools/search_symbols.js";
|
|
16
|
+
import { registerExplore } from "./tools/explore.js";
|
|
14
17
|
const packageMetadata = createRequire(import.meta.url)("../package.json");
|
|
15
18
|
export const PACKAGE_VERSION = packageMetadata.version;
|
|
16
19
|
export function createServer() {
|
|
@@ -19,11 +22,14 @@ export function createServer() {
|
|
|
19
22
|
version: PACKAGE_VERSION,
|
|
20
23
|
});
|
|
21
24
|
registerListFiles(server);
|
|
25
|
+
registerGetProjectStatus(server);
|
|
26
|
+
registerExplore(server);
|
|
22
27
|
registerGetOutline(server);
|
|
23
28
|
registerGetSymbolSource(server);
|
|
24
29
|
registerSearchSymbols(server);
|
|
25
30
|
registerFindReferences(server);
|
|
26
31
|
registerGetDiagnostics(server);
|
|
32
|
+
registerGetFile(server);
|
|
27
33
|
registerRenameSymbol(server);
|
|
28
34
|
registerReplaceSymbolBody(server);
|
|
29
35
|
registerScaffoldClass(server);
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { buildFileOutline, nodeSourceWithLocation } from "./outline.js";
|
|
4
|
+
import { paginate } from "./pagination.js";
|
|
5
|
+
import { findDeclarationByName, getSourceFileOrThrow } from "./project.js";
|
|
6
|
+
import { collectSymbolReferences } from "./references.js";
|
|
7
|
+
import { searchProjectSymbols, sourceFileSymbols, symbolMatchRank, } from "./symbols.js";
|
|
8
|
+
export const EXPLORE_DEFAULT_LIMIT = 20;
|
|
9
|
+
export const EXPLORE_DEFAULT_REFERENCE_LIMIT = 20;
|
|
10
|
+
export const EXPLORE_DEFAULT_MAX_BYTES = 64 * 1024;
|
|
11
|
+
export const EXPLORE_MAX_BYTES = 1024 * 1024;
|
|
12
|
+
function matchesQuery(record, query) {
|
|
13
|
+
const normalizedQuery = query.toLowerCase();
|
|
14
|
+
return (record.name.toLowerCase().includes(normalizedQuery) ||
|
|
15
|
+
record.symbol_path.toLowerCase().includes(normalizedQuery) ||
|
|
16
|
+
record.selector.toLowerCase().includes(normalizedQuery));
|
|
17
|
+
}
|
|
18
|
+
function sortRecords(records, query) {
|
|
19
|
+
return records.sort((left, right) => {
|
|
20
|
+
if (query) {
|
|
21
|
+
const rank = symbolMatchRank(query, {
|
|
22
|
+
symbolPath: left.symbol_path,
|
|
23
|
+
name: left.name,
|
|
24
|
+
line: left.line,
|
|
25
|
+
}) -
|
|
26
|
+
symbolMatchRank(query, {
|
|
27
|
+
symbolPath: right.symbol_path,
|
|
28
|
+
name: right.name,
|
|
29
|
+
line: right.line,
|
|
30
|
+
});
|
|
31
|
+
if (rank !== 0)
|
|
32
|
+
return rank;
|
|
33
|
+
}
|
|
34
|
+
return (left.file.localeCompare(right.file) ||
|
|
35
|
+
left.line - right.line ||
|
|
36
|
+
left.symbol_path.localeCompare(right.symbol_path) ||
|
|
37
|
+
left.kind.localeCompare(right.kind));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function recordFromOutlineSymbol(sourceFile, projectRoot, symbol) {
|
|
41
|
+
return {
|
|
42
|
+
file: path.relative(projectRoot, sourceFile.getFilePath()).replaceAll("\\", "/"),
|
|
43
|
+
symbol_path: symbol.symbolPath,
|
|
44
|
+
selector: `${symbol.symbolPath}@${symbol.startLine}`,
|
|
45
|
+
name: symbol.name,
|
|
46
|
+
kind: symbol.kind,
|
|
47
|
+
line: symbol.startLine,
|
|
48
|
+
signature: symbol.signature,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function fileRecords(sourceFile, projectRoot) {
|
|
52
|
+
const outline = buildFileOutline(sourceFile);
|
|
53
|
+
if (outline.symbols.length > 0) {
|
|
54
|
+
return outline.symbols.map((symbol) => recordFromOutlineSymbol(sourceFile, projectRoot, symbol));
|
|
55
|
+
}
|
|
56
|
+
return sourceFileSymbols(sourceFile, projectRoot);
|
|
57
|
+
}
|
|
58
|
+
function projectRecords(context, request) {
|
|
59
|
+
const { project, projectRoot } = context;
|
|
60
|
+
if (request.filePath) {
|
|
61
|
+
const sourceFile = getSourceFileOrThrow(project, request.filePath);
|
|
62
|
+
const file = path.relative(projectRoot, sourceFile.getFilePath()).replaceAll("\\", "/");
|
|
63
|
+
const requestedSymbolPath = request.symbolPath;
|
|
64
|
+
if (requestedSymbolPath) {
|
|
65
|
+
const node = findDeclarationByName(sourceFile, requestedSymbolPath);
|
|
66
|
+
const record = fileRecords(sourceFile, projectRoot).find((candidate) => candidate.symbol_path === requestedSymbolPath ||
|
|
67
|
+
candidate.selector === requestedSymbolPath ||
|
|
68
|
+
(candidate.symbol_path === requestedSymbolPath.split("@")[0] &&
|
|
69
|
+
candidate.line === node.getStartLineNumber()));
|
|
70
|
+
if (!record) {
|
|
71
|
+
throw new Error(`Symbol "${requestedSymbolPath}" could not be projected from ${file}.`);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
route: "symbol",
|
|
75
|
+
file,
|
|
76
|
+
symbol: record.selector,
|
|
77
|
+
records: [record],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const records = fileRecords(sourceFile, projectRoot)
|
|
81
|
+
.filter((record) => !request.query || matchesQuery(record, request.query))
|
|
82
|
+
.filter((record) => !request.kinds || request.kinds.includes(record.kind));
|
|
83
|
+
return { route: "file", file, symbol: null, records: sortRecords(records, request.query) };
|
|
84
|
+
}
|
|
85
|
+
if (!request.query) {
|
|
86
|
+
throw new Error("ast_explore requires query, file_path, or both file_path and symbol_path.");
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
route: "query",
|
|
90
|
+
file: null,
|
|
91
|
+
symbol: null,
|
|
92
|
+
records: searchProjectSymbols(project, projectRoot, {
|
|
93
|
+
query: request.query,
|
|
94
|
+
kinds: request.kinds,
|
|
95
|
+
fileFilter: request.fileFilter,
|
|
96
|
+
}),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function projectSymbol(detail, record) {
|
|
100
|
+
const base = { file: record.file, selector: record.selector, kind: record.kind };
|
|
101
|
+
if (detail === "selectors")
|
|
102
|
+
return base;
|
|
103
|
+
if (detail === "summary" || detail === "context") {
|
|
104
|
+
return { ...base, signature: record.signature };
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
...base,
|
|
108
|
+
symbol_path: record.symbol_path,
|
|
109
|
+
name: record.name,
|
|
110
|
+
line: record.line,
|
|
111
|
+
signature: record.signature,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function jsonBytes(value) {
|
|
115
|
+
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
116
|
+
}
|
|
117
|
+
function effectiveExpansion(request) {
|
|
118
|
+
return {
|
|
119
|
+
source: request.includeSource ?? (request.detail === "context" || request.detail === "full"),
|
|
120
|
+
references: request.includeReferences ?? request.detail === "full",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export function buildExploreContext(context, request) {
|
|
124
|
+
const routed = projectRecords(context, request);
|
|
125
|
+
const page = paginate(routed.records, request.offset, request.limit);
|
|
126
|
+
const expansion = effectiveExpansion(request);
|
|
127
|
+
const unresolved = [];
|
|
128
|
+
const evidence = [];
|
|
129
|
+
for (const record of page.items) {
|
|
130
|
+
let sourceFile;
|
|
131
|
+
try {
|
|
132
|
+
sourceFile = getSourceFileOrThrow(context.project, record.file);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
unresolved.push({ selector: record.selector, reason: "source_unresolved" });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
let node;
|
|
139
|
+
try {
|
|
140
|
+
node = findDeclarationByName(sourceFile, record.selector);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
unresolved.push({ selector: record.selector, reason: "source_unresolved" });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
let source;
|
|
147
|
+
let references;
|
|
148
|
+
if (expansion.source)
|
|
149
|
+
source = nodeSourceWithLocation(node, context.projectRoot);
|
|
150
|
+
if (expansion.references) {
|
|
151
|
+
try {
|
|
152
|
+
references = collectSymbolReferences(node, context.projectRoot, record.selector, true, request.referenceDetail, 0, request.referenceLimit);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
unresolved.push({ selector: record.selector, reason: "references_unresolved" });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (source || references) {
|
|
159
|
+
evidence.push({
|
|
160
|
+
selector: record.selector,
|
|
161
|
+
...(source ? { source } : {}),
|
|
162
|
+
...(references ? { references } : {}),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const symbols = page.items.map((record) => projectSymbol(request.detail, record));
|
|
167
|
+
let returnedSymbols = [...symbols];
|
|
168
|
+
let returnedEvidence = [...evidence];
|
|
169
|
+
let reason = page.has_more ? "record_limit" : null;
|
|
170
|
+
let result = createResult(context, request, routed.route, routed.file, routed.symbol, returnedSymbols, returnedEvidence, page.total, unresolved, reason);
|
|
171
|
+
while (jsonBytes(result) > request.maxBytes &&
|
|
172
|
+
(returnedEvidence.length > 0 || returnedSymbols.length > 0)) {
|
|
173
|
+
reason = "byte_limit";
|
|
174
|
+
if (returnedEvidence.length > 0) {
|
|
175
|
+
returnedEvidence = returnedEvidence.slice(0, -1);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
returnedSymbols = returnedSymbols.slice(0, -1);
|
|
179
|
+
const selectors = new Set(returnedSymbols.map((symbol) => symbol.selector));
|
|
180
|
+
returnedEvidence = returnedEvidence.filter((item) => selectors.has(item.selector));
|
|
181
|
+
}
|
|
182
|
+
result = createResult(context, request, routed.route, routed.file, routed.symbol, returnedSymbols, returnedEvidence, page.total, unresolved, reason);
|
|
183
|
+
}
|
|
184
|
+
const usedBytes = jsonBytes(result);
|
|
185
|
+
return {
|
|
186
|
+
...result,
|
|
187
|
+
budget: { ...result.budget, used_bytes: usedBytes },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function createResult(context, request, route, file, symbol, symbols, evidence, total, unresolved, reason) {
|
|
191
|
+
const hasMore = request.offset + symbols.length < total;
|
|
192
|
+
const nextOffset = hasMore ? request.offset + symbols.length : null;
|
|
193
|
+
const expansion = effectiveExpansion(request);
|
|
194
|
+
const evidenceComplete = unresolved.length === 0 &&
|
|
195
|
+
evidence.length >= (expansion.source || expansion.references ? symbols.length : 0);
|
|
196
|
+
const symbolsComplete = !hasMore;
|
|
197
|
+
return {
|
|
198
|
+
route,
|
|
199
|
+
query: request.query ?? null,
|
|
200
|
+
file,
|
|
201
|
+
symbol,
|
|
202
|
+
detail: request.detail,
|
|
203
|
+
symbols,
|
|
204
|
+
evidence,
|
|
205
|
+
offset: request.offset,
|
|
206
|
+
limit: request.limit,
|
|
207
|
+
total,
|
|
208
|
+
has_more: hasMore,
|
|
209
|
+
next_offset: nextOffset,
|
|
210
|
+
freshness: {
|
|
211
|
+
state: context.status.state,
|
|
212
|
+
causes: context.status.causes,
|
|
213
|
+
checked_at: context.status.lastSuccessfulSyncAt,
|
|
214
|
+
},
|
|
215
|
+
completeness: {
|
|
216
|
+
complete: symbolsComplete && evidenceComplete,
|
|
217
|
+
symbols_complete: symbolsComplete,
|
|
218
|
+
evidence_complete: evidenceComplete,
|
|
219
|
+
unresolved,
|
|
220
|
+
},
|
|
221
|
+
budget: {
|
|
222
|
+
max_records: request.limit,
|
|
223
|
+
max_bytes: request.maxBytes,
|
|
224
|
+
max_depth: null,
|
|
225
|
+
max_edges: null,
|
|
226
|
+
max_invocations: 1,
|
|
227
|
+
used_bytes: 0,
|
|
228
|
+
},
|
|
229
|
+
truncation: {
|
|
230
|
+
truncated: reason !== null || hasMore,
|
|
231
|
+
reason: reason ?? (hasMore ? "record_limit" : null),
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { TextDecoder } from "node:util";
|
|
2
|
+
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getSourceFileOrThrow } from "./project.js";
|
|
5
|
+
import { hashBytes } from "./workspace.js";
|
|
6
|
+
export const DEFAULT_FILE_LINE_LIMIT = 200;
|
|
7
|
+
export const MAX_FILE_LINE_LIMIT = 500;
|
|
8
|
+
function assertBoundedRange({ offset, limit }) {
|
|
9
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
10
|
+
throw new Error("File offset must be a non-negative integer.");
|
|
11
|
+
}
|
|
12
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_FILE_LINE_LIMIT) {
|
|
13
|
+
throw new Error(`File limit must be an integer between 1 and ${MAX_FILE_LINE_LIMIT}.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function rejectTraversalPath(filePath) {
|
|
17
|
+
const normalized = filePath.replaceAll("\\", "/");
|
|
18
|
+
const segments = normalized.split("/");
|
|
19
|
+
const isWindowsAbsolute = /^[A-Za-z]:\//.test(normalized) || normalized.startsWith("//");
|
|
20
|
+
if (segments.includes("..")) {
|
|
21
|
+
throw new Error("File path traversal is not allowed.");
|
|
22
|
+
}
|
|
23
|
+
if (isWindowsAbsolute && !path.isAbsolute(filePath)) {
|
|
24
|
+
throw new Error("Windows absolute paths are not valid on this host.");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function isContained(root, candidate) {
|
|
28
|
+
const relative = path.relative(root, candidate);
|
|
29
|
+
return (relative.length > 0 &&
|
|
30
|
+
relative !== ".." &&
|
|
31
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
32
|
+
!path.isAbsolute(relative));
|
|
33
|
+
}
|
|
34
|
+
function projectRelative(root, filePath) {
|
|
35
|
+
return path.relative(root, filePath).split(path.sep).join("/");
|
|
36
|
+
}
|
|
37
|
+
function decodeUtf8(bytes, filePath) {
|
|
38
|
+
try {
|
|
39
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new Error(`Source file "${filePath}" is not valid UTF-8.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function splitLines(source) {
|
|
46
|
+
if (source.length === 0)
|
|
47
|
+
return [];
|
|
48
|
+
const lines = source.split(/\r\n|\n|\r/);
|
|
49
|
+
if (source.endsWith("\n") || source.endsWith("\r"))
|
|
50
|
+
lines.pop();
|
|
51
|
+
return lines;
|
|
52
|
+
}
|
|
53
|
+
function compilerSnapshotHashes(sourceText) {
|
|
54
|
+
const sourceBytes = Buffer.from(sourceText, "utf8");
|
|
55
|
+
const hashes = [hashBytes(sourceBytes)];
|
|
56
|
+
if (sourceText.charCodeAt(0) !== 0xfeff) {
|
|
57
|
+
hashes.push(hashBytes(Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), sourceBytes])));
|
|
58
|
+
}
|
|
59
|
+
return hashes;
|
|
60
|
+
}
|
|
61
|
+
export async function readFileSnapshot(project, projectRoot, filePath, options) {
|
|
62
|
+
assertBoundedRange(options);
|
|
63
|
+
rejectTraversalPath(filePath);
|
|
64
|
+
const sourceFile = getSourceFileOrThrow(project, filePath);
|
|
65
|
+
const canonicalRoot = await realpath(projectRoot);
|
|
66
|
+
const sourcePath = sourceFile.getFilePath();
|
|
67
|
+
const canonicalFile = await realpath(sourcePath);
|
|
68
|
+
if (!isContained(canonicalRoot, canonicalFile)) {
|
|
69
|
+
throw new Error("Source file resolves outside the configured project root.");
|
|
70
|
+
}
|
|
71
|
+
const stat = await lstat(canonicalFile);
|
|
72
|
+
if (!stat.isFile()) {
|
|
73
|
+
throw new Error("Source file is not a regular file.");
|
|
74
|
+
}
|
|
75
|
+
const bytes = await readFile(canonicalFile);
|
|
76
|
+
const source = decodeUtf8(bytes, projectRelative(canonicalRoot, canonicalFile));
|
|
77
|
+
const fileHash = hashBytes(bytes);
|
|
78
|
+
const compilerHashes = compilerSnapshotHashes(sourceFile.getFullText());
|
|
79
|
+
const snapshotState = compilerHashes.includes(fileHash) ? "fresh" : "stale";
|
|
80
|
+
const allLines = splitLines(source);
|
|
81
|
+
const selectedLines = allLines.slice(options.offset, options.offset + options.limit);
|
|
82
|
+
return {
|
|
83
|
+
file: projectRelative(canonicalRoot, canonicalFile),
|
|
84
|
+
range: {
|
|
85
|
+
offset: options.offset,
|
|
86
|
+
limit: options.limit,
|
|
87
|
+
total_lines: allLines.length,
|
|
88
|
+
},
|
|
89
|
+
lines: selectedLines.map((text, index) => ({
|
|
90
|
+
line: options.offset + index + 1,
|
|
91
|
+
text,
|
|
92
|
+
})),
|
|
93
|
+
file_hash: fileHash,
|
|
94
|
+
snapshot_state: snapshotState,
|
|
95
|
+
};
|
|
96
|
+
}
|