ast-mcp-server 0.14.3 → 0.15.1
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 +42 -1
- package/README.md +28 -4
- package/cordis.patch.yml +1 -1
- package/dist/services/angular-templates.js +509 -0
- package/dist/services/operations.js +107 -39
- package/dist/services/project.js +46 -4
- package/dist/services/references.js +94 -5
- package/dist/services/safe-file.js +29 -0
- package/dist/services/workspace.js +9 -1
- package/dist/tools/find_references.js +43 -9
- package/dist/tools/rename_symbol.js +1 -1
- package/package.json +16 -2
- package/skills/structural-code-editing/SKILL.md +14 -0
- package/skills/structural-code-editing/references/operations.md +6 -0
- package/skills/structural-code-editing/releases.json +2 -2
|
@@ -4,6 +4,8 @@ import { open, readFile, stat } from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { createTwoFilesPatch } from "diff";
|
|
6
6
|
import { LanguageVariant, Node, ScriptTarget, SyntaxKind, ts } from "ts-morph";
|
|
7
|
+
import { collectTemplateUsages } from "./angular-templates.js";
|
|
8
|
+
import { findNonDeclarationReferences } from "./references.js";
|
|
7
9
|
import { compareObservedDiagnostics, observeDiagnostic, } from "./diagnostics.js";
|
|
8
10
|
import { probePublicationCapability, publishAuthenticated, removeOwnedPublicationEntry, rollbackOwnedCommit, snapshotHeldFile, } from "./authenticated-publication.js";
|
|
9
11
|
import { createFreshProject, findDeclarationByName, getSourceFileOrThrow, invalidateProjectIfIdle, recordProjectMutationHistory, withProjectOperation, } from "./project.js";
|
|
@@ -12,6 +14,7 @@ import { NO_REQUEST_CONTEXT } from "./request-context.js";
|
|
|
12
14
|
import { executableDeclaration } from "./symbols.js";
|
|
13
15
|
import { buildClassScaffold } from "./scaffold.js";
|
|
14
16
|
import { withWorkspaceFileLock } from "./runtime-state.js";
|
|
17
|
+
import { assertSafeOperationFile } from "./safe-file.js";
|
|
15
18
|
import { createWorkspaceSnapshot, hashBytes, hashWorkspaceFiles } from "./workspace.js";
|
|
16
19
|
const DEFAULT_OPERATION_TTL_MS = 15 * 60 * 1000;
|
|
17
20
|
const DEFAULT_MAX_OPERATIONS = 100;
|
|
@@ -104,33 +107,6 @@ function encodeUpdatedSource(originalBytes, updatedText) {
|
|
|
104
107
|
const hasUtf8Bom = originalBytes[0] === 0xef && originalBytes[1] === 0xbb && originalBytes[2] === 0xbf;
|
|
105
108
|
return hasUtf8Bom ? Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), encoded]) : encoded;
|
|
106
109
|
}
|
|
107
|
-
function assertSafeOperationFile(projectRoot, absolutePath, displayPath) {
|
|
108
|
-
const fileStat = lstatSync(absolutePath);
|
|
109
|
-
if (fileStat.isSymbolicLink()) {
|
|
110
|
-
throw new Error(`Operation target must not be a symbolic link: ${displayPath}`);
|
|
111
|
-
}
|
|
112
|
-
if (!fileStat.isFile()) {
|
|
113
|
-
throw new Error(`Operation target is not a regular file: ${displayPath}`);
|
|
114
|
-
}
|
|
115
|
-
const canonicalPath = realpathSync(absolutePath);
|
|
116
|
-
const samePath = process.platform === "win32"
|
|
117
|
-
? canonicalPath.toLowerCase() === absolutePath.toLowerCase()
|
|
118
|
-
: canonicalPath === absolutePath;
|
|
119
|
-
if (!samePath) {
|
|
120
|
-
throw new Error(`Operation target traverses a symbolic link: ${displayPath}`);
|
|
121
|
-
}
|
|
122
|
-
if (process.platform !== "win32" && fileStat.nlink !== 1) {
|
|
123
|
-
throw new Error(`Operation target must have exactly one hard link: ${displayPath}`);
|
|
124
|
-
}
|
|
125
|
-
const uid = process.getuid?.();
|
|
126
|
-
if (uid !== undefined && fileStat.uid !== uid) {
|
|
127
|
-
throw new Error(`Operation target is not owned by the current user: ${displayPath}`);
|
|
128
|
-
}
|
|
129
|
-
const relative = path.relative(projectRoot, canonicalPath);
|
|
130
|
-
if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`)) {
|
|
131
|
-
throw new Error(`Operation target escapes the project root: ${displayPath}`);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
110
|
function isCreatedFile(file) {
|
|
135
111
|
return file.originalHash === ABSENT_FILE_HASH;
|
|
136
112
|
}
|
|
@@ -178,7 +154,68 @@ function assertSafeCreationState(projectRoot, absolutePath, displayPath) {
|
|
|
178
154
|
assertSafeNewOperationFile(projectRoot, absolutePath, displayPath);
|
|
179
155
|
}
|
|
180
156
|
}
|
|
181
|
-
|
|
157
|
+
function compareFiles(left, right) {
|
|
158
|
+
return left.file < right.file ? -1 : left.file > right.file ? 1 : 0;
|
|
159
|
+
}
|
|
160
|
+
function applyEditsDescending(text, edits, file) {
|
|
161
|
+
const ordered = [...edits].sort((left, right) => left.start - right.start);
|
|
162
|
+
let previousEnd = 0;
|
|
163
|
+
for (const edit of ordered) {
|
|
164
|
+
if (!(edit.start >= previousEnd && edit.end >= edit.start && edit.end <= text.length)) {
|
|
165
|
+
throw new Error(`Auxiliary edits overlap or fall outside the file: ${file}`);
|
|
166
|
+
}
|
|
167
|
+
previousEnd = edit.end;
|
|
168
|
+
}
|
|
169
|
+
return ordered
|
|
170
|
+
.reverse()
|
|
171
|
+
.reduce((updated, edit) => updated.slice(0, edit.start) + edit.newText + updated.slice(edit.end), text);
|
|
172
|
+
}
|
|
173
|
+
/** Plans edits to files outside the compiler program, bound to the workspace snapshot hashes. */
|
|
174
|
+
async function planAuxiliaryFiles(edits, projectRoot, snapshotFiles, requestContext) {
|
|
175
|
+
const byFile = new Map();
|
|
176
|
+
for (const edit of edits) {
|
|
177
|
+
const absolutePath = realpathSync(edit.absolutePath);
|
|
178
|
+
byFile.set(absolutePath, [...(byFile.get(absolutePath) ?? []), edit]);
|
|
179
|
+
}
|
|
180
|
+
const planned = [];
|
|
181
|
+
for (const [absolutePath, fileEdits] of byFile) {
|
|
182
|
+
requestContext.checkpoint();
|
|
183
|
+
const file = path.relative(projectRoot, absolutePath);
|
|
184
|
+
assertSafeOperationFile(projectRoot, absolutePath, file);
|
|
185
|
+
const snapshotHash = snapshotFiles.get(absolutePath);
|
|
186
|
+
if (snapshotHash === undefined) {
|
|
187
|
+
throw new Error(`Affected file is missing from the workspace snapshot: ${file}`);
|
|
188
|
+
}
|
|
189
|
+
const originalBytes = await readFile(absolutePath);
|
|
190
|
+
requestContext.checkpoint();
|
|
191
|
+
const bytesHash = hashBytes(originalBytes);
|
|
192
|
+
if (bytesHash !== snapshotHash || fileEdits.some((edit) => edit.expectedHash !== bytesHash)) {
|
|
193
|
+
throw new Error(`Workspace changed while the operation was being prepared (${file}). Retry preparation.`);
|
|
194
|
+
}
|
|
195
|
+
const originalText = decodeSource(originalBytes, absolutePath);
|
|
196
|
+
const updatedText = applyEditsDescending(originalText, fileEdits, file);
|
|
197
|
+
if (updatedText === originalText)
|
|
198
|
+
continue;
|
|
199
|
+
const updatedBytes = encodeUpdatedSource(originalBytes, updatedText);
|
|
200
|
+
planned.push({
|
|
201
|
+
absolutePath,
|
|
202
|
+
file,
|
|
203
|
+
originalHash: bytesHash,
|
|
204
|
+
updatedHash: hashBytes(updatedBytes),
|
|
205
|
+
originalText,
|
|
206
|
+
updatedText,
|
|
207
|
+
originalBytes,
|
|
208
|
+
updatedBytes,
|
|
209
|
+
diff: createTwoFilesPatch(file, file, originalText, updatedText, "before", "after", {
|
|
210
|
+
context: 3,
|
|
211
|
+
}),
|
|
212
|
+
mode: (await stat(absolutePath)).mode,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return planned;
|
|
216
|
+
}
|
|
217
|
+
/** Internal seam shared by the prepare functions; exported so tests can drive `mutate` directly. */
|
|
218
|
+
export async function createPlan(projectRootInput, kind, allowNewErrors, mutate, requestContext) {
|
|
182
219
|
requestContext.checkpoint();
|
|
183
220
|
let retainedOperationId;
|
|
184
221
|
return withProjectOperation(projectRootInput, async (operationContext) => {
|
|
@@ -201,11 +238,12 @@ async function createPlan(projectRootInput, kind, allowNewErrors, mutate, reques
|
|
|
201
238
|
}
|
|
202
239
|
}
|
|
203
240
|
operationContext.checkpoint();
|
|
204
|
-
const referenceCount = mutate(context
|
|
241
|
+
const { referenceCount, auxiliaryEdits = [] } = await mutate(context, operationContext);
|
|
205
242
|
operationContext.checkpoint();
|
|
206
243
|
const afterObservations = observeProjectDiagnostics(context.project, context.projectRoot, operationContext);
|
|
207
244
|
const textChanges = collectPlannedTextChanges(context.project, context.projectRoot, originals, operationContext);
|
|
208
|
-
|
|
245
|
+
const auxiliaryFiles = await planAuxiliaryFiles(auxiliaryEdits, context.projectRoot, workspaceBefore.files, operationContext);
|
|
246
|
+
if (textChanges.length === 0 && auxiliaryFiles.length === 0) {
|
|
209
247
|
throw new Error("The structural operation produced no file changes.");
|
|
210
248
|
}
|
|
211
249
|
const comparisonWorkspace = createWorkspaceSnapshot(createFreshProject(context.tsConfigFilePath));
|
|
@@ -265,6 +303,8 @@ async function createPlan(projectRootInput, kind, allowNewErrors, mutate, reques
|
|
|
265
303
|
mode: fileStat.mode,
|
|
266
304
|
});
|
|
267
305
|
}
|
|
306
|
+
files.push(...auxiliaryFiles);
|
|
307
|
+
files.sort(compareFiles);
|
|
268
308
|
const postWorkspaceFiles = new Map(workspaceBefore.files);
|
|
269
309
|
for (const file of files) {
|
|
270
310
|
operationContext.checkpoint();
|
|
@@ -348,6 +388,22 @@ function publicOperation(record) {
|
|
|
348
388
|
preview_truncated: record.preview_truncated,
|
|
349
389
|
};
|
|
350
390
|
}
|
|
391
|
+
const TEMPLATE_ANY_CAST = "$any";
|
|
392
|
+
/** Fails closed before any edit when template evidence is incomplete or the new name would be captured. */
|
|
393
|
+
function assertTemplateRenameAllowed(coverageStatus, usages, newName) {
|
|
394
|
+
if (coverageStatus === "unfinished" || coverageStatus === "unsupported") {
|
|
395
|
+
throw new PublicOperationalError("INCOMPLETE_EVIDENCE", "Template coverage is incomplete, so the rename cannot be proven safe.");
|
|
396
|
+
}
|
|
397
|
+
if (usages.some((usage) => usage.container === "inline")) {
|
|
398
|
+
throw new PublicOperationalError("MUTATION_BLOCKED", "The member is used in an inline template, which rename does not edit.");
|
|
399
|
+
}
|
|
400
|
+
const collides = coverageStatus === "completed" &&
|
|
401
|
+
(newName === TEMPLATE_ANY_CAST ||
|
|
402
|
+
usages.some((usage) => usage.localsInScope.includes(newName)));
|
|
403
|
+
if (collides) {
|
|
404
|
+
throw new PublicOperationalError("MUTATION_BLOCKED", "The new name would collide with a template-local name or the $any cast.");
|
|
405
|
+
}
|
|
406
|
+
}
|
|
351
407
|
export async function prepareRename(args, requestContext = NO_REQUEST_CONTEXT) {
|
|
352
408
|
requestContext.checkpoint();
|
|
353
409
|
const scanner = ts.createScanner(ScriptTarget.Latest, false, LanguageVariant.Standard, args.newName);
|
|
@@ -355,21 +411,33 @@ export async function prepareRename(args, requestContext = NO_REQUEST_CONTEXT) {
|
|
|
355
411
|
if (!validIdentifier) {
|
|
356
412
|
throw new Error(`"${args.newName}" is not a valid TypeScript identifier.`);
|
|
357
413
|
}
|
|
358
|
-
return createPlan(args.projectRoot, "rename_symbol", args.allowNewErrors ?? false, (
|
|
359
|
-
const sourceFile = getSourceFileOrThrow(project, args.filePath);
|
|
414
|
+
return createPlan(args.projectRoot, "rename_symbol", args.allowNewErrors ?? false, async (context, rc) => {
|
|
415
|
+
const sourceFile = getSourceFileOrThrow(context.project, args.filePath);
|
|
360
416
|
const node = findDeclarationByName(sourceFile, args.symbolPath);
|
|
361
417
|
if (!Node.isRenameable(node)) {
|
|
362
418
|
throw new Error(`Symbol "${args.symbolPath}" (${node.getKindName()}) does not support structural rename.`);
|
|
363
419
|
}
|
|
364
|
-
|
|
365
|
-
|
|
420
|
+
// Template usages must be collected before the rename mutates the program.
|
|
421
|
+
const { coverage, usages } = await collectTemplateUsages(node, context, rc);
|
|
422
|
+
assertTemplateRenameAllowed(coverage.status, usages, args.newName);
|
|
423
|
+
const tsReferenceCount = Node.isReferenceFindable(node)
|
|
424
|
+
? findNonDeclarationReferences(node).length
|
|
366
425
|
: 0;
|
|
367
426
|
node.rename(args.newName);
|
|
368
|
-
return
|
|
427
|
+
return {
|
|
428
|
+
referenceCount: tsReferenceCount + usages.length,
|
|
429
|
+
auxiliaryEdits: usages.map((usage) => ({
|
|
430
|
+
absolutePath: usage.absolutePath,
|
|
431
|
+
start: usage.start,
|
|
432
|
+
end: usage.end,
|
|
433
|
+
newText: args.newName,
|
|
434
|
+
expectedHash: usage.fileHash,
|
|
435
|
+
})),
|
|
436
|
+
};
|
|
369
437
|
}, requestContext);
|
|
370
438
|
}
|
|
371
439
|
export async function prepareReplaceBody(args, requestContext = NO_REQUEST_CONTEXT) {
|
|
372
|
-
return createPlan(args.projectRoot, "replace_symbol_body", args.allowNewErrors ?? false, (project) => {
|
|
440
|
+
return createPlan(args.projectRoot, "replace_symbol_body", args.allowNewErrors ?? false, async ({ project }) => {
|
|
373
441
|
const sourceFile = getSourceFileOrThrow(project, args.filePath);
|
|
374
442
|
const declaration = findDeclarationByName(sourceFile, args.symbolPath);
|
|
375
443
|
const executable = executableDeclaration(declaration);
|
|
@@ -380,7 +448,7 @@ export async function prepareReplaceBody(args, requestContext = NO_REQUEST_CONTE
|
|
|
380
448
|
executable.getBody().replaceWithText("{}");
|
|
381
449
|
}
|
|
382
450
|
executable.setBodyText(args.newBody);
|
|
383
|
-
return 0;
|
|
451
|
+
return { referenceCount: 0 };
|
|
384
452
|
}, requestContext);
|
|
385
453
|
}
|
|
386
454
|
export async function prepareScaffoldClass(args, requestContext = NO_REQUEST_CONTEXT) {
|
|
@@ -399,7 +467,7 @@ export async function prepareScaffoldClass(args, requestContext = NO_REQUEST_CON
|
|
|
399
467
|
throw new Error("Scaffold file_path must target a .ts or .tsx implementation file.");
|
|
400
468
|
}
|
|
401
469
|
let metadata;
|
|
402
|
-
const operation = await createPlan(args.projectRoot, "scaffold_class", args.allowNewErrors ?? false, (project, projectRoot) => {
|
|
470
|
+
const operation = await createPlan(args.projectRoot, "scaffold_class", args.allowNewErrors ?? false, async ({ project, projectRoot }) => {
|
|
403
471
|
const absolutePath = path.resolve(projectRoot, normalizedFile);
|
|
404
472
|
assertSafeNewOperationFile(projectRoot, absolutePath, normalizedFile);
|
|
405
473
|
const scaffold = buildClassScaffold(project, absolutePath, args.spec);
|
|
@@ -407,7 +475,7 @@ export async function prepareScaffoldClass(args, requestContext = NO_REQUEST_CON
|
|
|
407
475
|
outline: scaffold.outline,
|
|
408
476
|
pendingMethods: scaffold.pendingMethods,
|
|
409
477
|
};
|
|
410
|
-
return 0;
|
|
478
|
+
return { referenceCount: 0 };
|
|
411
479
|
}, requestContext);
|
|
412
480
|
if (!metadata)
|
|
413
481
|
throw new Error("Scaffold generation did not produce metadata.");
|
package/dist/services/project.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { Project } from "ts-morph";
|
|
3
|
+
import { Project, ts } from "ts-morph";
|
|
4
4
|
// prettier-ignore
|
|
5
5
|
import { bindConfiguredH03Error, captureConfiguredH03Command, captureConfiguredH05Command, runConfiguredH03Fixture, runConfiguredH05Fixture } from "./h03-timeout-fixture.js";
|
|
6
6
|
import { InMemorySymbolIndex, SYMBOL_INDEX_SCHEMA_VERSION, } from "./symbol-index.js";
|
|
@@ -9,10 +9,12 @@ import { openSQLiteSymbolIndexStore, quarantineSQLiteSymbolIndexFile, } from "./
|
|
|
9
9
|
import { findDeclaration, sourceFileIndexSymbols } from "./symbols.js";
|
|
10
10
|
import { createProjectWatcher, selectProjectWatchDirectories, } from "./project-watcher.js";
|
|
11
11
|
import { createConfigSnapshot, createWorkspaceSnapshot } from "./workspace.js";
|
|
12
|
+
import { listTemplateFiles } from "./angular-templates.js";
|
|
12
13
|
import { createInitialProjectStatus, createProjectIdentity, projectOperationQueueToProjection, projectStatusToProjection, transitionProjectStatus, } from "./project-status.js";
|
|
13
14
|
import { ProjectOperationScheduler, ProjectOperationSchedulerError, } from "./project-operation-scheduler.js";
|
|
14
15
|
import { isCooperativeInterruption, NO_REQUEST_CONTEXT, } from "./request-context.js";
|
|
15
16
|
import { readRuntimePolicy } from "./runtime-policy.js";
|
|
17
|
+
import { PublicOperationalError } from "./public-errors.js";
|
|
16
18
|
export class ProjectCapacityError extends Error {
|
|
17
19
|
code = "PROJECT_CAPACITY_EXCEEDED";
|
|
18
20
|
constructor() {
|
|
@@ -203,10 +205,43 @@ export function resolveTsConfigPath(projectRoot) {
|
|
|
203
205
|
}
|
|
204
206
|
const candidate = path.join(abs, "tsconfig.json");
|
|
205
207
|
if (fs.existsSync(candidate)) {
|
|
206
|
-
|
|
208
|
+
const resolved = fs.realpathSync(candidate);
|
|
209
|
+
const references = solutionStyleReferences(resolved);
|
|
210
|
+
if (references !== null) {
|
|
211
|
+
throw new PublicOperationalError("PROJECT_NOT_FOUND", `"${resolved}" is a solution-style tsconfig with no source files and ${references.length} project reference${references.length === 1 ? "" : "s"} (${references.join(", ")}). Pass one of the referenced configs as project_root.`);
|
|
212
|
+
}
|
|
213
|
+
return resolved;
|
|
207
214
|
}
|
|
208
215
|
throw new Error(`No tsconfig.json found at "${abs}". Pass a project directory containing tsconfig.json or the config file itself.`);
|
|
209
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Detect a solution-style config (`tsc -b` root: no `files`/`include` of its
|
|
219
|
+
* own, only `references`). Returns the referenced paths as written, or `null`
|
|
220
|
+
* when the config declares sources or cannot be read; unreadable configs keep
|
|
221
|
+
* the existing compiler-failure path.
|
|
222
|
+
*/
|
|
223
|
+
function solutionStyleReferences(tsConfigFilePath) {
|
|
224
|
+
try {
|
|
225
|
+
const { config, error } = ts.readConfigFile(tsConfigFilePath, ts.sys.readFile);
|
|
226
|
+
if (error !== undefined || typeof config !== "object" || config === null)
|
|
227
|
+
return null;
|
|
228
|
+
const raw = config;
|
|
229
|
+
if (raw.include !== undefined || !Array.isArray(raw.references))
|
|
230
|
+
return null;
|
|
231
|
+
const references = raw.references.flatMap((reference) => typeof reference === "object" &&
|
|
232
|
+
reference !== null &&
|
|
233
|
+
typeof reference.path === "string"
|
|
234
|
+
? [reference.path]
|
|
235
|
+
: []);
|
|
236
|
+
if (references.length === 0)
|
|
237
|
+
return null;
|
|
238
|
+
const parsed = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(tsConfigFilePath));
|
|
239
|
+
return parsed.fileNames.length === 0 ? references : null;
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
210
245
|
function buildProjectContext(tsConfigFilePath) {
|
|
211
246
|
const project = new Project({
|
|
212
247
|
tsConfigFilePath,
|
|
@@ -330,6 +365,13 @@ function assertProjectRuntimeAdmissionOpen() {
|
|
|
330
365
|
throw new ProjectOperationSchedulerError("SERVER_SHUTTING_DOWN");
|
|
331
366
|
}
|
|
332
367
|
}
|
|
368
|
+
/** Compiler sources plus external templates, which sit outside the TS program but join the hash. */
|
|
369
|
+
function watchedFilePaths(context) {
|
|
370
|
+
return [
|
|
371
|
+
...context.project.getSourceFiles().map((sourceFile) => sourceFile.getFilePath()),
|
|
372
|
+
...listTemplateFiles(context),
|
|
373
|
+
];
|
|
374
|
+
}
|
|
333
375
|
function createProjectSession(projectRoot, runtimePolicy, symbolIndexPolicy, requestContext, lastAccessSequence) {
|
|
334
376
|
requestContext.checkpoint();
|
|
335
377
|
const tsConfigFilePath = resolveTsConfigPath(projectRoot);
|
|
@@ -338,7 +380,7 @@ function createProjectSession(projectRoot, runtimePolicy, symbolIndexPolicy, req
|
|
|
338
380
|
...baseContext,
|
|
339
381
|
symbolIndexObservability: createInitialSymbolIndexRuntimeObservability(symbolIndexPolicy),
|
|
340
382
|
};
|
|
341
|
-
const watchDirectories = selectProjectWatchDirectories(context.projectRoot, context
|
|
383
|
+
const watchDirectories = selectProjectWatchDirectories(context.projectRoot, watchedFilePaths(context));
|
|
342
384
|
const owner = {};
|
|
343
385
|
const watcher = createSessionWatcher(() => owner.session, context.projectRoot, watchDirectories);
|
|
344
386
|
const session = {
|
|
@@ -430,7 +472,7 @@ async function synchronizeSession(session, requestContext) {
|
|
|
430
472
|
project.removeSourceFile(sourceFile);
|
|
431
473
|
}
|
|
432
474
|
requestContext.checkpoint();
|
|
433
|
-
replaceSessionWatcher(session, selectProjectWatchDirectories(session.context.projectRoot,
|
|
475
|
+
replaceSessionWatcher(session, selectProjectWatchDirectories(session.context.projectRoot, watchedFilePaths(session.context)));
|
|
434
476
|
requestContext.checkpoint();
|
|
435
477
|
const snapshot = createWorkspaceSnapshot(session.context, {
|
|
436
478
|
previousFingerprints: session.fingerprints,
|
|
@@ -1,8 +1,24 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { Node } from "ts-morph";
|
|
4
|
+
import { collectTemplateUsages, } from "./angular-templates.js";
|
|
3
5
|
import { paginate } from "./pagination.js";
|
|
4
6
|
import { NO_REQUEST_CONTEXT } from "./request-context.js";
|
|
5
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Semantic references of `node` excluding the declaration itself.
|
|
9
|
+
*
|
|
10
|
+
* ts-morph only skips the first definition entry per referenced symbol; when a usage
|
|
11
|
+
* precedes the declaration in source order the declaration identifier is returned again
|
|
12
|
+
* as an ordinary reference. Drop any node at the declaration's position (the declaration
|
|
13
|
+
* node or its name identifier) so the declaration is reported exactly once.
|
|
14
|
+
*/
|
|
15
|
+
export function findNonDeclarationReferences(node) {
|
|
16
|
+
const declarationNodes = Node.hasName(node) ? [node, node.getNameNode()] : [node];
|
|
17
|
+
const isDeclaration = (reference) => declarationNodes.some((declaration) => declaration.getSourceFile().getFilePath() === reference.getSourceFile().getFilePath() &&
|
|
18
|
+
declaration.getStart() === reference.getStart());
|
|
19
|
+
return node.findReferencesAsNodes().filter((reference) => !isDeclaration(reference));
|
|
20
|
+
}
|
|
21
|
+
function collectTsLocations(node, projectRoot, symbolPath, includeDeclaration, detail, requestContext) {
|
|
6
22
|
requestContext.checkpoint();
|
|
7
23
|
if (!Node.isReferenceFindable(node)) {
|
|
8
24
|
throw new Error(`Symbol "${symbolPath}" (${node.getKindName()}) does not support semantic reference search.`);
|
|
@@ -30,13 +46,24 @@ export function collectSymbolReferences(node, projectRoot, symbolPath, includeDe
|
|
|
30
46
|
};
|
|
31
47
|
};
|
|
32
48
|
requestContext.checkpoint();
|
|
33
|
-
const references = node
|
|
49
|
+
const references = findNonDeclarationReferences(node).map((reference) => location(reference, false));
|
|
34
50
|
const declaration = location(node, true);
|
|
35
|
-
|
|
51
|
+
return {
|
|
52
|
+
declaration,
|
|
53
|
+
references,
|
|
54
|
+
locations: includeDeclaration ? [declaration, ...references] : [...references],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function compareLocations(left, right) {
|
|
58
|
+
const key = (item) => `${item.file}:${item.line}:${item.column}:${item.is_declaration ? 0 : 1}`;
|
|
59
|
+
return key(left).localeCompare(key(right));
|
|
60
|
+
}
|
|
61
|
+
export function collectSymbolReferences(node, projectRoot, symbolPath, includeDeclaration, detail, offset, limit, requestContext = NO_REQUEST_CONTEXT) {
|
|
62
|
+
const { declaration, references, locations } = collectTsLocations(node, projectRoot, symbolPath, includeDeclaration, detail, requestContext);
|
|
36
63
|
requestContext.checkpoint();
|
|
37
|
-
|
|
64
|
+
locations.sort(compareLocations);
|
|
38
65
|
const affectedFiles = [...new Set([declaration, ...references].map((item) => item.file))].sort();
|
|
39
|
-
const page = paginate(
|
|
66
|
+
const page = paginate(locations, offset, limit);
|
|
40
67
|
requestContext.checkpoint();
|
|
41
68
|
return {
|
|
42
69
|
symbol: symbolPath,
|
|
@@ -52,3 +79,65 @@ export function collectSymbolReferences(node, projectRoot, symbolPath, includeDe
|
|
|
52
79
|
next_offset: page.next_offset,
|
|
53
80
|
};
|
|
54
81
|
}
|
|
82
|
+
async function templateLocation(usage, detail, lineCache) {
|
|
83
|
+
let context;
|
|
84
|
+
if (detail === "context") {
|
|
85
|
+
let lines = lineCache.get(usage.absolutePath);
|
|
86
|
+
if (!lines) {
|
|
87
|
+
lines = readFile(usage.absolutePath, "utf8").then((text) => text.split(/\r?\n/));
|
|
88
|
+
lineCache.set(usage.absolutePath, lines);
|
|
89
|
+
}
|
|
90
|
+
context = ((await lines)[usage.line - 1] ?? "").trim().slice(0, 500);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
file: usage.file,
|
|
94
|
+
line: usage.line,
|
|
95
|
+
column: usage.column,
|
|
96
|
+
kind: usage.kind,
|
|
97
|
+
is_declaration: false,
|
|
98
|
+
origin: usage.container === "inline" ? "template_inline" : "template_external",
|
|
99
|
+
...(context === undefined ? {} : { context }),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Collects TypeScript references and, for Angular component members, template usages.
|
|
104
|
+
* Without Angular evidence the result is identical to {@link collectSymbolReferences}.
|
|
105
|
+
*/
|
|
106
|
+
export async function collectSymbolReferencesWithTemplates(node, context, query, requestContext = NO_REQUEST_CONTEXT) {
|
|
107
|
+
const { projectRoot } = context;
|
|
108
|
+
const { coverage, usages } = await collectTemplateUsages(node, context, requestContext);
|
|
109
|
+
if (coverage.status === "not_applicable") {
|
|
110
|
+
return collectSymbolReferences(node, projectRoot, query.symbolPath, query.includeDeclaration, query.detail, query.offset, query.limit, requestContext);
|
|
111
|
+
}
|
|
112
|
+
const ts = collectTsLocations(node, projectRoot, query.symbolPath, query.includeDeclaration, query.detail, requestContext);
|
|
113
|
+
const lineCache = new Map();
|
|
114
|
+
const templateItems = [];
|
|
115
|
+
for (const usage of usages) {
|
|
116
|
+
requestContext.checkpoint();
|
|
117
|
+
templateItems.push(await templateLocation(usage, query.detail, lineCache));
|
|
118
|
+
}
|
|
119
|
+
const merged = [
|
|
120
|
+
...ts.locations.map((item) => ({ ...item, origin: "compiler" })),
|
|
121
|
+
...templateItems,
|
|
122
|
+
].sort(compareLocations);
|
|
123
|
+
const affectedFiles = [
|
|
124
|
+
...new Set([ts.declaration, ...ts.references, ...templateItems].map((item) => item.file)),
|
|
125
|
+
].sort();
|
|
126
|
+
const page = paginate(merged, query.offset, query.limit);
|
|
127
|
+
requestContext.checkpoint();
|
|
128
|
+
return {
|
|
129
|
+
symbol: query.symbolPath,
|
|
130
|
+
include_declaration: query.includeDeclaration,
|
|
131
|
+
declaration_count: 1,
|
|
132
|
+
reference_count: ts.references.length,
|
|
133
|
+
affected_files: affectedFiles,
|
|
134
|
+
references: page.items,
|
|
135
|
+
offset: page.offset,
|
|
136
|
+
limit: page.limit,
|
|
137
|
+
total: page.total,
|
|
138
|
+
has_more: page.has_more,
|
|
139
|
+
next_offset: page.next_offset,
|
|
140
|
+
template_coverage: coverage,
|
|
141
|
+
template_reference_count: templateItems.length,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function assertSafeOperationFile(projectRoot, absolutePath, displayPath) {
|
|
4
|
+
const fileStat = lstatSync(absolutePath);
|
|
5
|
+
if (fileStat.isSymbolicLink()) {
|
|
6
|
+
throw new Error(`Operation target must not be a symbolic link: ${displayPath}`);
|
|
7
|
+
}
|
|
8
|
+
if (!fileStat.isFile()) {
|
|
9
|
+
throw new Error(`Operation target is not a regular file: ${displayPath}`);
|
|
10
|
+
}
|
|
11
|
+
const canonicalPath = realpathSync(absolutePath);
|
|
12
|
+
const samePath = process.platform === "win32"
|
|
13
|
+
? canonicalPath.toLowerCase() === absolutePath.toLowerCase()
|
|
14
|
+
: canonicalPath === absolutePath;
|
|
15
|
+
if (!samePath) {
|
|
16
|
+
throw new Error(`Operation target traverses a symbolic link: ${displayPath}`);
|
|
17
|
+
}
|
|
18
|
+
if (process.platform !== "win32" && fileStat.nlink !== 1) {
|
|
19
|
+
throw new Error(`Operation target must have exactly one hard link: ${displayPath}`);
|
|
20
|
+
}
|
|
21
|
+
const uid = process.getuid?.();
|
|
22
|
+
if (uid !== undefined && fileStat.uid !== uid) {
|
|
23
|
+
throw new Error(`Operation target is not owned by the current user: ${displayPath}`);
|
|
24
|
+
}
|
|
25
|
+
const relative = path.relative(projectRoot, canonicalPath);
|
|
26
|
+
if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`)) {
|
|
27
|
+
throw new Error(`Operation target escapes the project root: ${displayPath}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { ts } from "ts-morph";
|
|
5
|
+
import { listTemplateFiles } from "./angular-templates.js";
|
|
5
6
|
import { collectFileFingerprints, compareFileFingerprints, hashBytes, } from "./file-fingerprints.js";
|
|
6
7
|
export { hashBytes } from "./file-fingerprints.js";
|
|
7
8
|
export function hashWorkspaceFiles(files) {
|
|
@@ -66,7 +67,9 @@ export function createWorkspaceSnapshot(context, options = {}) {
|
|
|
66
67
|
.getSourceFiles()
|
|
67
68
|
.map((sourceFile) => canonicalPath(sourceFile.getFilePath()));
|
|
68
69
|
const configSnapshot = createConfigSnapshot(context.tsConfigFilePath);
|
|
69
|
-
|
|
70
|
+
// Template files sit outside the TS program; listing them here keeps every snapshot consumer in agreement.
|
|
71
|
+
const templatePaths = listTemplateFiles(context).map(canonicalPath);
|
|
72
|
+
const allPaths = [...new Set([...sourcePaths, ...configSnapshot.files, ...templatePaths])];
|
|
70
73
|
const fingerprintCollection = collectFileFingerprints(allPaths, options.previousFingerprints, {
|
|
71
74
|
verifyContentHash: options.verifyContentHash,
|
|
72
75
|
});
|
|
@@ -90,6 +93,11 @@ export function createWorkspaceSnapshot(context, options = {}) {
|
|
|
90
93
|
}
|
|
91
94
|
files.set(filePath, digest);
|
|
92
95
|
}
|
|
96
|
+
for (const filePath of templatePaths) {
|
|
97
|
+
const fingerprint = fingerprints.get(filePath);
|
|
98
|
+
if (fingerprint)
|
|
99
|
+
files.set(filePath, fingerprint.content_hash);
|
|
100
|
+
}
|
|
93
101
|
return {
|
|
94
102
|
digest: hashWorkspaceFiles(files),
|
|
95
103
|
sourceDigest: hashWorkspaceFiles(sourceFiles),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { PaginationInputSchema, PaginationOutputSchema } from "../services/pagination.js";
|
|
3
3
|
import { findDeclarationByName, getSourceFileOrThrow, withProject } from "../services/project.js";
|
|
4
|
-
import {
|
|
4
|
+
import { collectSymbolReferencesWithTemplates } from "../services/references.js";
|
|
5
5
|
import { createRequestContext } from "../services/request-context.js";
|
|
6
6
|
import { createToolErrorContext, errorResult, formattedResult, ToolOutputFormatInputSchema, } from "./result.js";
|
|
7
7
|
const TOOL_NAME = "ast_find_references";
|
|
@@ -25,13 +25,34 @@ const ContextReferenceSchema = z.object({
|
|
|
25
25
|
is_declaration: z.boolean(),
|
|
26
26
|
context: z.string(),
|
|
27
27
|
});
|
|
28
|
-
const
|
|
28
|
+
const ContextReferenceSchemaWithOrigin = ContextReferenceSchema.extend({
|
|
29
|
+
origin: z.enum(["compiler", "template_external", "template_inline"]).optional(),
|
|
30
|
+
});
|
|
31
|
+
const LocationReferenceSchema = ContextReferenceSchemaWithOrigin.omit({ context: true });
|
|
32
|
+
const TemplateCoverageSchema = z.object({
|
|
33
|
+
status: z.enum(["completed", "unfinished", "unsupported"]),
|
|
34
|
+
reason: z
|
|
35
|
+
.enum([
|
|
36
|
+
"compiler_missing",
|
|
37
|
+
"compiler_unsupported",
|
|
38
|
+
"template_unresolved",
|
|
39
|
+
"template_unsafe",
|
|
40
|
+
"template_parse_error",
|
|
41
|
+
"size_exceeded",
|
|
42
|
+
"work_exhausted",
|
|
43
|
+
"inherited_member",
|
|
44
|
+
])
|
|
45
|
+
.optional(),
|
|
46
|
+
templates: z.number().int().min(0).optional(),
|
|
47
|
+
});
|
|
29
48
|
const ReferenceOutputBase = {
|
|
30
49
|
symbol: z.string(),
|
|
31
50
|
include_declaration: z.boolean(),
|
|
32
51
|
declaration_count: z.number().int().min(0),
|
|
33
52
|
reference_count: z.number().int().min(0),
|
|
34
53
|
affected_files: z.array(z.string()),
|
|
54
|
+
template_coverage: TemplateCoverageSchema.optional(),
|
|
55
|
+
template_reference_count: z.number().int().min(0).optional(),
|
|
35
56
|
...PaginationOutputSchema,
|
|
36
57
|
};
|
|
37
58
|
const ReferenceOutputSchemas = {
|
|
@@ -41,13 +62,13 @@ const ReferenceOutputSchemas = {
|
|
|
41
62
|
}),
|
|
42
63
|
context: z.object({
|
|
43
64
|
...ReferenceOutputBase,
|
|
44
|
-
references: z.array(
|
|
65
|
+
references: z.array(ContextReferenceSchemaWithOrigin),
|
|
45
66
|
}),
|
|
46
67
|
};
|
|
47
68
|
export function registerFindReferences(server) {
|
|
48
69
|
server.registerTool(TOOL_NAME, {
|
|
49
70
|
title: "Find semantic symbol references",
|
|
50
|
-
description: "Finds type-resolved references and returns bounded project-relative locations, including declaration impact by default.",
|
|
71
|
+
description: "Finds type-resolved references and returns bounded project-relative locations, including declaration impact by default. When @angular/compiler resolves, it also reports Angular template usages tagged by origin with a template_coverage verdict; incomplete coverage does not prove absence, and template errors are not diagnosed.",
|
|
51
72
|
inputSchema: AstFindReferencesInputSchema,
|
|
52
73
|
annotations: {
|
|
53
74
|
readOnlyHint: true,
|
|
@@ -58,18 +79,31 @@ export function registerFindReferences(server) {
|
|
|
58
79
|
}, async ({ project_root, file_path, symbol_path, include_declaration, detail, output_format, offset, limit, }, extra) => {
|
|
59
80
|
const requestContext = createRequestContext(extra.signal);
|
|
60
81
|
try {
|
|
61
|
-
const structuredContent = await withProject(project_root, (
|
|
62
|
-
const sourceFile = getSourceFileOrThrow(project, file_path);
|
|
82
|
+
const structuredContent = await withProject(project_root, async (context, operationContext) => {
|
|
83
|
+
const sourceFile = getSourceFileOrThrow(context.project, file_path);
|
|
63
84
|
const node = findDeclarationByName(sourceFile, symbol_path);
|
|
64
|
-
const collected =
|
|
85
|
+
const collected = await collectSymbolReferencesWithTemplates(node, context, {
|
|
86
|
+
symbolPath: symbol_path,
|
|
87
|
+
includeDeclaration: include_declaration,
|
|
88
|
+
detail,
|
|
89
|
+
offset,
|
|
90
|
+
limit,
|
|
91
|
+
}, operationContext);
|
|
65
92
|
const { references, ...metadata } = collected;
|
|
66
93
|
return {
|
|
67
94
|
...metadata,
|
|
68
95
|
references: detail === "context"
|
|
69
96
|
? references
|
|
70
|
-
: references.map(({ file, line, column, kind, is_declaration }) => {
|
|
97
|
+
: references.map(({ file, line, column, kind, is_declaration, origin }) => {
|
|
71
98
|
operationContext.checkpoint();
|
|
72
|
-
return {
|
|
99
|
+
return {
|
|
100
|
+
file,
|
|
101
|
+
line,
|
|
102
|
+
column,
|
|
103
|
+
kind,
|
|
104
|
+
is_declaration,
|
|
105
|
+
...(origin === undefined ? {} : { origin }),
|
|
106
|
+
};
|
|
73
107
|
}),
|
|
74
108
|
};
|
|
75
109
|
}, requestContext);
|
|
@@ -21,7 +21,7 @@ const AstRenameSymbolInputSchema = z.object({
|
|
|
21
21
|
export function registerRenameSymbol(server) {
|
|
22
22
|
server.registerTool(TOOL_NAME, {
|
|
23
23
|
title: "Prepare a project-wide structural rename",
|
|
24
|
-
description: "Prepares an exact hash-bound rename plan without writing. Review the preview and apply its operation_id with ast_apply_operation.",
|
|
24
|
+
description: "Prepares an exact hash-bound rename plan without writing. Review the preview and apply its operation_id with ast_apply_operation. The plan also rewrites external Angular templates hash-bound to the workspace snapshot; diagnostics stay TypeScript-only.",
|
|
25
25
|
inputSchema: AstRenameSymbolInputSchema,
|
|
26
26
|
outputSchema: PreparedOperationOutputSchema,
|
|
27
27
|
annotations: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ast-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "Structural code MCP server and batch CLI for compact TypeScript/JavaScript reads and hash-bound AST edits.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -109,17 +109,31 @@
|
|
|
109
109
|
"ts-morph": "28.0.0",
|
|
110
110
|
"zod": "^4.6.7"
|
|
111
111
|
},
|
|
112
|
+
"peerDependencies": {
|
|
113
|
+
"@angular/compiler": ">=18.0.0 <23.0.0"
|
|
114
|
+
},
|
|
115
|
+
"peerDependenciesMeta": {
|
|
116
|
+
"@angular/compiler": {
|
|
117
|
+
"optional": true
|
|
118
|
+
}
|
|
119
|
+
},
|
|
112
120
|
"devDependencies": {
|
|
113
121
|
"@eslint/js": "^10.0.1",
|
|
114
122
|
"@types/node": "^26.6.5",
|
|
123
|
+
"angular-compiler-v18": "npm:@angular/compiler@18.2.14",
|
|
124
|
+
"angular-compiler-v19": "npm:@angular/compiler@19.2.15",
|
|
125
|
+
"angular-compiler-v20": "npm:@angular/compiler@20.3.34",
|
|
126
|
+
"angular-compiler-v21": "npm:@angular/compiler@21.2.25",
|
|
127
|
+
"angular-compiler-v22": "npm:@angular/compiler@22.2.4",
|
|
115
128
|
"eslint": "^10.8.0",
|
|
116
129
|
"gpt-tokenizer": "4.1.0",
|
|
117
130
|
"prettier": "^3.9.6",
|
|
131
|
+
"tar": "7.5.22",
|
|
118
132
|
"typescript": "^5.6.0",
|
|
119
133
|
"typescript-eslint": "^8.65.0",
|
|
120
134
|
"vite": "^8.3.4",
|
|
121
135
|
"vitest": "^5.0.5",
|
|
122
136
|
"yaml": "^2.9.0"
|
|
123
137
|
},
|
|
124
|
-
"gitHead": "
|
|
138
|
+
"gitHead": "20548017b7c9266160963a0272ce8757b8f42a77"
|
|
125
139
|
}
|