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.
Files changed (57) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +63 -14
  3. package/SECURITY.md +31 -0
  4. package/dist/cli.js +0 -0
  5. package/dist/index.js +129 -10
  6. package/dist/server.js +25 -2
  7. package/dist/services/agent-setup.js +36 -117
  8. package/dist/services/agent-targets.js +122 -0
  9. package/dist/services/context-builder.js +262 -0
  10. package/dist/services/diagnostics.js +10 -5
  11. package/dist/services/file-fingerprints.js +104 -0
  12. package/dist/services/file-snapshot.js +103 -0
  13. package/dist/services/impact.js +334 -0
  14. package/dist/services/operations.js +343 -254
  15. package/dist/services/outline.js +94 -21
  16. package/dist/services/pagination.js +10 -4
  17. package/dist/services/project-operation-scheduler.js +475 -0
  18. package/dist/services/project-status.js +134 -52
  19. package/dist/services/project-watcher.js +230 -0
  20. package/dist/services/project.js +646 -66
  21. package/dist/services/public-errors.js +255 -0
  22. package/dist/services/references.js +54 -0
  23. package/dist/services/relationships.js +1198 -0
  24. package/dist/services/request-context.js +42 -0
  25. package/dist/services/runtime-activity.js +65 -0
  26. package/dist/services/runtime-logger.js +50 -0
  27. package/dist/services/runtime-policy.js +47 -0
  28. package/dist/services/setup-wizard.js +4 -7
  29. package/dist/services/shutdown.js +93 -0
  30. package/dist/services/symbol-index-limits.js +5 -0
  31. package/dist/services/symbol-index-order.js +13 -0
  32. package/dist/services/symbol-index-policy.js +91 -0
  33. package/dist/services/symbol-index-sqlite.js +850 -0
  34. package/dist/services/symbol-index.js +447 -0
  35. package/dist/services/symbols.js +430 -47
  36. package/dist/services/test-candidates.js +209 -0
  37. package/dist/services/workspace.js +18 -7
  38. package/dist/tools/apply_operation.js +8 -4
  39. package/dist/tools/explore.js +176 -0
  40. package/dist/tools/find_references.js +17 -55
  41. package/dist/tools/get_diagnostics.js +10 -7
  42. package/dist/tools/get_file.js +107 -0
  43. package/dist/tools/get_impact.js +134 -0
  44. package/dist/tools/get_operation_preview.js +7 -4
  45. package/dist/tools/get_outline.js +8 -6
  46. package/dist/tools/get_project_status.js +77 -8
  47. package/dist/tools/get_symbol_source.js +8 -6
  48. package/dist/tools/list_files.js +11 -6
  49. package/dist/tools/rename_symbol.js +6 -4
  50. package/dist/tools/replace_symbol_body.js +6 -4
  51. package/dist/tools/result.js +27 -17
  52. package/dist/tools/scaffold_class.js +6 -4
  53. package/dist/tools/search_symbols.js +15 -63
  54. package/docs/support.md +80 -0
  55. package/package.json +18 -6
  56. package/skills/structural-code-editing/SKILL.md +42 -8
  57. 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 "./setup-wizard.js";
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(`${AGENT_DEFINITIONS[agent].label} ${operation} failed with exit code ${result.exitCode}.`, "AGENT_COMMAND_FAILED", { agent, operation, output });
113
+ return new AgentSetupError(`${getAgentTarget(agent).label} ${operation} failed with exit code ${result.exitCode}.`, "AGENT_COMMAND_FAILED", { agent, operation, output });
132
114
  }
133
- function isMissingClaudeMcp(output) {
134
- return (/\bno\s+mcp\s+server\b/i.test(output) &&
135
- /(?:\bfound\b|\bnamed?\b)/i.test(output) &&
136
- /\bast\b/i.test(output));
137
- }
138
- async function inspectClaudeMcp(detection, serverEntryPath, nodeExecutable, environment, timeoutMs) {
139
- const result = await runCommand(detection.executable, ["mcp", "get", MCP_SERVER_NAME], {
140
- environment,
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
- return agent === "claude"
195
- ? inspectClaudeMcp(detection, serverEntryPath, nodeExecutable, environment, timeoutMs)
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
- ], { environment, timeoutMs });
211
- if (added.exitCode !== 0) {
212
- throw commandFailure("claude", "MCP registration", added);
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 configureHermesMcp(detection, serverEntryPath, nodeExecutable, environment, timeoutMs) {
220
- const added = await runCommand(detection.executable, ["mcp", "add", MCP_SERVER_NAME, "--command", nodeExecutable, "--args", serverEntryPath], { environment, timeoutMs, input: "\n" });
221
- if (added.exitCode !== 0 || !/saved\s+'ast'/i.test(added.stdout)) {
222
- throw commandFailure("hermes", "MCP registration", added);
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 inspectHermesMcp(detection, environment, timeoutMs);
148
+ const verified = await inspectMcp(agent, detection, serverEntryPath, nodeExecutable, environment, timeoutMs);
225
149
  if (verified.status !== "current") {
226
- throw new AgentSetupError("Hermes saved the MCP registration but verification did not expose the expected tools.", "AGENT_VERIFICATION_FAILED", { agent: "hermes", inspection: verified });
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 = AGENT_DEFINITIONS[id];
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(`${AGENT_DEFINITIONS[agent].label} is not installed or is not available in PATH.`, "AGENT_NOT_INSTALLED", { agent });
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 ${AGENT_DEFINITIONS[conflict.agent].label} MCP registration: ${conflict.detail}`, "AGENT_MCP_CONFLICT", { agent: conflict.agent });
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 === 2 ? "all" : requested[0];
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
- if (agent === "claude") {
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
- export function normalizeDiagnostic(diagnostic, projectRoot) {
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
- const added = subtract(after, before);
45
- const removed = subtract(before, after);
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,