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/dist/services/project.js
CHANGED
|
@@ -2,7 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { Project } from "ts-morph";
|
|
4
4
|
import { findDeclaration } from "./symbols.js";
|
|
5
|
-
import { createConfigSnapshot } from "./workspace.js";
|
|
5
|
+
import { createConfigSnapshot, createWorkspaceSnapshot } from "./workspace.js";
|
|
6
|
+
import { createInitialProjectStatus, createProjectIdentity, projectStatusToProjection, transitionProjectStatus, } from "./project-status.js";
|
|
6
7
|
const MAX_PROJECT_SESSIONS = 8;
|
|
7
8
|
const projectSessions = new Map();
|
|
8
9
|
const projectRoots = new WeakMap();
|
|
@@ -25,7 +26,8 @@ function buildProjectContext(tsConfigFilePath) {
|
|
|
25
26
|
});
|
|
26
27
|
const projectRoot = path.dirname(tsConfigFilePath);
|
|
27
28
|
projectRoots.set(project, projectRoot);
|
|
28
|
-
|
|
29
|
+
const status = createInitialProjectStatus(createProjectIdentity({ projectRoot, configPath: tsConfigFilePath }), { sourceCount: project.getSourceFiles().length });
|
|
30
|
+
return { project, projectRoot, tsConfigFilePath, status };
|
|
29
31
|
}
|
|
30
32
|
export function createFreshProject(projectRoot) {
|
|
31
33
|
return buildProjectContext(resolveTsConfigPath(projectRoot));
|
|
@@ -50,41 +52,113 @@ function getOrCreateSession(projectRoot) {
|
|
|
50
52
|
configDigest: createConfigSnapshot(tsConfigFilePath).digest,
|
|
51
53
|
queue: Promise.resolve(),
|
|
52
54
|
activeOperations: 0,
|
|
55
|
+
runningOperations: 0,
|
|
56
|
+
queuedOperations: 0,
|
|
53
57
|
lastAccessedAt: Date.now(),
|
|
54
58
|
};
|
|
55
59
|
projectSessions.set(tsConfigFilePath, session);
|
|
56
60
|
return session;
|
|
57
61
|
}
|
|
62
|
+
function getQueueStatus(session) {
|
|
63
|
+
return {
|
|
64
|
+
state: session.runningOperations > 0 ? "running" : session.queuedOperations > 0 ? "queued" : "idle",
|
|
65
|
+
active_operations: session.runningOperations,
|
|
66
|
+
queued_operations: session.queuedOperations,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
58
69
|
async function synchronizeSession(session) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
let syncFailureCause = "compiler_rebuild";
|
|
71
|
+
let currentConfigDigest;
|
|
72
|
+
try {
|
|
73
|
+
currentConfigDigest = createConfigSnapshot(session.context.tsConfigFilePath).digest;
|
|
74
|
+
if (currentConfigDigest !== session.configDigest) {
|
|
75
|
+
let status = transitionProjectStatus(session.context.status, { type: "config_changed" });
|
|
76
|
+
status = transitionProjectStatus(status, { type: "compiler_rebuild_started" });
|
|
77
|
+
const rebuilt = buildProjectContext(session.context.tsConfigFilePath);
|
|
78
|
+
session.context = { ...rebuilt, status };
|
|
79
|
+
}
|
|
80
|
+
const { project, tsConfigFilePath } = session.context;
|
|
81
|
+
syncFailureCause = "source_change";
|
|
82
|
+
const refreshSourceFiles = async () => {
|
|
83
|
+
project.addSourceFilesFromTsConfig(tsConfigFilePath);
|
|
84
|
+
await Promise.all(project.getSourceFiles().map((sourceFile) => sourceFile.refreshFromFileSystem()));
|
|
85
|
+
};
|
|
86
|
+
await refreshSourceFiles();
|
|
87
|
+
const snapshot = createWorkspaceSnapshot(session.context);
|
|
88
|
+
if (snapshot.configDigest !== currentConfigDigest) {
|
|
89
|
+
syncFailureCause = "config_change";
|
|
90
|
+
throw new Error("Project configuration changed during synchronization. Retry the operation.");
|
|
91
|
+
}
|
|
92
|
+
await refreshSourceFiles();
|
|
93
|
+
const verificationSnapshot = createWorkspaceSnapshot(session.context);
|
|
94
|
+
if (verificationSnapshot.configDigest !== currentConfigDigest) {
|
|
95
|
+
syncFailureCause = "config_change";
|
|
96
|
+
throw new Error("Project configuration changed during synchronization. Retry the operation.");
|
|
97
|
+
}
|
|
98
|
+
if (verificationSnapshot.sourceDigest !== snapshot.sourceDigest) {
|
|
99
|
+
syncFailureCause = "source_change";
|
|
100
|
+
throw new Error("Project source changed during synchronization. Retry the operation.");
|
|
101
|
+
}
|
|
102
|
+
const status = transitionProjectStatus(session.context.status, {
|
|
103
|
+
type: "sync_succeeded",
|
|
104
|
+
sourceCount: verificationSnapshot.sourceFileCount,
|
|
105
|
+
indexedCount: 0,
|
|
106
|
+
sourceSnapshotFingerprint: `source_${verificationSnapshot.sourceDigest}`,
|
|
107
|
+
configSnapshotFingerprint: `config_${verificationSnapshot.configDigest}`,
|
|
108
|
+
canonicalSnapshotFingerprint: `snapshot_${verificationSnapshot.digest}`,
|
|
109
|
+
at: new Date().toISOString(),
|
|
110
|
+
});
|
|
111
|
+
session.context = { ...session.context, status };
|
|
112
|
+
session.configDigest = verificationSnapshot.configDigest;
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
const status = transitionProjectStatus(session.context.status, {
|
|
116
|
+
type: "sync_failed",
|
|
117
|
+
cause: syncFailureCause,
|
|
118
|
+
error: error instanceof Error ? error.message : String(error),
|
|
119
|
+
});
|
|
120
|
+
session.context = { ...session.context, status };
|
|
121
|
+
throw error;
|
|
64
122
|
}
|
|
65
|
-
const { project, tsConfigFilePath } = session.context;
|
|
66
|
-
project.addSourceFilesFromTsConfig(tsConfigFilePath);
|
|
67
|
-
await Promise.all(project.getSourceFiles().map((sourceFile) => sourceFile.refreshFromFileSystem()));
|
|
68
123
|
}
|
|
69
|
-
|
|
70
|
-
const session = getOrCreateSession(projectRoot);
|
|
124
|
+
async function runSessionWithSyncPolicy(session, operation, allowSynchronizationFailure) {
|
|
71
125
|
session.activeOperations += 1;
|
|
126
|
+
session.queuedOperations += 1;
|
|
72
127
|
let release;
|
|
73
128
|
const previous = session.queue;
|
|
74
129
|
session.queue = new Promise((resolve) => {
|
|
75
130
|
release = resolve;
|
|
76
131
|
});
|
|
77
132
|
await previous;
|
|
133
|
+
session.queuedOperations -= 1;
|
|
134
|
+
session.runningOperations += 1;
|
|
78
135
|
try {
|
|
79
|
-
|
|
136
|
+
try {
|
|
137
|
+
await synchronizeSession(session);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (!allowSynchronizationFailure)
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
80
143
|
session.lastAccessedAt = Date.now();
|
|
81
144
|
return await operation(session.context);
|
|
82
145
|
}
|
|
83
146
|
finally {
|
|
147
|
+
session.runningOperations -= 1;
|
|
84
148
|
session.activeOperations -= 1;
|
|
85
149
|
release?.();
|
|
86
150
|
}
|
|
87
151
|
}
|
|
152
|
+
export async function withProject(projectRoot, operation) {
|
|
153
|
+
return runSessionWithSyncPolicy(getOrCreateSession(projectRoot), operation, false);
|
|
154
|
+
}
|
|
155
|
+
export async function getProjectStatus(projectRoot) {
|
|
156
|
+
const session = getOrCreateSession(projectRoot);
|
|
157
|
+
return runSessionWithSyncPolicy(session, (context) => ({
|
|
158
|
+
...projectStatusToProjection(context.status),
|
|
159
|
+
operation_queue: getQueueStatus(session),
|
|
160
|
+
}), true);
|
|
161
|
+
}
|
|
88
162
|
// Compatibility API for internal scripts. MCP tools should use withProject().
|
|
89
163
|
export function getProject(projectRoot) {
|
|
90
164
|
return createFreshProject(projectRoot).project;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
export const SNAPSHOT_STATES = Object.freeze([
|
|
2
|
+
"fresh",
|
|
3
|
+
"pending",
|
|
4
|
+
"stale",
|
|
5
|
+
"rebuilding",
|
|
6
|
+
"degraded",
|
|
7
|
+
]);
|
|
8
|
+
export function isSnapshotState(value) {
|
|
9
|
+
return SNAPSHOT_STATES.some((state) => state === value);
|
|
10
|
+
}
|
|
11
|
+
export const FRESHNESS_CAUSES = Object.freeze([
|
|
12
|
+
"source_change",
|
|
13
|
+
"config_change",
|
|
14
|
+
"index_failure",
|
|
15
|
+
"watcher_failure",
|
|
16
|
+
"compiler_rebuild",
|
|
17
|
+
]);
|
|
18
|
+
export function isFreshnessCause(value) {
|
|
19
|
+
return FRESHNESS_CAUSES.some((cause) => cause === value);
|
|
20
|
+
}
|
|
21
|
+
function assertIntegerAtLeast(value, minimum, name) {
|
|
22
|
+
if (!Number.isInteger(value) || value < minimum) {
|
|
23
|
+
throw new Error(`${name} must be an integer greater than or equal to ${minimum}.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function createFileRange(offset, limit, totalLines) {
|
|
27
|
+
assertIntegerAtLeast(offset, 0, "File range offset");
|
|
28
|
+
assertIntegerAtLeast(limit, 1, "File range limit");
|
|
29
|
+
assertIntegerAtLeast(totalLines, 0, "File range total_lines");
|
|
30
|
+
return { offset, limit, total_lines: totalLines };
|
|
31
|
+
}
|
|
32
|
+
function projectRelativePath(file) {
|
|
33
|
+
let unquoted = file.trim();
|
|
34
|
+
const quote = unquoted[0];
|
|
35
|
+
if (unquoted.length >= 2 &&
|
|
36
|
+
(quote === '"' || quote === "'" || quote === "`") &&
|
|
37
|
+
unquoted.endsWith(quote)) {
|
|
38
|
+
unquoted = unquoted.slice(1, -1).trim();
|
|
39
|
+
}
|
|
40
|
+
const normalized = unquoted.replaceAll("\\", "/");
|
|
41
|
+
const relative = normalized.startsWith("./") ? normalized.slice(2) : normalized;
|
|
42
|
+
const segments = relative.split("/");
|
|
43
|
+
const isWindowsAbsolute = /^[A-Za-z]:/.test(relative);
|
|
44
|
+
if (relative.length === 0 ||
|
|
45
|
+
relative.startsWith("/") ||
|
|
46
|
+
isWindowsAbsolute ||
|
|
47
|
+
segments.some((segment) => segment.length === 0 || segment === ".." || segment === ".")) {
|
|
48
|
+
throw new Error("Source location file must be project-relative.");
|
|
49
|
+
}
|
|
50
|
+
return relative;
|
|
51
|
+
}
|
|
52
|
+
export function createSourceLocation(file, range) {
|
|
53
|
+
if (typeof file !== "string" || typeof range !== "object" || range === null) {
|
|
54
|
+
throw new Error("Source location input is invalid.");
|
|
55
|
+
}
|
|
56
|
+
assertIntegerAtLeast(range.start_line, 1, "Source location start_line");
|
|
57
|
+
if (range.start_column !== undefined) {
|
|
58
|
+
assertIntegerAtLeast(range.start_column, 1, "Source location start_column");
|
|
59
|
+
}
|
|
60
|
+
if (range.end_line !== undefined) {
|
|
61
|
+
assertIntegerAtLeast(range.end_line, range.start_line, "Source location end_line");
|
|
62
|
+
}
|
|
63
|
+
if (range.end_column !== undefined) {
|
|
64
|
+
assertIntegerAtLeast(range.end_column, 1, "Source location end_column");
|
|
65
|
+
}
|
|
66
|
+
if (range.end_line === range.start_line &&
|
|
67
|
+
range.start_column !== undefined &&
|
|
68
|
+
range.end_column !== undefined &&
|
|
69
|
+
range.end_column < range.start_column) {
|
|
70
|
+
throw new Error("Source location end_column must not precede start_column.");
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
file: projectRelativePath(file),
|
|
74
|
+
start_line: range.start_line,
|
|
75
|
+
...(range.start_column === undefined ? {} : { start_column: range.start_column }),
|
|
76
|
+
...(range.end_line === undefined ? {} : { end_line: range.end_line }),
|
|
77
|
+
...(range.end_column === undefined ? {} : { end_column: range.end_column }),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export const TRUNCATION_REASONS = Object.freeze([
|
|
81
|
+
"record_limit",
|
|
82
|
+
"byte_limit",
|
|
83
|
+
"depth_limit",
|
|
84
|
+
"edge_limit",
|
|
85
|
+
"invocation_limit",
|
|
86
|
+
"serialization_limit",
|
|
87
|
+
]);
|
|
88
|
+
export function isTruncationReason(value) {
|
|
89
|
+
return TRUNCATION_REASONS.some((reason) => reason === value);
|
|
90
|
+
}
|
|
91
|
+
export function createTruncationMetadata(truncated, reason = null) {
|
|
92
|
+
if (reason !== null && !isTruncationReason(reason)) {
|
|
93
|
+
throw new Error(`Unknown truncation reason: ${String(reason)}.`);
|
|
94
|
+
}
|
|
95
|
+
if (truncated !== (reason !== null)) {
|
|
96
|
+
throw new Error("A truncated result requires a machine-readable reason, and a complete result does not.");
|
|
97
|
+
}
|
|
98
|
+
return { truncated, reason };
|
|
99
|
+
}
|
|
100
|
+
export function isJsonValue(value) {
|
|
101
|
+
const ancestors = new Set();
|
|
102
|
+
function visit(candidate) {
|
|
103
|
+
if (candidate === null)
|
|
104
|
+
return true;
|
|
105
|
+
if (typeof candidate === "string" || typeof candidate === "boolean")
|
|
106
|
+
return true;
|
|
107
|
+
if (typeof candidate === "number") {
|
|
108
|
+
return Number.isFinite(candidate) && !Object.is(candidate, -0);
|
|
109
|
+
}
|
|
110
|
+
if (typeof candidate !== "object")
|
|
111
|
+
return false;
|
|
112
|
+
if (ancestors.has(candidate))
|
|
113
|
+
return false;
|
|
114
|
+
ancestors.add(candidate);
|
|
115
|
+
try {
|
|
116
|
+
if ("toJSON" in candidate)
|
|
117
|
+
return false;
|
|
118
|
+
if (Array.isArray(candidate)) {
|
|
119
|
+
if (Object.getPrototypeOf(candidate) !== Array.prototype)
|
|
120
|
+
return false;
|
|
121
|
+
if (Object.getOwnPropertySymbols(candidate).length > 0)
|
|
122
|
+
return false;
|
|
123
|
+
for (let index = 0; index < candidate.length; index += 1) {
|
|
124
|
+
if (!Object.hasOwn(candidate, index))
|
|
125
|
+
return false;
|
|
126
|
+
const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index));
|
|
127
|
+
if (descriptor === undefined || !("value" in descriptor) || !visit(descriptor.value)) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return Object.getOwnPropertyNames(candidate).every((key) => {
|
|
132
|
+
if (key === "length")
|
|
133
|
+
return true;
|
|
134
|
+
const index = Number(key);
|
|
135
|
+
return (Number.isInteger(index) &&
|
|
136
|
+
index >= 0 &&
|
|
137
|
+
index < candidate.length &&
|
|
138
|
+
String(index) === key);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const prototype = Object.getPrototypeOf(candidate);
|
|
142
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
143
|
+
return false;
|
|
144
|
+
if (Object.getOwnPropertySymbols(candidate).length > 0)
|
|
145
|
+
return false;
|
|
146
|
+
return Object.getOwnPropertyNames(candidate).every((key) => {
|
|
147
|
+
const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
|
|
148
|
+
return (descriptor !== undefined &&
|
|
149
|
+
descriptor.enumerable &&
|
|
150
|
+
"value" in descriptor &&
|
|
151
|
+
visit(descriptor.value));
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
ancestors.delete(candidate);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return visit(value);
|
|
159
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Node } from "ts-morph";
|
|
3
|
+
import { paginate } from "./pagination.js";
|
|
4
|
+
export function collectSymbolReferences(node, projectRoot, symbolPath, includeDeclaration, detail, offset, limit) {
|
|
5
|
+
if (!Node.isReferenceFindable(node)) {
|
|
6
|
+
throw new Error(`Symbol "${symbolPath}" (${node.getKindName()}) does not support semantic reference search.`);
|
|
7
|
+
}
|
|
8
|
+
const lineCache = new Map();
|
|
9
|
+
const location = (referenceNode, isDeclaration) => {
|
|
10
|
+
const referenceSource = referenceNode.getSourceFile();
|
|
11
|
+
const absoluteFile = referenceSource.getFilePath();
|
|
12
|
+
let lines = lineCache.get(absoluteFile);
|
|
13
|
+
if (!lines) {
|
|
14
|
+
lines = referenceSource.getFullText().split(/\r?\n/);
|
|
15
|
+
lineCache.set(absoluteFile, lines);
|
|
16
|
+
}
|
|
17
|
+
const position = referenceSource.getLineAndColumnAtPos(referenceNode.getStart());
|
|
18
|
+
return {
|
|
19
|
+
file: path.relative(projectRoot, absoluteFile),
|
|
20
|
+
line: position.line,
|
|
21
|
+
column: position.column,
|
|
22
|
+
kind: referenceNode.getParent()?.getKindName() ?? referenceNode.getKindName(),
|
|
23
|
+
is_declaration: isDeclaration,
|
|
24
|
+
...(detail === "context"
|
|
25
|
+
? { context: (lines[position.line - 1] ?? "").trim().slice(0, 500) }
|
|
26
|
+
: {}),
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
const references = node.findReferencesAsNodes().map((reference) => location(reference, false));
|
|
30
|
+
const declaration = location(node, true);
|
|
31
|
+
const allLocations = includeDeclaration ? [declaration, ...references] : references;
|
|
32
|
+
allLocations.sort((left, right) => `${left.file}:${left.line}:${left.column}:${left.is_declaration ? 0 : 1}`.localeCompare(`${right.file}:${right.line}:${right.column}:${right.is_declaration ? 0 : 1}`));
|
|
33
|
+
const affectedFiles = [...new Set([declaration, ...references].map((item) => item.file))].sort();
|
|
34
|
+
const page = paginate(allLocations, offset, limit);
|
|
35
|
+
return {
|
|
36
|
+
symbol: symbolPath,
|
|
37
|
+
include_declaration: includeDeclaration,
|
|
38
|
+
declaration_count: 1,
|
|
39
|
+
reference_count: references.length,
|
|
40
|
+
affected_files: affectedFiles,
|
|
41
|
+
references: page.items,
|
|
42
|
+
offset: page.offset,
|
|
43
|
+
limit: page.limit,
|
|
44
|
+
total: page.total,
|
|
45
|
+
has_more: page.has_more,
|
|
46
|
+
next_offset: page.next_offset,
|
|
47
|
+
};
|
|
48
|
+
}
|
package/dist/services/symbols.js
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { Node, } from "ts-morph";
|
|
3
|
+
import { buildFileOutline } from "./outline.js";
|
|
4
|
+
export function sourceFileSymbols(sourceFile, projectRoot) {
|
|
5
|
+
const file = path.relative(projectRoot, sourceFile.getFilePath());
|
|
6
|
+
const signatures = new Map(buildFileOutline(sourceFile).symbols.map((symbol) => [
|
|
7
|
+
`${symbol.symbolPath}@${symbol.startLine}`,
|
|
8
|
+
symbol.signature,
|
|
9
|
+
]));
|
|
10
|
+
return collectSymbols(sourceFile).map((symbol) => ({
|
|
11
|
+
file,
|
|
12
|
+
symbol_path: symbol.symbolPath,
|
|
13
|
+
selector: `${symbol.symbolPath}@${symbol.line}`,
|
|
14
|
+
name: symbol.name,
|
|
15
|
+
kind: symbol.kind,
|
|
16
|
+
line: symbol.line,
|
|
17
|
+
signature: signatures.get(`${symbol.symbolPath}@${symbol.line}`) ?? symbol.node.getText(),
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
2
20
|
export function symbolMatchRank(query, symbol) {
|
|
3
21
|
const normalizedQuery = query.toLowerCase();
|
|
4
22
|
const normalizedPath = symbol.symbolPath.toLowerCase();
|
|
@@ -15,6 +33,47 @@ export function symbolMatchRank(query, symbol) {
|
|
|
15
33
|
}
|
|
16
34
|
return 4;
|
|
17
35
|
}
|
|
36
|
+
export function searchProjectSymbols(project, projectRoot, options) {
|
|
37
|
+
const normalizedQuery = options.query.toLowerCase();
|
|
38
|
+
const normalizedFileFilter = options.fileFilter?.toLowerCase();
|
|
39
|
+
const kindSet = options.kinds ? new Set(options.kinds) : undefined;
|
|
40
|
+
const matches = [];
|
|
41
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
42
|
+
const file = path.relative(projectRoot, sourceFile.getFilePath());
|
|
43
|
+
if (normalizedFileFilter && !file.toLowerCase().includes(normalizedFileFilter))
|
|
44
|
+
continue;
|
|
45
|
+
const matchingSymbols = sourceFileSymbols(sourceFile, projectRoot).filter((symbol) => {
|
|
46
|
+
return ((!kindSet || kindSet.has(symbol.kind)) &&
|
|
47
|
+
(symbol.name.toLowerCase().includes(normalizedQuery) ||
|
|
48
|
+
symbol.symbol_path.toLowerCase().includes(normalizedQuery) ||
|
|
49
|
+
symbol.selector.toLowerCase().includes(normalizedQuery)));
|
|
50
|
+
});
|
|
51
|
+
if (matchingSymbols.length === 0)
|
|
52
|
+
continue;
|
|
53
|
+
for (const symbol of matchingSymbols) {
|
|
54
|
+
matches.push(symbol);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
matches.sort((left, right) => {
|
|
58
|
+
const rank = symbolMatchRank(options.query, {
|
|
59
|
+
symbolPath: left.symbol_path,
|
|
60
|
+
name: left.name,
|
|
61
|
+
line: left.line,
|
|
62
|
+
}) -
|
|
63
|
+
symbolMatchRank(options.query, {
|
|
64
|
+
symbolPath: right.symbol_path,
|
|
65
|
+
name: right.name,
|
|
66
|
+
line: right.line,
|
|
67
|
+
});
|
|
68
|
+
if (rank !== 0)
|
|
69
|
+
return rank;
|
|
70
|
+
return (left.file.localeCompare(right.file) ||
|
|
71
|
+
left.line - right.line ||
|
|
72
|
+
left.symbol_path.localeCompare(right.symbol_path) ||
|
|
73
|
+
left.kind.localeCompare(right.kind));
|
|
74
|
+
});
|
|
75
|
+
return matches;
|
|
76
|
+
}
|
|
18
77
|
function namedNode(node) {
|
|
19
78
|
if (Node.isConstructorDeclaration(node))
|
|
20
79
|
return "constructor";
|
|
@@ -65,12 +65,15 @@ export function createConfigSnapshot(rootConfigPath) {
|
|
|
65
65
|
export function createWorkspaceSnapshot(context) {
|
|
66
66
|
const sourcePaths = context.project
|
|
67
67
|
.getSourceFiles()
|
|
68
|
-
.map((sourceFile) => sourceFile.getFilePath());
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
.map((sourceFile) => canonicalPath(sourceFile.getFilePath()));
|
|
69
|
+
const configSnapshot = createConfigSnapshot(context.tsConfigFilePath);
|
|
70
|
+
const sourceFiles = new Map();
|
|
71
|
+
for (const filePath of [...new Set(sourcePaths)].sort()) {
|
|
72
|
+
const digest = hashBytes(fs.readFileSync(filePath));
|
|
73
|
+
sourceFiles.set(filePath, digest);
|
|
74
|
+
}
|
|
75
|
+
const files = new Map(sourceFiles);
|
|
76
|
+
for (const filePath of configSnapshot.files) {
|
|
74
77
|
const digest = hashBytes(fs.readFileSync(filePath));
|
|
75
78
|
const previous = files.get(filePath);
|
|
76
79
|
if (previous && previous !== digest) {
|
|
@@ -80,6 +83,9 @@ export function createWorkspaceSnapshot(context) {
|
|
|
80
83
|
}
|
|
81
84
|
return {
|
|
82
85
|
digest: hashWorkspaceFiles(files),
|
|
86
|
+
sourceDigest: hashWorkspaceFiles(sourceFiles),
|
|
87
|
+
configDigest: configSnapshot.digest,
|
|
88
|
+
sourceFileCount: sourceFiles.size,
|
|
83
89
|
fileCount: files.size,
|
|
84
90
|
files,
|
|
85
91
|
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { buildExploreContext, EXPLORE_DEFAULT_LIMIT, EXPLORE_DEFAULT_MAX_BYTES, EXPLORE_DEFAULT_REFERENCE_LIMIT, EXPLORE_MAX_BYTES, } from "../services/context-builder.js";
|
|
3
|
+
import { createPaginationInputSchema } from "../services/pagination.js";
|
|
4
|
+
import { withProject } from "../services/project.js";
|
|
5
|
+
import { FRESHNESS_CAUSES, SNAPSHOT_STATES, TRUNCATION_REASONS, } from "../services/read-contracts.js";
|
|
6
|
+
import { errorResult, structuredResult } from "./result.js";
|
|
7
|
+
const ExploreDetailSchema = z.enum(["selectors", "summary", "context", "full"]).default("summary");
|
|
8
|
+
const ReferenceDetailSchema = z.enum(["locations", "context"]).default("locations");
|
|
9
|
+
const ExploreInputSchema = z
|
|
10
|
+
.object({
|
|
11
|
+
project_root: z
|
|
12
|
+
.string()
|
|
13
|
+
.describe("Absolute project directory containing tsconfig.json, or the config path."),
|
|
14
|
+
query: z.string().min(1).optional().describe("Bounded case-insensitive symbol query."),
|
|
15
|
+
file_path: z.string().optional().describe("Known project-relative or unambiguous source file."),
|
|
16
|
+
symbol_path: z
|
|
17
|
+
.string()
|
|
18
|
+
.min(1)
|
|
19
|
+
.optional()
|
|
20
|
+
.describe("Exact symbol path or selector, valid only with file_path."),
|
|
21
|
+
kinds: z.array(z.string()).max(32).optional().describe("Optional exact syntax-kind filters."),
|
|
22
|
+
file_filter: z.string().optional().describe("Optional query-route file path substring filter."),
|
|
23
|
+
detail: ExploreDetailSchema.describe("selectors returns routing coordinates; summary adds signatures; context adds source; full adds source and references."),
|
|
24
|
+
include_source: z
|
|
25
|
+
.boolean()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe("Override detail-based source expansion for selected symbols."),
|
|
28
|
+
include_references: z
|
|
29
|
+
.boolean()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe("Override detail-based compiler reference expansion for selected symbols."),
|
|
32
|
+
reference_detail: ReferenceDetailSchema.describe("Reference locations or bounded source-line context."),
|
|
33
|
+
reference_limit: z
|
|
34
|
+
.number()
|
|
35
|
+
.int()
|
|
36
|
+
.min(1)
|
|
37
|
+
.max(100)
|
|
38
|
+
.default(EXPLORE_DEFAULT_REFERENCE_LIMIT)
|
|
39
|
+
.describe("Maximum compiler references per selected symbol."),
|
|
40
|
+
...createPaginationInputSchema(EXPLORE_DEFAULT_LIMIT),
|
|
41
|
+
max_bytes: z
|
|
42
|
+
.number()
|
|
43
|
+
.int()
|
|
44
|
+
.min(1024)
|
|
45
|
+
.max(EXPLORE_MAX_BYTES)
|
|
46
|
+
.default(EXPLORE_DEFAULT_MAX_BYTES)
|
|
47
|
+
.describe("Maximum serialized logical result size in UTF-8 bytes."),
|
|
48
|
+
})
|
|
49
|
+
.refine((input) => Boolean(input.query || input.file_path), {
|
|
50
|
+
message: "Provide query, file_path, or both file_path and symbol_path.",
|
|
51
|
+
path: ["query"],
|
|
52
|
+
})
|
|
53
|
+
.refine((input) => !input.symbol_path || Boolean(input.file_path), {
|
|
54
|
+
message: "symbol_path requires file_path so the exact declaration can be resolved.",
|
|
55
|
+
path: ["symbol_path"],
|
|
56
|
+
});
|
|
57
|
+
const ExploreSymbolSchema = z.object({
|
|
58
|
+
file: z.string(),
|
|
59
|
+
selector: z.string(),
|
|
60
|
+
kind: z.string(),
|
|
61
|
+
symbol_path: z.string().optional(),
|
|
62
|
+
name: z.string().optional(),
|
|
63
|
+
line: z.number().int().positive().optional(),
|
|
64
|
+
signature: z.string().optional(),
|
|
65
|
+
});
|
|
66
|
+
const ExploreSourceSchema = z.object({
|
|
67
|
+
file: z.string(),
|
|
68
|
+
startLine: z.number().int().positive(),
|
|
69
|
+
endLine: z.number().int().positive(),
|
|
70
|
+
text: z.string(),
|
|
71
|
+
});
|
|
72
|
+
const ExploreReferenceSchema = z.object({
|
|
73
|
+
file: z.string(),
|
|
74
|
+
line: z.number().int().positive(),
|
|
75
|
+
column: z.number().int().positive(),
|
|
76
|
+
kind: z.string(),
|
|
77
|
+
is_declaration: z.boolean(),
|
|
78
|
+
context: z.string().optional(),
|
|
79
|
+
});
|
|
80
|
+
const ExploreEvidenceSchema = z.object({
|
|
81
|
+
selector: z.string(),
|
|
82
|
+
source: ExploreSourceSchema.optional(),
|
|
83
|
+
references: z
|
|
84
|
+
.object({
|
|
85
|
+
symbol: z.string(),
|
|
86
|
+
include_declaration: z.boolean(),
|
|
87
|
+
declaration_count: z.number().int().min(0),
|
|
88
|
+
reference_count: z.number().int().min(0),
|
|
89
|
+
affected_files: z.array(z.string()),
|
|
90
|
+
references: z.array(ExploreReferenceSchema),
|
|
91
|
+
offset: z.number().int().min(0),
|
|
92
|
+
limit: z.number().int().positive(),
|
|
93
|
+
total: z.number().int().min(0),
|
|
94
|
+
has_more: z.boolean(),
|
|
95
|
+
next_offset: z.number().int().min(0).nullable(),
|
|
96
|
+
})
|
|
97
|
+
.optional(),
|
|
98
|
+
});
|
|
99
|
+
const ExploreOutputSchema = z.object({
|
|
100
|
+
route: z.enum(["query", "file", "symbol"]),
|
|
101
|
+
query: z.string().nullable(),
|
|
102
|
+
file: z.string().nullable(),
|
|
103
|
+
symbol: z.string().nullable(),
|
|
104
|
+
detail: ExploreDetailSchema,
|
|
105
|
+
symbols: z.array(ExploreSymbolSchema),
|
|
106
|
+
evidence: z.array(ExploreEvidenceSchema),
|
|
107
|
+
offset: z.number().int().min(0),
|
|
108
|
+
limit: z.number().int().positive(),
|
|
109
|
+
total: z.number().int().min(0),
|
|
110
|
+
has_more: z.boolean(),
|
|
111
|
+
next_offset: z.number().int().min(0).nullable(),
|
|
112
|
+
freshness: z.object({
|
|
113
|
+
state: z.enum(SNAPSHOT_STATES),
|
|
114
|
+
causes: z.array(z.enum(FRESHNESS_CAUSES)),
|
|
115
|
+
checked_at: z.string().nullable(),
|
|
116
|
+
}),
|
|
117
|
+
completeness: z.object({
|
|
118
|
+
complete: z.boolean(),
|
|
119
|
+
symbols_complete: z.boolean(),
|
|
120
|
+
evidence_complete: z.boolean(),
|
|
121
|
+
unresolved: z.array(z.object({
|
|
122
|
+
selector: z.string(),
|
|
123
|
+
reason: z.enum(["source_unresolved", "references_unresolved"]),
|
|
124
|
+
})),
|
|
125
|
+
}),
|
|
126
|
+
budget: z.object({
|
|
127
|
+
max_records: z.number().int().positive(),
|
|
128
|
+
max_bytes: z.number().int().positive(),
|
|
129
|
+
max_depth: z.null(),
|
|
130
|
+
max_edges: z.null(),
|
|
131
|
+
max_invocations: z.number().int().positive(),
|
|
132
|
+
used_bytes: z.number().int().nonnegative(),
|
|
133
|
+
}),
|
|
134
|
+
truncation: z.object({
|
|
135
|
+
truncated: z.boolean(),
|
|
136
|
+
reason: z.enum(TRUNCATION_REASONS).nullable(),
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
export function registerExplore(server) {
|
|
140
|
+
server.registerTool("ast_explore", {
|
|
141
|
+
title: "Explore project context",
|
|
142
|
+
description: "Composes bounded structural search, exact selectors, source evidence and compiler references without weakening the primitive AST tools.",
|
|
143
|
+
inputSchema: ExploreInputSchema,
|
|
144
|
+
outputSchema: ExploreOutputSchema,
|
|
145
|
+
annotations: {
|
|
146
|
+
readOnlyHint: true,
|
|
147
|
+
destructiveHint: false,
|
|
148
|
+
idempotentHint: true,
|
|
149
|
+
openWorldHint: false,
|
|
150
|
+
},
|
|
151
|
+
}, async ({ project_root, query, file_path, symbol_path, kinds, file_filter, detail, include_source, include_references, reference_detail, reference_limit, offset, limit, max_bytes, }) => {
|
|
152
|
+
try {
|
|
153
|
+
const output = await withProject(project_root, (context) => buildExploreContext(context, {
|
|
154
|
+
query,
|
|
155
|
+
filePath: file_path,
|
|
156
|
+
symbolPath: symbol_path,
|
|
157
|
+
kinds,
|
|
158
|
+
fileFilter: file_filter,
|
|
159
|
+
detail,
|
|
160
|
+
includeSource: include_source,
|
|
161
|
+
includeReferences: include_references,
|
|
162
|
+
referenceDetail: reference_detail,
|
|
163
|
+
offset,
|
|
164
|
+
limit,
|
|
165
|
+
referenceLimit: reference_limit,
|
|
166
|
+
maxBytes: max_bytes,
|
|
167
|
+
}));
|
|
168
|
+
return structuredResult(ExploreOutputSchema.parse(output));
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
return errorResult(error);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|