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
@@ -0,0 +1,104 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ function isMissingFileError(error) {
5
+ return (typeof error === "object" &&
6
+ error !== null &&
7
+ "code" in error &&
8
+ (error.code === "ENOENT" || error.code === "ENOTDIR"));
9
+ }
10
+ function resolveCandidatePath(filePath) {
11
+ try {
12
+ return fs.realpathSync(filePath);
13
+ }
14
+ catch (error) {
15
+ if (!isMissingFileError(error))
16
+ throw error;
17
+ return path.resolve(filePath);
18
+ }
19
+ }
20
+ function assertRegularFile(filePath) {
21
+ const stats = fs.statSync(filePath, { bigint: true });
22
+ if (!stats.isFile()) {
23
+ throw new Error(`Cannot fingerprint non-file path: ${filePath}`);
24
+ }
25
+ return stats;
26
+ }
27
+ function metadataMatches(previous, current) {
28
+ return (previous.size_bytes === current.size_bytes &&
29
+ previous.mtime_ns === current.mtime_ns &&
30
+ previous.device === current.device &&
31
+ previous.inode === current.inode);
32
+ }
33
+ export function hashBytes(content) {
34
+ return createHash("sha256").update(content).digest("hex");
35
+ }
36
+ export function captureFileFingerprint(filePath, previous, options = {}) {
37
+ const canonicalPath = fs.realpathSync(filePath);
38
+ const stats = assertRegularFile(canonicalPath);
39
+ const metadata = {
40
+ size_bytes: Number(stats.size),
41
+ mtime_ns: stats.mtimeNs.toString(),
42
+ device: stats.dev.toString(),
43
+ inode: stats.ino.toString(),
44
+ };
45
+ if (!Number.isSafeInteger(metadata.size_bytes)) {
46
+ throw new Error(`File size is outside the safe integer range: ${canonicalPath}`);
47
+ }
48
+ if (previous && metadataMatches(previous, metadata) && !options.verifyContentHash) {
49
+ return {
50
+ ...previous,
51
+ file_path: canonicalPath,
52
+ };
53
+ }
54
+ return {
55
+ file_path: canonicalPath,
56
+ ...metadata,
57
+ content_hash: hashBytes(fs.readFileSync(canonicalPath)),
58
+ };
59
+ }
60
+ export function collectFileFingerprints(filePaths, previous = new Map(), options = {}) {
61
+ const files = new Map();
62
+ const missing = new Set();
63
+ for (const filePath of new Set(filePaths)) {
64
+ const candidatePath = resolveCandidatePath(filePath);
65
+ try {
66
+ const previousFingerprint = previous.get(candidatePath);
67
+ const fingerprint = captureFileFingerprint(candidatePath, previousFingerprint, options);
68
+ files.set(fingerprint.file_path, fingerprint);
69
+ }
70
+ catch (error) {
71
+ if (!isMissingFileError(error))
72
+ throw error;
73
+ missing.add(candidatePath);
74
+ }
75
+ }
76
+ return {
77
+ files,
78
+ missing: [...missing].sort(),
79
+ };
80
+ }
81
+ export function compareFileFingerprints(previous, current) {
82
+ const added = [];
83
+ const changed = [];
84
+ const deleted = [];
85
+ const unchanged = [];
86
+ const paths = [...new Set([...previous.keys(), ...current.keys()])].sort();
87
+ for (const filePath of paths) {
88
+ const before = previous.get(filePath);
89
+ const after = current.get(filePath);
90
+ if (!before && after) {
91
+ added.push(filePath);
92
+ }
93
+ else if (before && !after) {
94
+ deleted.push(filePath);
95
+ }
96
+ else if (before && after && before.content_hash !== after.content_hash) {
97
+ changed.push(filePath);
98
+ }
99
+ else {
100
+ unchanged.push(filePath);
101
+ }
102
+ }
103
+ return { added, changed, deleted, unchanged };
104
+ }
@@ -0,0 +1,103 @@
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 { NO_REQUEST_CONTEXT } from "./request-context.js";
6
+ import { hashBytes } from "./workspace.js";
7
+ export const DEFAULT_FILE_LINE_LIMIT = 200;
8
+ export const MAX_FILE_LINE_LIMIT = 500;
9
+ function assertBoundedRange({ offset, limit }) {
10
+ if (!Number.isInteger(offset) || offset < 0) {
11
+ throw new Error("File offset must be a non-negative integer.");
12
+ }
13
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_FILE_LINE_LIMIT) {
14
+ throw new Error(`File limit must be an integer between 1 and ${MAX_FILE_LINE_LIMIT}.`);
15
+ }
16
+ }
17
+ function rejectTraversalPath(filePath) {
18
+ const normalized = filePath.replaceAll("\\", "/");
19
+ const segments = normalized.split("/");
20
+ const isWindowsAbsolute = /^[A-Za-z]:\//.test(normalized) || normalized.startsWith("//");
21
+ if (segments.includes("..")) {
22
+ throw new Error("File path traversal is not allowed.");
23
+ }
24
+ if (isWindowsAbsolute && !path.isAbsolute(filePath)) {
25
+ throw new Error("Windows absolute paths are not valid on this host.");
26
+ }
27
+ }
28
+ function isContained(root, candidate) {
29
+ const relative = path.relative(root, candidate);
30
+ return (relative.length > 0 &&
31
+ relative !== ".." &&
32
+ !relative.startsWith(`..${path.sep}`) &&
33
+ !path.isAbsolute(relative));
34
+ }
35
+ function projectRelative(root, filePath) {
36
+ return path.relative(root, filePath).split(path.sep).join("/");
37
+ }
38
+ function decodeUtf8(bytes, filePath) {
39
+ try {
40
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
41
+ }
42
+ catch {
43
+ throw new Error(`Source file "${filePath}" is not valid UTF-8.`);
44
+ }
45
+ }
46
+ function splitLines(source) {
47
+ if (source.length === 0)
48
+ return [];
49
+ const lines = source.split(/\r\n|\n|\r/);
50
+ if (source.endsWith("\n") || source.endsWith("\r"))
51
+ lines.pop();
52
+ return lines;
53
+ }
54
+ function compilerSnapshotHashes(sourceText) {
55
+ const sourceBytes = Buffer.from(sourceText, "utf8");
56
+ const hashes = [hashBytes(sourceBytes)];
57
+ if (sourceText.charCodeAt(0) !== 0xfeff) {
58
+ hashes.push(hashBytes(Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), sourceBytes])));
59
+ }
60
+ return hashes;
61
+ }
62
+ export async function readFileSnapshot(project, projectRoot, filePath, options, requestContext = NO_REQUEST_CONTEXT) {
63
+ requestContext.checkpoint();
64
+ assertBoundedRange(options);
65
+ rejectTraversalPath(filePath);
66
+ const sourceFile = getSourceFileOrThrow(project, filePath);
67
+ requestContext.checkpoint();
68
+ const canonicalRoot = await realpath(projectRoot);
69
+ const sourcePath = sourceFile.getFilePath();
70
+ const canonicalFile = await realpath(sourcePath);
71
+ requestContext.checkpoint();
72
+ if (!isContained(canonicalRoot, canonicalFile)) {
73
+ throw new Error("Source file resolves outside the configured project root.");
74
+ }
75
+ const stat = await lstat(canonicalFile);
76
+ requestContext.checkpoint();
77
+ if (!stat.isFile()) {
78
+ throw new Error("Source file is not a regular file.");
79
+ }
80
+ const bytes = await readFile(canonicalFile);
81
+ requestContext.checkpoint();
82
+ const source = decodeUtf8(bytes, projectRelative(canonicalRoot, canonicalFile));
83
+ const fileHash = hashBytes(bytes);
84
+ const compilerHashes = compilerSnapshotHashes(sourceFile.getFullText());
85
+ const snapshotState = compilerHashes.includes(fileHash) ? "fresh" : "stale";
86
+ const allLines = splitLines(source);
87
+ const selectedLines = allLines.slice(options.offset, options.offset + options.limit);
88
+ requestContext.checkpoint();
89
+ return {
90
+ file: projectRelative(canonicalRoot, canonicalFile),
91
+ range: {
92
+ offset: options.offset,
93
+ limit: options.limit,
94
+ total_lines: allLines.length,
95
+ },
96
+ lines: selectedLines.map((text, index) => ({
97
+ line: options.offset + index + 1,
98
+ text,
99
+ })),
100
+ file_hash: fileHash,
101
+ snapshot_state: snapshotState,
102
+ };
103
+ }
@@ -0,0 +1,334 @@
1
+ import path from "node:path";
2
+ import { createTruncationMetadata, createSourceLocation, } from "./read-contracts.js";
3
+ import { getSourceFileOrThrow } from "./project.js";
4
+ import { findLocatedDeclaration } from "./symbols.js";
5
+ import { NO_REQUEST_CONTEXT } from "./request-context.js";
6
+ import { createCompilerRelationshipResolver, RELATIONSHIP_EDGE_KINDS, } from "./relationships.js";
7
+ export const IMPACT_DIRECTIONS = Object.freeze(["incoming", "outgoing", "both"]);
8
+ export const DEFAULT_IMPACT_MAX_DEPTH = 3;
9
+ export const DEFAULT_IMPACT_MAX_NODES = 100;
10
+ export const DEFAULT_IMPACT_MAX_EDGES = 200;
11
+ export const MAX_IMPACT_DEPTH = 32;
12
+ export const MAX_IMPACT_NODES = 1_000;
13
+ export const MAX_IMPACT_EDGES = 5_000;
14
+ export function isExactImpactEdge(edge) {
15
+ return (typeof edge === "object" &&
16
+ edge !== null &&
17
+ edge.provenance === "compiler" &&
18
+ edge.confidence === "exact" &&
19
+ edge.resolution === "resolved" &&
20
+ edge.compiler_authoritative === true &&
21
+ typeof edge.freshness === "object" &&
22
+ edge.freshness !== null &&
23
+ edge.freshness.state === "fresh" &&
24
+ Array.isArray(edge.freshness.causes) &&
25
+ edge.freshness.causes.length === 0);
26
+ }
27
+ export function assertExactImpactEvidence(impact) {
28
+ if (typeof impact !== "object" || impact === null || !Array.isArray(impact.edges)) {
29
+ throw new Error("Impact evidence is invalid.");
30
+ }
31
+ if (impact.incomplete) {
32
+ throw new Error("Test candidates require complete impact evidence.");
33
+ }
34
+ if (!impact.edges.every(isExactImpactEdge)) {
35
+ throw new Error("Test candidates require fresh exact compiler impact evidence.");
36
+ }
37
+ }
38
+ const IMPACT_PROBE_WORK_ITEMS = 1_024;
39
+ const IMPACT_RELATIONSHIP_WORK_ITEMS = 100_000;
40
+ function assertIntegerInRange(value, minimum, maximum, name) {
41
+ if (!Number.isInteger(value) || value < minimum || value > maximum) {
42
+ throw new Error(`${name} must be an integer from ${minimum} to ${maximum}.`);
43
+ }
44
+ return value;
45
+ }
46
+ function assertDirection(value) {
47
+ if (!IMPACT_DIRECTIONS.includes(value)) {
48
+ throw new Error("Impact direction is invalid.");
49
+ }
50
+ return value;
51
+ }
52
+ function normalizeRelationshipKinds(kinds) {
53
+ if (kinds === undefined)
54
+ return [...RELATIONSHIP_EDGE_KINDS];
55
+ if (!Array.isArray(kinds))
56
+ throw new Error("Impact relationship_kinds must be an array.");
57
+ const unique = new Set();
58
+ for (const kind of kinds) {
59
+ if (!RELATIONSHIP_EDGE_KINDS.includes(kind)) {
60
+ throw new Error("Impact relationship kind is invalid.");
61
+ }
62
+ unique.add(kind);
63
+ }
64
+ return [...unique].sort((left, right) => RELATIONSHIP_EDGE_KINDS.indexOf(left) - RELATIONSHIP_EDGE_KINDS.indexOf(right));
65
+ }
66
+ function normalizeOptions(options = {}) {
67
+ if (typeof options !== "object" || options === null) {
68
+ throw new Error("Impact traversal options are invalid.");
69
+ }
70
+ return {
71
+ direction: assertDirection(options.direction ?? "both"),
72
+ max_depth: assertIntegerInRange(options.max_depth ?? DEFAULT_IMPACT_MAX_DEPTH, 0, MAX_IMPACT_DEPTH, "Impact max_depth"),
73
+ max_nodes: assertIntegerInRange(options.max_nodes ?? DEFAULT_IMPACT_MAX_NODES, 1, MAX_IMPACT_NODES, "Impact max_nodes"),
74
+ max_edges: assertIntegerInRange(options.max_edges ?? DEFAULT_IMPACT_MAX_EDGES, 1, MAX_IMPACT_EDGES, "Impact max_edges"),
75
+ relationship_kinds: normalizeRelationshipKinds(options.relationship_kinds),
76
+ };
77
+ }
78
+ function projectRelativeFile(projectRoot, filePath) {
79
+ return path.relative(projectRoot, filePath).split(path.sep).join("/");
80
+ }
81
+ function nodeRange(node) {
82
+ const sourceFile = node.getSourceFile();
83
+ const start = sourceFile.getLineAndColumnAtPos(node.getStart());
84
+ const end = sourceFile.getLineAndColumnAtPos(Math.max(node.getStart(), node.getEnd() - 1));
85
+ return {
86
+ start_line: start.line,
87
+ start_column: start.column,
88
+ end_line: end.line,
89
+ end_column: end.column,
90
+ };
91
+ }
92
+ export function resolveImpactRoot(project, projectRoot, request, requestContext = NO_REQUEST_CONTEXT) {
93
+ requestContext.checkpoint();
94
+ if (typeof request !== "object" ||
95
+ request === null ||
96
+ typeof request.file_path !== "string" ||
97
+ typeof request.symbol_path !== "string") {
98
+ throw new Error("Impact root is invalid.");
99
+ }
100
+ const sourceFile = getSourceFileOrThrow(project, request.file_path);
101
+ const located = findLocatedDeclaration(sourceFile, request.symbol_path, () => requestContext.checkpoint());
102
+ const node = located.node;
103
+ const location = createSourceLocation(projectRelativeFile(projectRoot, sourceFile.getFilePath()), nodeRange(node));
104
+ const { file, ...range } = location;
105
+ return Object.freeze({
106
+ file,
107
+ symbol_path: located.symbolPath,
108
+ selector: `${located.symbolPath}@${located.line}`,
109
+ range,
110
+ });
111
+ }
112
+ function endpointKey(endpoint) {
113
+ return `${endpoint.file}\u0000${endpoint.symbol_path}\u0000${endpoint.selector}`;
114
+ }
115
+ function edgeOrder(left, right) {
116
+ return (left.kind.localeCompare(right.kind) ||
117
+ left.source.file.localeCompare(right.source.file) ||
118
+ left.source.selector.localeCompare(right.source.selector) ||
119
+ left.target.file.localeCompare(right.target.file) ||
120
+ left.target.selector.localeCompare(right.target.selector) ||
121
+ left.relationship_id.localeCompare(right.relationship_id));
122
+ }
123
+ function neighborOrder(left, right) {
124
+ return (left.endpoint.file.localeCompare(right.endpoint.file) ||
125
+ left.endpoint.selector.localeCompare(right.endpoint.selector) ||
126
+ left.endpoint.symbol_path.localeCompare(right.endpoint.symbol_path) ||
127
+ edgeOrder(left.edge, right.edge));
128
+ }
129
+ function collectNeighbors(current, edges, options, requestContext) {
130
+ const currentKey = endpointKey(current);
131
+ const kinds = new Set(options.relationship_kinds);
132
+ const neighbors = new Map();
133
+ const orderedEdges = [...edges].sort(edgeOrder);
134
+ for (const edge of orderedEdges) {
135
+ requestContext.checkpoint();
136
+ if (!kinds.has(edge.kind))
137
+ continue;
138
+ const matchesSource = endpointKey(edge.source) === currentKey;
139
+ const matchesTarget = endpointKey(edge.target) === currentKey;
140
+ if ((options.direction === "outgoing" || options.direction === "both") && matchesSource) {
141
+ neighbors.set(`${edge.relationship_id}\u0000${endpointKey(edge.target)}`, {
142
+ edge,
143
+ endpoint: edge.target,
144
+ });
145
+ }
146
+ if ((options.direction === "incoming" || options.direction === "both") && matchesTarget) {
147
+ neighbors.set(`${edge.relationship_id}\u0000${endpointKey(edge.source)}`, {
148
+ edge,
149
+ endpoint: edge.source,
150
+ });
151
+ }
152
+ }
153
+ return [...neighbors.values()].sort(neighborOrder);
154
+ }
155
+ function traverseWithNeighborProvider(root, neighborsFor, options = {}, requestContext = NO_REQUEST_CONTEXT) {
156
+ requestContext.checkpoint();
157
+ if (typeof root !== "object" || root === null) {
158
+ throw new Error("Impact traversal input is invalid.");
159
+ }
160
+ const normalized = normalizeOptions(options);
161
+ const nodes = new Map([
162
+ [endpointKey(root), { endpoint: root, depth: 0, direct: false }],
163
+ ]);
164
+ const selectedEdges = new Map();
165
+ const queue = [{ key: endpointKey(root), endpoint: root, depth: 0 }];
166
+ const truncationReasons = new Set();
167
+ let maxDepthReached = 0;
168
+ const classifyObservedNeighbor = (current, neighbor, edgeBlocked) => {
169
+ const nextKey = endpointKey(neighbor.endpoint);
170
+ const isKnownNode = nodes.has(nextKey);
171
+ if (edgeBlocked)
172
+ truncationReasons.add("edge_limit");
173
+ if (!isKnownNode && current.depth + 1 > normalized.max_depth) {
174
+ truncationReasons.add("depth_limit");
175
+ }
176
+ if (!isKnownNode && nodes.size >= normalized.max_nodes) {
177
+ truncationReasons.add("record_limit");
178
+ }
179
+ };
180
+ while (queue.length > 0) {
181
+ requestContext.checkpoint();
182
+ const current = queue.shift();
183
+ const remainingEdges = normalized.max_edges - selectedEdges.size;
184
+ if (remainingEdges <= 0) {
185
+ const probe = neighborsFor(current.endpoint, normalized, requestContext, 1, true, undefined, new Set(selectedEdges.keys()), IMPACT_PROBE_WORK_ITEMS);
186
+ for (const neighbor of probe.neighbors) {
187
+ classifyObservedNeighbor(current, neighbor, true);
188
+ }
189
+ if (probe.incomplete && !probe.workLimitReached)
190
+ truncationReasons.add("edge_limit");
191
+ continue;
192
+ }
193
+ const restrictionReason = current.depth >= normalized.max_depth
194
+ ? "depth_limit"
195
+ : nodes.size >= normalized.max_nodes
196
+ ? "record_limit"
197
+ : undefined;
198
+ const allowedNeighborKeys = restrictionReason ? new Set(nodes.keys()) : undefined;
199
+ const batch = neighborsFor(current.endpoint, normalized, requestContext, remainingEdges, false, allowedNeighborKeys, new Set(selectedEdges.keys()));
200
+ if (batch.excludedNeighbors && restrictionReason)
201
+ truncationReasons.add(restrictionReason);
202
+ if (batch.workLimitReached)
203
+ truncationReasons.add("record_limit");
204
+ let skippedNewNode = false;
205
+ for (const neighbor of batch.neighbors) {
206
+ requestContext.checkpoint();
207
+ const nextKey = endpointKey(neighbor.endpoint);
208
+ const nextDepth = current.depth + 1;
209
+ const isKnownNode = nodes.has(nextKey);
210
+ if (nextDepth > normalized.max_depth && !isKnownNode) {
211
+ truncationReasons.add("depth_limit");
212
+ continue;
213
+ }
214
+ if (!isKnownNode && nodes.size >= normalized.max_nodes) {
215
+ truncationReasons.add("record_limit");
216
+ skippedNewNode = true;
217
+ continue;
218
+ }
219
+ if (!selectedEdges.has(neighbor.edge.relationship_id)) {
220
+ if (selectedEdges.size >= normalized.max_edges) {
221
+ truncationReasons.add("edge_limit");
222
+ continue;
223
+ }
224
+ selectedEdges.set(neighbor.edge.relationship_id, neighbor.edge);
225
+ }
226
+ if (isKnownNode)
227
+ continue;
228
+ nodes.set(nextKey, {
229
+ endpoint: neighbor.endpoint,
230
+ depth: nextDepth,
231
+ direct: nextDepth === 1,
232
+ });
233
+ queue.push({ key: nextKey, endpoint: neighbor.endpoint, depth: nextDepth });
234
+ maxDepthReached = Math.max(maxDepthReached, nextDepth);
235
+ }
236
+ if (restrictionReason) {
237
+ if (batch.incomplete && !batch.workLimitReached)
238
+ truncationReasons.add("edge_limit");
239
+ const probe = neighborsFor(current.endpoint, normalized, requestContext, 1, true, undefined, new Set(selectedEdges.keys()), IMPACT_PROBE_WORK_ITEMS);
240
+ for (const neighbor of probe.neighbors) {
241
+ classifyObservedNeighbor(current, neighbor, selectedEdges.size >= normalized.max_edges);
242
+ }
243
+ continue;
244
+ }
245
+ if ((skippedNewNode || nodes.size >= normalized.max_nodes) &&
246
+ selectedEdges.size < normalized.max_edges) {
247
+ const knownBatch = neighborsFor(current.endpoint, normalized, requestContext, normalized.max_edges - selectedEdges.size, false, new Set(nodes.keys()), new Set(selectedEdges.keys()));
248
+ if (knownBatch.excludedNeighbors)
249
+ truncationReasons.add("record_limit");
250
+ if (knownBatch.workLimitReached)
251
+ truncationReasons.add("record_limit");
252
+ for (const neighbor of knownBatch.neighbors) {
253
+ requestContext.checkpoint();
254
+ if (selectedEdges.size >= normalized.max_edges) {
255
+ truncationReasons.add("edge_limit");
256
+ break;
257
+ }
258
+ selectedEdges.set(neighbor.edge.relationship_id, neighbor.edge);
259
+ }
260
+ if (knownBatch.incomplete && !knownBatch.workLimitReached) {
261
+ truncationReasons.add("edge_limit");
262
+ }
263
+ }
264
+ else if (batch.incomplete && !batch.workLimitReached) {
265
+ truncationReasons.add("edge_limit");
266
+ const overflowProbe = neighborsFor(current.endpoint, normalized, requestContext, 1, true, undefined, new Set(selectedEdges.keys()), IMPACT_PROBE_WORK_ITEMS);
267
+ for (const neighbor of overflowProbe.neighbors) {
268
+ classifyObservedNeighbor(current, neighbor, true);
269
+ }
270
+ }
271
+ }
272
+ const orderedReasons = ["depth_limit", "record_limit", "edge_limit"].filter((reason) => truncationReasons.has(reason));
273
+ const truncated = orderedReasons.length > 0;
274
+ requestContext.checkpoint();
275
+ return {
276
+ root,
277
+ direction: normalized.direction,
278
+ relationship_kinds: normalized.relationship_kinds,
279
+ nodes: [...nodes.values()].sort((left, right) => left.depth - right.depth ||
280
+ left.endpoint.file.localeCompare(right.endpoint.file) ||
281
+ left.endpoint.selector.localeCompare(right.endpoint.selector) ||
282
+ left.endpoint.symbol_path.localeCompare(right.endpoint.symbol_path)),
283
+ edges: [...selectedEdges.values()].sort(edgeOrder),
284
+ visited_nodes: nodes.size,
285
+ visited_edges: selectedEdges.size,
286
+ max_depth_reached: maxDepthReached,
287
+ max_depth: normalized.max_depth,
288
+ max_nodes: normalized.max_nodes,
289
+ max_edges: normalized.max_edges,
290
+ incomplete: truncated,
291
+ truncation: createTruncationMetadata(truncated, truncated ? orderedReasons[0] : null),
292
+ truncation_reasons: orderedReasons,
293
+ };
294
+ }
295
+ export function traverseImpact(root, edges, options = {}, requestContext = NO_REQUEST_CONTEXT) {
296
+ if (!Array.isArray(edges))
297
+ throw new Error("Impact traversal input is invalid.");
298
+ return traverseWithNeighborProvider(root, (current, normalized, _context, maxEdges, _stopAfterFirst, allowedNeighborKeys, excludedRelationshipIds) => {
299
+ const allNeighbors = collectNeighbors(current, edges, normalized, requestContext).filter((neighbor) => !excludedRelationshipIds?.has(neighbor.edge.relationship_id));
300
+ const neighbors = allowedNeighborKeys
301
+ ? allNeighbors.filter((neighbor) => allowedNeighborKeys.has(endpointKey(neighbor.endpoint)))
302
+ : allNeighbors;
303
+ return {
304
+ neighbors: neighbors.slice(0, maxEdges),
305
+ incomplete: neighbors.length > maxEdges,
306
+ excludedNeighbors: allowedNeighborKeys
307
+ ? allNeighbors.some((neighbor) => !allowedNeighborKeys.has(endpointKey(neighbor.endpoint)))
308
+ : false,
309
+ workLimitReached: false,
310
+ };
311
+ }, options, requestContext);
312
+ }
313
+ export function traverseCompilerImpact(project, projectRoot, root, freshness, options = {}, requestContext = NO_REQUEST_CONTEXT) {
314
+ const resolver = createCompilerRelationshipResolver(project, projectRoot, freshness, requestContext);
315
+ return traverseWithNeighborProvider(root, (current, normalized, _context, maxEdges, stopAfterFirst, allowedNeighborKeys, excludedRelationshipIds, maxWorkItems) => {
316
+ const resolution = resolver.edgesFor(current, {
317
+ direction: normalized.direction,
318
+ relationship_kinds: normalized.relationship_kinds,
319
+ max_edges: maxEdges,
320
+ max_work_items: maxWorkItems ?? IMPACT_RELATIONSHIP_WORK_ITEMS,
321
+ allowed_neighbor_keys: allowedNeighborKeys ? [...allowedNeighborKeys] : undefined,
322
+ excluded_relationship_ids: excludedRelationshipIds
323
+ ? [...excludedRelationshipIds]
324
+ : undefined,
325
+ stop_after_first: stopAfterFirst,
326
+ });
327
+ return {
328
+ neighbors: collectNeighbors(current, resolution.edges, normalized, requestContext),
329
+ incomplete: resolution.incomplete,
330
+ excludedNeighbors: resolution.excluded_neighbors,
331
+ workLimitReached: resolution.work_limit_reached,
332
+ };
333
+ }, options, requestContext);
334
+ }