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.
@@ -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
- async function createPlan(projectRootInput, kind, allowNewErrors, mutate, requestContext) {
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.project, context.projectRoot);
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
- if (textChanges.length === 0) {
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, (project) => {
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
- const referenceCount = Node.isReferenceFindable(node)
365
- ? node.findReferencesAsNodes().length
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 referenceCount;
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.");
@@ -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
- return fs.realpathSync(candidate);
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.project.getSourceFiles().map((sourceFile) => sourceFile.getFilePath()));
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, project.getSourceFiles().map((sourceFile) => sourceFile.getFilePath())));
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
- export function collectSymbolReferences(node, projectRoot, symbolPath, includeDeclaration, detail, offset, limit, requestContext = NO_REQUEST_CONTEXT) {
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.findReferencesAsNodes().map((reference) => location(reference, false));
49
+ const references = findNonDeclarationReferences(node).map((reference) => location(reference, false));
34
50
  const declaration = location(node, true);
35
- const allLocations = includeDeclaration ? [declaration, ...references] : references;
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
- 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}`));
64
+ locations.sort(compareLocations);
38
65
  const affectedFiles = [...new Set([declaration, ...references].map((item) => item.file))].sort();
39
- const page = paginate(allLocations, offset, limit);
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
- const allPaths = [...new Set([...sourcePaths, ...configSnapshot.files])];
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 { collectSymbolReferences } from "../services/references.js";
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 LocationReferenceSchema = ContextReferenceSchema.omit({ context: true });
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(ContextReferenceSchema),
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, ({ project, projectRoot }, operationContext) => {
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 = collectSymbolReferences(node, projectRoot, symbol_path, include_declaration, detail, offset, limit, operationContext);
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 { file, line, column, kind, is_declaration };
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.14.3",
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": "da7adf2662572454fdd388b549eb538fbfc5a380"
138
+ "gitHead": "20548017b7c9266160963a0272ce8757b8f42a77"
125
139
  }