ast-mcp-server 0.5.1 → 0.7.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 +36 -0
- package/README.md +63 -14
- package/SECURITY.md +31 -0
- package/dist/cli.js +0 -0
- package/dist/index.js +129 -10
- package/dist/server.js +25 -2
- package/dist/services/agent-setup.js +36 -117
- package/dist/services/agent-targets.js +122 -0
- package/dist/services/context-builder.js +262 -0
- package/dist/services/diagnostics.js +10 -5
- package/dist/services/file-fingerprints.js +104 -0
- package/dist/services/file-snapshot.js +103 -0
- package/dist/services/impact.js +334 -0
- package/dist/services/operations.js +343 -254
- package/dist/services/outline.js +94 -21
- package/dist/services/pagination.js +10 -4
- package/dist/services/project-operation-scheduler.js +475 -0
- package/dist/services/project-status.js +134 -52
- package/dist/services/project-watcher.js +230 -0
- package/dist/services/project.js +646 -66
- package/dist/services/public-errors.js +255 -0
- package/dist/services/references.js +54 -0
- package/dist/services/relationships.js +1198 -0
- package/dist/services/request-context.js +42 -0
- package/dist/services/runtime-activity.js +65 -0
- package/dist/services/runtime-logger.js +50 -0
- package/dist/services/runtime-policy.js +47 -0
- package/dist/services/setup-wizard.js +4 -7
- package/dist/services/shutdown.js +93 -0
- package/dist/services/symbol-index-limits.js +5 -0
- package/dist/services/symbol-index-order.js +13 -0
- package/dist/services/symbol-index-policy.js +91 -0
- package/dist/services/symbol-index-sqlite.js +850 -0
- package/dist/services/symbol-index.js +447 -0
- package/dist/services/symbols.js +430 -47
- package/dist/services/test-candidates.js +209 -0
- package/dist/services/workspace.js +18 -7
- package/dist/tools/apply_operation.js +8 -4
- package/dist/tools/explore.js +176 -0
- package/dist/tools/find_references.js +17 -55
- package/dist/tools/get_diagnostics.js +10 -7
- package/dist/tools/get_file.js +107 -0
- package/dist/tools/get_impact.js +134 -0
- package/dist/tools/get_operation_preview.js +7 -4
- package/dist/tools/get_outline.js +8 -6
- package/dist/tools/get_project_status.js +77 -8
- package/dist/tools/get_symbol_source.js +8 -6
- package/dist/tools/list_files.js +11 -6
- package/dist/tools/rename_symbol.js +6 -4
- package/dist/tools/replace_symbol_body.js +6 -4
- package/dist/tools/result.js +27 -17
- package/dist/tools/scaffold_class.js +6 -4
- package/dist/tools/search_symbols.js +15 -63
- package/docs/support.md +80 -0
- package/package.json +18 -6
- package/skills/structural-code-editing/SKILL.md +42 -8
- package/benchmark/README.md +0 -129
|
@@ -4,27 +4,9 @@ import { access, realpath, stat } from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import process from "node:process";
|
|
6
6
|
import { installBundledSkill, SkillConflictError, } from "./skill-installer.js";
|
|
7
|
-
import { AGENT_IDS } from "./
|
|
8
|
-
const MCP_SERVER_NAME = "ast";
|
|
7
|
+
import { AGENT_IDS, getAgentTarget, MCP_SERVER_NAME, } from "./agent-targets.js";
|
|
9
8
|
const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
|
|
10
9
|
const MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024;
|
|
11
|
-
const EXPECTED_HERMES_TOOLS = [
|
|
12
|
-
"ast_list_files",
|
|
13
|
-
"ast_get_outline",
|
|
14
|
-
"ast_get_symbol_source",
|
|
15
|
-
"ast_search_symbols",
|
|
16
|
-
"ast_find_references",
|
|
17
|
-
"ast_get_diagnostics",
|
|
18
|
-
"ast_rename_symbol",
|
|
19
|
-
"ast_replace_symbol_body",
|
|
20
|
-
"ast_scaffold_class",
|
|
21
|
-
"ast_get_operation_preview",
|
|
22
|
-
"ast_apply_operation",
|
|
23
|
-
];
|
|
24
|
-
const AGENT_DEFINITIONS = {
|
|
25
|
-
claude: { label: "Claude Code", command: "claude" },
|
|
26
|
-
hermes: { label: "Hermes", command: "hermes" },
|
|
27
|
-
};
|
|
28
10
|
export class AgentSetupError extends Error {
|
|
29
11
|
code;
|
|
30
12
|
details;
|
|
@@ -128,102 +110,44 @@ async function runCommand(executable, args, options) {
|
|
|
128
110
|
}
|
|
129
111
|
function commandFailure(agent, operation, result) {
|
|
130
112
|
const output = `${result.stdout}\n${result.stderr}`.trim().slice(0, 4000);
|
|
131
|
-
return new AgentSetupError(`${
|
|
113
|
+
return new AgentSetupError(`${getAgentTarget(agent).label} ${operation} failed with exit code ${result.exitCode}.`, "AGENT_COMMAND_FAILED", { agent, operation, output });
|
|
132
114
|
}
|
|
133
|
-
function
|
|
134
|
-
return
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
timeoutMs,
|
|
142
|
-
});
|
|
143
|
-
const output = `${result.stdout}\n${result.stderr}`;
|
|
144
|
-
if (result.exitCode !== 0) {
|
|
145
|
-
if (isMissingClaudeMcp(output)) {
|
|
146
|
-
return { agent: "claude", status: "missing" };
|
|
147
|
-
}
|
|
148
|
-
throw commandFailure("claude", "MCP inspection", result);
|
|
149
|
-
}
|
|
150
|
-
const fields = new Map();
|
|
151
|
-
for (const line of result.stdout.split(/\r?\n/)) {
|
|
152
|
-
const match = line.match(/^\s*(Status|Type|Command|Args):\s*(.*)$/i);
|
|
153
|
-
if (match !== null) {
|
|
154
|
-
fields.set(match[1].toLowerCase(), match[2].trim());
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
const current = /connected/i.test(fields.get("status") ?? "") &&
|
|
158
|
-
fields.get("type")?.toLowerCase() === "stdio" &&
|
|
159
|
-
fields.get("command") === nodeExecutable &&
|
|
160
|
-
fields.get("args") === serverEntryPath;
|
|
161
|
-
return current
|
|
162
|
-
? { agent: "claude", status: "current" }
|
|
163
|
-
: {
|
|
164
|
-
agent: "claude",
|
|
165
|
-
status: "conflict",
|
|
166
|
-
detail: "The existing user-scoped 'ast' registration does not match this package.",
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
async function inspectHermesMcp(detection, environment, timeoutMs) {
|
|
170
|
-
const listed = await runCommand(detection.executable, ["mcp", "list"], {
|
|
171
|
-
environment,
|
|
172
|
-
timeoutMs,
|
|
173
|
-
});
|
|
174
|
-
if (listed.exitCode !== 0) {
|
|
175
|
-
throw commandFailure("hermes", "MCP list", listed);
|
|
176
|
-
}
|
|
177
|
-
if (!/^\s*ast(?:\s|$)/im.test(listed.stdout)) {
|
|
178
|
-
return { agent: "hermes", status: "missing" };
|
|
179
|
-
}
|
|
180
|
-
const tested = await runCommand(detection.executable, ["mcp", "test", MCP_SERVER_NAME], {
|
|
181
|
-
environment,
|
|
182
|
-
timeoutMs,
|
|
183
|
-
});
|
|
184
|
-
const current = tested.exitCode === 0 && EXPECTED_HERMES_TOOLS.every((tool) => tested.stdout.includes(tool));
|
|
185
|
-
return current
|
|
186
|
-
? { agent: "hermes", status: "current" }
|
|
187
|
-
: {
|
|
188
|
-
agent: "hermes",
|
|
189
|
-
status: "conflict",
|
|
190
|
-
detail: "The existing 'ast' registration does not expose the expected structural tools.",
|
|
191
|
-
};
|
|
115
|
+
function targetRuntime(detection, environment, timeoutMs) {
|
|
116
|
+
return {
|
|
117
|
+
run: (args, input) => runCommand(detection.executable, [...args], {
|
|
118
|
+
environment,
|
|
119
|
+
timeoutMs,
|
|
120
|
+
input,
|
|
121
|
+
}),
|
|
122
|
+
};
|
|
192
123
|
}
|
|
193
124
|
async function inspectMcp(agent, detection, serverEntryPath, nodeExecutable, environment, timeoutMs) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
: inspectHermesMcp(detection, environment, timeoutMs);
|
|
197
|
-
}
|
|
198
|
-
async function configureClaudeMcp(detection, serverEntryPath, nodeExecutable, environment, timeoutMs) {
|
|
199
|
-
const added = await runCommand(detection.executable, [
|
|
200
|
-
"mcp",
|
|
201
|
-
"add",
|
|
202
|
-
"--scope",
|
|
203
|
-
"user",
|
|
204
|
-
"--transport",
|
|
205
|
-
"stdio",
|
|
206
|
-
MCP_SERVER_NAME,
|
|
207
|
-
"--",
|
|
208
|
-
nodeExecutable,
|
|
125
|
+
const target = getAgentTarget(agent);
|
|
126
|
+
const inspection = await target.mcp.inspect(targetRuntime(detection, environment, timeoutMs), {
|
|
209
127
|
serverEntryPath,
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const verified = await inspectClaudeMcp(detection, serverEntryPath, nodeExecutable, environment, timeoutMs);
|
|
215
|
-
if (verified.status !== "current") {
|
|
216
|
-
throw new AgentSetupError("Claude Code saved the MCP registration but verification did not match.", "AGENT_VERIFICATION_FAILED", { agent: "claude", inspection: verified });
|
|
128
|
+
nodeExecutable,
|
|
129
|
+
});
|
|
130
|
+
if (inspection.status === "error") {
|
|
131
|
+
throw commandFailure(agent, inspection.operation, inspection.result);
|
|
217
132
|
}
|
|
133
|
+
return {
|
|
134
|
+
agent,
|
|
135
|
+
status: inspection.status,
|
|
136
|
+
...(inspection.detail ? { detail: inspection.detail } : {}),
|
|
137
|
+
};
|
|
218
138
|
}
|
|
219
|
-
async function
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
139
|
+
async function configureMcp(agent, detection, serverEntryPath, nodeExecutable, environment, timeoutMs) {
|
|
140
|
+
const target = getAgentTarget(agent);
|
|
141
|
+
const added = await target.mcp.register(targetRuntime(detection, environment, timeoutMs), {
|
|
142
|
+
serverEntryPath,
|
|
143
|
+
nodeExecutable,
|
|
144
|
+
});
|
|
145
|
+
if (!target.mcp.registrationAccepted(added)) {
|
|
146
|
+
throw commandFailure(agent, "MCP registration", added);
|
|
223
147
|
}
|
|
224
|
-
const verified = await
|
|
148
|
+
const verified = await inspectMcp(agent, detection, serverEntryPath, nodeExecutable, environment, timeoutMs);
|
|
225
149
|
if (verified.status !== "current") {
|
|
226
|
-
throw new AgentSetupError(
|
|
150
|
+
throw new AgentSetupError(`${target.label} saved the MCP registration but verification did not match.`, "AGENT_VERIFICATION_FAILED", { agent, inspection: verified });
|
|
227
151
|
}
|
|
228
152
|
}
|
|
229
153
|
async function assertRegularFile(filePath, label) {
|
|
@@ -248,7 +172,7 @@ export async function detectInstalledAgents(options = {}) {
|
|
|
248
172
|
const environment = options.environment ?? process.env;
|
|
249
173
|
const timeoutMs = options.commandTimeoutMs ?? 5000;
|
|
250
174
|
return Promise.all(AGENT_IDS.map(async (id) => {
|
|
251
|
-
const definition =
|
|
175
|
+
const definition = getAgentTarget(id);
|
|
252
176
|
const executable = await findExecutable(definition.command, environment);
|
|
253
177
|
if (executable === undefined) {
|
|
254
178
|
return { id, label: definition.label, installed: false };
|
|
@@ -282,7 +206,7 @@ export async function runAgentSetup(options) {
|
|
|
282
206
|
for (const agent of requested) {
|
|
283
207
|
const detection = detectionMap.get(agent);
|
|
284
208
|
if (!detection?.installed || detection.executable === undefined) {
|
|
285
|
-
throw new AgentSetupError(`${
|
|
209
|
+
throw new AgentSetupError(`${getAgentTarget(agent).label} is not installed or is not available in PATH.`, "AGENT_NOT_INSTALLED", { agent });
|
|
286
210
|
}
|
|
287
211
|
}
|
|
288
212
|
await assertRegularFile(options.serverEntryPath, "MCP server entrypoint");
|
|
@@ -290,9 +214,9 @@ export async function runAgentSetup(options) {
|
|
|
290
214
|
const inspections = await Promise.all(requested.map((agent) => inspectMcp(agent, detectionMap.get(agent), options.serverEntryPath, nodeExecutable, environment, timeoutMs)));
|
|
291
215
|
const conflict = inspections.find((item) => item.status === "conflict");
|
|
292
216
|
if (conflict !== undefined) {
|
|
293
|
-
throw new AgentSetupError(`Conflicting ${
|
|
217
|
+
throw new AgentSetupError(`Conflicting ${getAgentTarget(conflict.agent).label} MCP registration: ${conflict.detail}`, "AGENT_MCP_CONFLICT", { agent: conflict.agent });
|
|
294
218
|
}
|
|
295
|
-
const skillTarget = requested.length ===
|
|
219
|
+
const skillTarget = requested.length === AGENT_IDS.length ? "all" : getAgentTarget(requested[0]).skillTarget;
|
|
296
220
|
const skillResult = await installBundledSkill({
|
|
297
221
|
target: skillTarget,
|
|
298
222
|
scope: "user",
|
|
@@ -312,12 +236,7 @@ export async function runAgentSetup(options) {
|
|
|
312
236
|
const inspection = inspections.find((item) => item.agent === agent);
|
|
313
237
|
try {
|
|
314
238
|
if (inspection.status === "missing") {
|
|
315
|
-
|
|
316
|
-
await configureClaudeMcp(detection, options.serverEntryPath, nodeExecutable, environment, timeoutMs);
|
|
317
|
-
}
|
|
318
|
-
else {
|
|
319
|
-
await configureHermesMcp(detection, options.serverEntryPath, nodeExecutable, environment, timeoutMs);
|
|
320
|
-
}
|
|
239
|
+
await configureMcp(agent, detection, options.serverEntryPath, nodeExecutable, environment, timeoutMs);
|
|
321
240
|
}
|
|
322
241
|
completed.push({
|
|
323
242
|
agent,
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export const MCP_SERVER_NAME = "ast";
|
|
2
|
+
const EXPECTED_HERMES_TOOLS = [
|
|
3
|
+
"ast_list_files",
|
|
4
|
+
"ast_get_outline",
|
|
5
|
+
"ast_get_symbol_source",
|
|
6
|
+
"ast_search_symbols",
|
|
7
|
+
"ast_find_references",
|
|
8
|
+
"ast_get_diagnostics",
|
|
9
|
+
"ast_rename_symbol",
|
|
10
|
+
"ast_replace_symbol_body",
|
|
11
|
+
"ast_scaffold_class",
|
|
12
|
+
"ast_get_operation_preview",
|
|
13
|
+
"ast_apply_operation",
|
|
14
|
+
];
|
|
15
|
+
function isMissingClaudeMcp(output) {
|
|
16
|
+
return (/\bno\s+mcp\s+server\b/i.test(output) &&
|
|
17
|
+
/(?:\bfound\b|\bnamed?\b)/i.test(output) &&
|
|
18
|
+
/\bast\b/i.test(output));
|
|
19
|
+
}
|
|
20
|
+
function inspectClaudeFields(stdout) {
|
|
21
|
+
const fields = new Map();
|
|
22
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
23
|
+
const match = line.match(/^\s*(Status|Type|Command|Args):\s*(.*)$/i);
|
|
24
|
+
if (match !== null)
|
|
25
|
+
fields.set(match[1].toLowerCase(), match[2].trim());
|
|
26
|
+
}
|
|
27
|
+
return fields;
|
|
28
|
+
}
|
|
29
|
+
const claudeMcp = {
|
|
30
|
+
async inspect(runtime, context) {
|
|
31
|
+
const result = await runtime.run(["mcp", "get", MCP_SERVER_NAME]);
|
|
32
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
33
|
+
if (result.exitCode !== 0) {
|
|
34
|
+
return isMissingClaudeMcp(output)
|
|
35
|
+
? { status: "missing" }
|
|
36
|
+
: { status: "error", operation: "MCP inspection", result };
|
|
37
|
+
}
|
|
38
|
+
const fields = inspectClaudeFields(result.stdout);
|
|
39
|
+
const current = /connected/i.test(fields.get("status") ?? "") &&
|
|
40
|
+
fields.get("type")?.toLowerCase() === "stdio" &&
|
|
41
|
+
fields.get("command") === context.nodeExecutable &&
|
|
42
|
+
fields.get("args") === context.serverEntryPath;
|
|
43
|
+
return current
|
|
44
|
+
? { status: "current" }
|
|
45
|
+
: {
|
|
46
|
+
status: "conflict",
|
|
47
|
+
detail: "The existing user-scoped 'ast' registration does not match this package.",
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
register(runtime, context) {
|
|
51
|
+
return runtime.run([
|
|
52
|
+
"mcp",
|
|
53
|
+
"add",
|
|
54
|
+
"--scope",
|
|
55
|
+
"user",
|
|
56
|
+
"--transport",
|
|
57
|
+
"stdio",
|
|
58
|
+
MCP_SERVER_NAME,
|
|
59
|
+
"--",
|
|
60
|
+
context.nodeExecutable,
|
|
61
|
+
context.serverEntryPath,
|
|
62
|
+
]);
|
|
63
|
+
},
|
|
64
|
+
registrationAccepted(result) {
|
|
65
|
+
return result.exitCode === 0;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
const hermesMcp = {
|
|
69
|
+
async inspect(runtime) {
|
|
70
|
+
const listed = await runtime.run(["mcp", "list"]);
|
|
71
|
+
if (listed.exitCode !== 0) {
|
|
72
|
+
return { status: "error", operation: "MCP list", result: listed };
|
|
73
|
+
}
|
|
74
|
+
if (!/^\s*ast(?:\s|$)/im.test(listed.stdout))
|
|
75
|
+
return { status: "missing" };
|
|
76
|
+
const tested = await runtime.run(["mcp", "test", MCP_SERVER_NAME]);
|
|
77
|
+
const current = tested.exitCode === 0 && EXPECTED_HERMES_TOOLS.every((tool) => tested.stdout.includes(tool));
|
|
78
|
+
return current
|
|
79
|
+
? { status: "current" }
|
|
80
|
+
: {
|
|
81
|
+
status: "conflict",
|
|
82
|
+
detail: "The existing 'ast' registration does not expose the expected structural tools.",
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
register(runtime, context) {
|
|
86
|
+
return runtime.run([
|
|
87
|
+
"mcp",
|
|
88
|
+
"add",
|
|
89
|
+
MCP_SERVER_NAME,
|
|
90
|
+
"--command",
|
|
91
|
+
context.nodeExecutable,
|
|
92
|
+
"--args",
|
|
93
|
+
context.serverEntryPath,
|
|
94
|
+
], "\n");
|
|
95
|
+
},
|
|
96
|
+
registrationAccepted(result) {
|
|
97
|
+
return result.exitCode === 0 && /saved\s+'ast'/i.test(result.stdout);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
export const AGENT_TARGETS = [
|
|
101
|
+
{
|
|
102
|
+
id: "claude",
|
|
103
|
+
label: "Claude Code",
|
|
104
|
+
command: "claude",
|
|
105
|
+
skillTarget: "claude",
|
|
106
|
+
mcp: claudeMcp,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: "hermes",
|
|
110
|
+
label: "Hermes",
|
|
111
|
+
command: "hermes",
|
|
112
|
+
skillTarget: "hermes",
|
|
113
|
+
mcp: hermesMcp,
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
export const AGENT_IDS = AGENT_TARGETS.map((target) => target.id);
|
|
117
|
+
export function getAgentTarget(id) {
|
|
118
|
+
const target = AGENT_TARGETS.find((candidate) => candidate.id === id);
|
|
119
|
+
if (target === undefined)
|
|
120
|
+
throw new Error(`Unsupported agent target: ${id}`);
|
|
121
|
+
return target;
|
|
122
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
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 { isCooperativeInterruption, NO_REQUEST_CONTEXT, } from "./request-context.js";
|
|
6
|
+
import { findDeclarationByName, getSourceFileOrThrow, reportSymbolIndexFailure, } from "./project.js";
|
|
7
|
+
import { collectSymbolReferences } from "./references.js";
|
|
8
|
+
import { searchProjectSymbolsPage, searchProjectSymbolsPageWithIndex, sourceFileSymbols, symbolMatchRank, } from "./symbols.js";
|
|
9
|
+
export const EXPLORE_DEFAULT_LIMIT = 20;
|
|
10
|
+
export const EXPLORE_DEFAULT_REFERENCE_LIMIT = 20;
|
|
11
|
+
export const EXPLORE_DEFAULT_MAX_BYTES = 64 * 1024;
|
|
12
|
+
export const EXPLORE_MAX_BYTES = 1024 * 1024;
|
|
13
|
+
function matchesQuery(record, query) {
|
|
14
|
+
const normalizedQuery = query.toLowerCase();
|
|
15
|
+
return (record.name.toLowerCase().includes(normalizedQuery) ||
|
|
16
|
+
record.symbol_path.toLowerCase().includes(normalizedQuery) ||
|
|
17
|
+
record.selector.toLowerCase().includes(normalizedQuery));
|
|
18
|
+
}
|
|
19
|
+
function sortRecords(records, query) {
|
|
20
|
+
return records.sort((left, right) => {
|
|
21
|
+
if (query) {
|
|
22
|
+
const rank = symbolMatchRank(query, {
|
|
23
|
+
symbolPath: left.symbol_path,
|
|
24
|
+
name: left.name,
|
|
25
|
+
line: left.line,
|
|
26
|
+
}) -
|
|
27
|
+
symbolMatchRank(query, {
|
|
28
|
+
symbolPath: right.symbol_path,
|
|
29
|
+
name: right.name,
|
|
30
|
+
line: right.line,
|
|
31
|
+
});
|
|
32
|
+
if (rank !== 0)
|
|
33
|
+
return rank;
|
|
34
|
+
}
|
|
35
|
+
return (left.file.localeCompare(right.file) ||
|
|
36
|
+
left.line - right.line ||
|
|
37
|
+
left.symbol_path.localeCompare(right.symbol_path) ||
|
|
38
|
+
left.kind.localeCompare(right.kind));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function recordFromOutlineSymbol(sourceFile, projectRoot, symbol) {
|
|
42
|
+
return {
|
|
43
|
+
file: path.relative(projectRoot, sourceFile.getFilePath()).replaceAll("\\", "/"),
|
|
44
|
+
symbol_path: symbol.symbolPath,
|
|
45
|
+
selector: `${symbol.symbolPath}@${symbol.startLine}`,
|
|
46
|
+
name: symbol.name,
|
|
47
|
+
kind: symbol.kind,
|
|
48
|
+
line: symbol.startLine,
|
|
49
|
+
signature: symbol.signature,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function fileRecords(sourceFile, projectRoot, requestContext) {
|
|
53
|
+
const outline = buildFileOutline(sourceFile, requestContext);
|
|
54
|
+
if (outline.symbols.length > 0) {
|
|
55
|
+
return outline.symbols.map((symbol) => recordFromOutlineSymbol(sourceFile, projectRoot, symbol));
|
|
56
|
+
}
|
|
57
|
+
return sourceFileSymbols(sourceFile, projectRoot, requestContext);
|
|
58
|
+
}
|
|
59
|
+
async function projectRecords(context, request, requestContext) {
|
|
60
|
+
const { project, projectRoot } = context;
|
|
61
|
+
if (request.filePath) {
|
|
62
|
+
const sourceFile = getSourceFileOrThrow(project, request.filePath);
|
|
63
|
+
const file = path.relative(projectRoot, sourceFile.getFilePath()).replaceAll("\\", "/");
|
|
64
|
+
const requestedSymbolPath = request.symbolPath;
|
|
65
|
+
if (requestedSymbolPath) {
|
|
66
|
+
const node = findDeclarationByName(sourceFile, requestedSymbolPath);
|
|
67
|
+
const record = fileRecords(sourceFile, projectRoot, requestContext).find((candidate) => candidate.symbol_path === requestedSymbolPath ||
|
|
68
|
+
candidate.selector === requestedSymbolPath ||
|
|
69
|
+
(candidate.symbol_path === requestedSymbolPath.split("@")[0] &&
|
|
70
|
+
candidate.line === node.getStartLineNumber()));
|
|
71
|
+
if (!record) {
|
|
72
|
+
throw new Error(`Symbol "${requestedSymbolPath}" could not be projected from ${file}.`);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
context,
|
|
76
|
+
route: "symbol",
|
|
77
|
+
file,
|
|
78
|
+
symbol: record.selector,
|
|
79
|
+
records: [record],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const records = fileRecords(sourceFile, projectRoot, requestContext)
|
|
83
|
+
.filter((record) => !request.query || matchesQuery(record, request.query))
|
|
84
|
+
.filter((record) => !request.kinds || request.kinds.includes(record.kind));
|
|
85
|
+
return {
|
|
86
|
+
context,
|
|
87
|
+
route: "file",
|
|
88
|
+
file,
|
|
89
|
+
symbol: null,
|
|
90
|
+
records: sortRecords(records, request.query),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (!request.query) {
|
|
94
|
+
throw new Error("ast_explore requires query, file_path, or both file_path and symbol_path.");
|
|
95
|
+
}
|
|
96
|
+
let effectiveContext = context;
|
|
97
|
+
const indexedPage = await searchProjectSymbolsPageWithIndex(project, projectRoot, context.status.project, context.symbolIndex, context.symbolIndexReady, {
|
|
98
|
+
query: request.query,
|
|
99
|
+
kinds: request.kinds,
|
|
100
|
+
fileFilter: request.fileFilter,
|
|
101
|
+
}, request.offset, request.limit, async (reason) => {
|
|
102
|
+
effectiveContext = (await reportSymbolIndexFailure(projectRoot, reason)) ?? effectiveContext;
|
|
103
|
+
}, requestContext);
|
|
104
|
+
return {
|
|
105
|
+
context: effectiveContext,
|
|
106
|
+
route: "query",
|
|
107
|
+
file: null,
|
|
108
|
+
symbol: null,
|
|
109
|
+
page: indexedPage ??
|
|
110
|
+
searchProjectSymbolsPage(effectiveContext.project, effectiveContext.projectRoot, {
|
|
111
|
+
query: request.query,
|
|
112
|
+
kinds: request.kinds,
|
|
113
|
+
fileFilter: request.fileFilter,
|
|
114
|
+
}, request.offset, request.limit, requestContext),
|
|
115
|
+
records: [],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function projectSymbol(detail, record) {
|
|
119
|
+
const base = { file: record.file, selector: record.selector, kind: record.kind };
|
|
120
|
+
if (detail === "selectors")
|
|
121
|
+
return base;
|
|
122
|
+
if (detail === "summary" || detail === "context") {
|
|
123
|
+
return { ...base, signature: record.signature };
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
...base,
|
|
127
|
+
symbol_path: record.symbol_path,
|
|
128
|
+
name: record.name,
|
|
129
|
+
line: record.line,
|
|
130
|
+
signature: record.signature,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function jsonBytes(value) {
|
|
134
|
+
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
135
|
+
}
|
|
136
|
+
function effectiveExpansion(request) {
|
|
137
|
+
return {
|
|
138
|
+
source: request.includeSource ?? (request.detail === "context" || request.detail === "full"),
|
|
139
|
+
references: request.includeReferences ?? request.detail === "full",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export async function buildExploreContext(context, request, requestContext = NO_REQUEST_CONTEXT) {
|
|
143
|
+
requestContext.checkpoint();
|
|
144
|
+
const routed = await projectRecords(context, request, requestContext);
|
|
145
|
+
requestContext.checkpoint();
|
|
146
|
+
const effectiveContext = routed.context;
|
|
147
|
+
const page = routed.page ?? paginate(routed.records, request.offset, request.limit);
|
|
148
|
+
const expansion = effectiveExpansion(request);
|
|
149
|
+
const unresolved = [];
|
|
150
|
+
const evidence = [];
|
|
151
|
+
for (const record of page.items) {
|
|
152
|
+
requestContext.checkpoint();
|
|
153
|
+
let sourceFile;
|
|
154
|
+
try {
|
|
155
|
+
sourceFile = getSourceFileOrThrow(effectiveContext.project, record.file);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
unresolved.push({ selector: record.selector, reason: "source_unresolved" });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
let node;
|
|
162
|
+
try {
|
|
163
|
+
node = findDeclarationByName(sourceFile, record.selector);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
unresolved.push({ selector: record.selector, reason: "source_unresolved" });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
let source;
|
|
170
|
+
let references;
|
|
171
|
+
if (expansion.source) {
|
|
172
|
+
source = nodeSourceWithLocation(node, effectiveContext.projectRoot, requestContext);
|
|
173
|
+
}
|
|
174
|
+
if (expansion.references) {
|
|
175
|
+
try {
|
|
176
|
+
references = collectSymbolReferences(node, effectiveContext.projectRoot, record.selector, true, request.referenceDetail, 0, request.referenceLimit, requestContext);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (isCooperativeInterruption(error))
|
|
180
|
+
throw error;
|
|
181
|
+
unresolved.push({ selector: record.selector, reason: "references_unresolved" });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (source || references) {
|
|
185
|
+
evidence.push({
|
|
186
|
+
selector: record.selector,
|
|
187
|
+
...(source ? { source } : {}),
|
|
188
|
+
...(references ? { references } : {}),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
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);
|
|
203
|
+
}
|
|
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));
|
|
208
|
+
}
|
|
209
|
+
result = createResult(effectiveContext, request, routed.route, routed.file, routed.symbol, returnedSymbols, returnedEvidence, page.total, unresolved, reason);
|
|
210
|
+
}
|
|
211
|
+
requestContext.checkpoint();
|
|
212
|
+
const usedBytes = jsonBytes(result);
|
|
213
|
+
return {
|
|
214
|
+
...result,
|
|
215
|
+
budget: { ...result.budget, used_bytes: usedBytes },
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function createResult(context, request, route, file, symbol, symbols, evidence, total, unresolved, reason) {
|
|
219
|
+
const hasMore = request.offset + symbols.length < total;
|
|
220
|
+
const nextOffset = hasMore ? request.offset + symbols.length : null;
|
|
221
|
+
const expansion = effectiveExpansion(request);
|
|
222
|
+
const evidenceComplete = unresolved.length === 0 &&
|
|
223
|
+
evidence.length >= (expansion.source || expansion.references ? symbols.length : 0);
|
|
224
|
+
const symbolsComplete = !hasMore;
|
|
225
|
+
return {
|
|
226
|
+
route,
|
|
227
|
+
query: request.query ?? null,
|
|
228
|
+
file,
|
|
229
|
+
symbol,
|
|
230
|
+
detail: request.detail,
|
|
231
|
+
symbols,
|
|
232
|
+
evidence,
|
|
233
|
+
offset: request.offset,
|
|
234
|
+
limit: request.limit,
|
|
235
|
+
total,
|
|
236
|
+
has_more: hasMore,
|
|
237
|
+
next_offset: nextOffset,
|
|
238
|
+
freshness: {
|
|
239
|
+
state: context.status.state,
|
|
240
|
+
causes: context.status.causes,
|
|
241
|
+
checked_at: context.status.lastSuccessfulSyncAt,
|
|
242
|
+
},
|
|
243
|
+
completeness: {
|
|
244
|
+
complete: symbolsComplete && evidenceComplete,
|
|
245
|
+
symbols_complete: symbolsComplete,
|
|
246
|
+
evidence_complete: evidenceComplete,
|
|
247
|
+
unresolved,
|
|
248
|
+
},
|
|
249
|
+
budget: {
|
|
250
|
+
max_records: request.limit,
|
|
251
|
+
max_bytes: request.maxBytes,
|
|
252
|
+
max_depth: null,
|
|
253
|
+
max_edges: null,
|
|
254
|
+
max_invocations: 1,
|
|
255
|
+
used_bytes: 0,
|
|
256
|
+
},
|
|
257
|
+
truncation: {
|
|
258
|
+
truncated: reason !== null || hasMore,
|
|
259
|
+
reason: reason ?? (hasMore ? "record_limit" : null),
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { DiagnosticCategory, ts } from "ts-morph";
|
|
3
|
-
|
|
3
|
+
import { NO_REQUEST_CONTEXT } from "./request-context.js";
|
|
4
|
+
export function normalizeDiagnostic(diagnostic, projectRoot, requestContext = NO_REQUEST_CONTEXT) {
|
|
5
|
+
requestContext.checkpoint();
|
|
4
6
|
const sourceFile = diagnostic.getSourceFile();
|
|
5
7
|
const start = diagnostic.getStart();
|
|
6
8
|
const location = sourceFile && start !== undefined ? sourceFile.getLineAndColumnAtPos(start) : undefined;
|
|
@@ -21,14 +23,16 @@ function identity(diagnostic) {
|
|
|
21
23
|
diagnostic.message,
|
|
22
24
|
]);
|
|
23
25
|
}
|
|
24
|
-
function subtract(candidates, baseline) {
|
|
26
|
+
function subtract(candidates, baseline, requestContext) {
|
|
25
27
|
const counts = new Map();
|
|
26
28
|
for (const diagnostic of baseline) {
|
|
29
|
+
requestContext.checkpoint();
|
|
27
30
|
const key = identity(diagnostic);
|
|
28
31
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
29
32
|
}
|
|
30
33
|
const difference = [];
|
|
31
34
|
for (const diagnostic of candidates) {
|
|
35
|
+
requestContext.checkpoint();
|
|
32
36
|
const key = identity(diagnostic);
|
|
33
37
|
const remaining = counts.get(key) ?? 0;
|
|
34
38
|
if (remaining > 0) {
|
|
@@ -40,9 +44,10 @@ function subtract(candidates, baseline) {
|
|
|
40
44
|
}
|
|
41
45
|
return difference;
|
|
42
46
|
}
|
|
43
|
-
export function compareDiagnostics(before, after) {
|
|
44
|
-
|
|
45
|
-
const
|
|
47
|
+
export function compareDiagnostics(before, after, requestContext = NO_REQUEST_CONTEXT) {
|
|
48
|
+
requestContext.checkpoint();
|
|
49
|
+
const added = subtract(after, before, requestContext);
|
|
50
|
+
const removed = subtract(before, after, requestContext);
|
|
46
51
|
return {
|
|
47
52
|
added,
|
|
48
53
|
removed,
|