ast-mcp-server 0.16.0 → 0.18.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.
@@ -7,6 +7,8 @@ import { LanguageVariant, Node, ScriptTarget, SyntaxKind, ts } from "ts-morph";
7
7
  import { isPipeClass } from "./angular-decorators.js";
8
8
  import { planTemplateEdits } from "./angular-template-rename.js";
9
9
  import { collectTemplateUsages } from "./angular-templates.js";
10
+ import { checkTemplateTypes } from "./angular-template-typecheck.js";
11
+ import { evaluateBlock } from "./operation-block-policy.js";
10
12
  import { findNonDeclarationReferences } from "./references.js";
11
13
  import { compareObservedDiagnostics, observeDiagnostic, } from "./diagnostics.js";
12
14
  import { probePublicationCapability, publishAuthenticated, removeOwnedPublicationEntry, rollbackOwnedCommit, snapshotHeldFile, } from "./authenticated-publication.js";
@@ -47,6 +49,7 @@ function planHashFor(operation, version = 2) {
47
49
  diagnostics: operation.diagnostics,
48
50
  allow_new_errors: operation.allow_new_errors,
49
51
  blocked: operation.blocked,
52
+ ...(version === 3 ? templateHashFields(operation) : {}),
50
53
  files: operation.files.map((file) => ({
51
54
  file: file.file,
52
55
  original_hash: file.originalHash,
@@ -55,6 +58,27 @@ function planHashFor(operation, version = 2) {
55
58
  })),
56
59
  }));
57
60
  }
61
+ /** The version 3 additions, in a fixed key order so equal deltas always hash equal. */
62
+ function templateHashFields(operation) {
63
+ const delta = operation.template_diagnostics;
64
+ if (delta === undefined || operation.block_on_template_errors === undefined) {
65
+ throw new Error("A version 3 plan requires template_diagnostics and block_on_template_errors.");
66
+ }
67
+ return {
68
+ template_diagnostics: {
69
+ status: delta.status,
70
+ ...(delta.status === "completed" ? {} : { reason: delta.reason }),
71
+ added: delta.added,
72
+ removed: delta.removed,
73
+ addedErrors: delta.addedErrors,
74
+ },
75
+ block_on_template_errors: operation.block_on_template_errors,
76
+ };
77
+ }
78
+ /** Plans without a template check keep hashing as version 2; only a check upgrades them. */
79
+ function planVersionOf(operation) {
80
+ return operation.template_diagnostics === undefined ? 2 : 3;
81
+ }
58
82
  function pruneOperations() {
59
83
  const now = operationNow();
60
84
  for (const [id, operation] of operations) {
@@ -216,8 +240,22 @@ async function planAuxiliaryFiles(edits, projectRoot, snapshotFiles, requestCont
216
240
  }
217
241
  return planned;
218
242
  }
243
+ /** Maps the planned files to the check input; paths are relative to the tsconfig directory. */
244
+ function templateCheckInput(tsConfigFilePath, files, auxiliaryFiles) {
245
+ const base = path.dirname(tsConfigFilePath);
246
+ return {
247
+ tsConfigFilePath,
248
+ templates: auxiliaryFiles.map((file) => file.absolutePath),
249
+ overlay: files.map((file) => ({ absolutePath: file.absolutePath, text: file.updatedText })),
250
+ changes: files.map((file) => ({
251
+ file: path.relative(base, file.absolutePath),
252
+ beforeText: isCreatedFile(file) ? null : file.originalText,
253
+ afterText: file.updatedText,
254
+ })),
255
+ };
256
+ }
219
257
  /** Internal seam shared by the prepare functions; exported so tests can drive `mutate` directly. */
220
- export async function createPlan(projectRootInput, kind, allowNewErrors, mutate, requestContext) {
258
+ export async function createPlan(projectRootInput, kind, allowNewErrors, mutate, requestContext, planOptions = {}) {
221
259
  requestContext.checkpoint();
222
260
  let retainedOperationId;
223
261
  return withProjectOperation(projectRootInput, async (operationContext) => {
@@ -316,13 +354,25 @@ export async function createPlan(projectRootInput, kind, allowNewErrors, mutate,
316
354
  postWorkspaceFiles.set(file.absolutePath, file.updatedHash);
317
355
  }
318
356
  const postWorkspaceHash = hashWorkspaceFiles(postWorkspaceFiles);
357
+ // Only a rename that edits templates is checked; the check runs inside the project lock and
358
+ // before the digest check below, which proves the child's disk reads saw an unchanged tree.
359
+ const templateDiagnostics = kind === "rename_symbol" && auxiliaryFiles.length > 0
360
+ ? await checkTemplateTypes(templateCheckInput(context.tsConfigFilePath, files, auxiliaryFiles), operationContext)
361
+ : undefined;
362
+ operationContext.checkpoint();
319
363
  const workspaceAfter = createWorkspaceSnapshot(createFreshProject(context.tsConfigFilePath));
320
364
  operationContext.checkpoint();
321
365
  if (workspaceAfter.digest !== workspaceBefore.digest) {
322
366
  throw new Error("Workspace changed while the operation was being prepared. Retry preparation.");
323
367
  }
324
368
  const fullPreview = files.map((file) => file.diff).join("\n");
325
- const blocked = !allowNewErrors && diagnosticDelta.addedErrors.length > 0;
369
+ const blockOnTemplateErrors = planOptions.blockOnTemplateErrors ?? false;
370
+ const verdict = evaluateBlock({
371
+ allowNewErrors,
372
+ typescriptErrors: diagnosticDelta.addedErrors.length,
373
+ template: templateDiagnostics,
374
+ blockOnTemplateErrors,
375
+ });
326
376
  const now = operationNow();
327
377
  const operationId = randomUUID();
328
378
  const recordWithoutHash = {
@@ -344,17 +394,21 @@ export async function createPlan(projectRootInput, kind, allowNewErrors, mutate,
344
394
  workspace_file_count: workspaceBefore.fileCount,
345
395
  diagnostics: diagnosticDelta,
346
396
  allow_new_errors: allowNewErrors,
347
- blocked,
348
- block_reason: blocked
349
- ? `${diagnosticDelta.addedErrors.length} new TypeScript error(s) would be introduced. Prepare again with allow_new_errors=true only after explicit review.`
350
- : null,
397
+ blocked: verdict.blocked,
398
+ block_reason: verdict.reason,
351
399
  preview: fullPreview.length <= MAX_INLINE_PREVIEW_CHARS ? fullPreview : null,
352
400
  preview_truncated: fullPreview.length > MAX_INLINE_PREVIEW_CHARS,
401
+ ...(templateDiagnostics === undefined
402
+ ? {}
403
+ : {
404
+ template_diagnostics: templateDiagnostics,
405
+ block_on_template_errors: blockOnTemplateErrors,
406
+ }),
353
407
  files,
354
408
  };
355
409
  const record = {
356
410
  ...recordWithoutHash,
357
- plan_hash: planHashFor(recordWithoutHash),
411
+ plan_hash: planHashFor(recordWithoutHash, planVersionOf(recordWithoutHash)),
358
412
  };
359
413
  operationContext.checkpoint();
360
414
  operations.set(operationId, record);
@@ -388,6 +442,12 @@ function publicOperation(record) {
388
442
  block_reason: record.block_reason,
389
443
  preview: record.preview,
390
444
  preview_truncated: record.preview_truncated,
445
+ ...(record.template_diagnostics === undefined
446
+ ? {}
447
+ : {
448
+ template_diagnostics: record.template_diagnostics,
449
+ block_on_template_errors: record.block_on_template_errors,
450
+ }),
391
451
  };
392
452
  }
393
453
  const TEMPLATE_ANY_CAST = "$any";
@@ -448,7 +508,7 @@ export async function prepareRename(args, requestContext = NO_REQUEST_CONTEXT) {
448
508
  expectedHash: usage.fileHash,
449
509
  })),
450
510
  };
451
- }, requestContext);
511
+ }, requestContext, { blockOnTemplateErrors: args.blockOnTemplateErrors });
452
512
  }
453
513
  export async function prepareReplaceBody(args, requestContext = NO_REQUEST_CONTEXT) {
454
514
  return createPlan(args.projectRoot, "replace_symbol_body", args.allowNewErrors ?? false, async ({ project }) => {
@@ -982,7 +1042,7 @@ export function exportOperationRecord(operationId) {
982
1042
  })),
983
1043
  };
984
1044
  }
985
- export function importOperationRecord(persisted, expectedPlanHash, planVersion = 2) {
1045
+ export function importOperationRecord(persisted, expectedPlanHash, planVersion = planVersionOf(persisted)) {
986
1046
  if (persisted.plan_hash !== expectedPlanHash) {
987
1047
  throw new Error(`Plan hash mismatch for operation "${persisted.operation_id}". No files were written.`);
988
1048
  }
@@ -1047,18 +1107,31 @@ export function importOperationRecord(persisted, expectedPlanHash, planVersion =
1047
1107
  if (JSON.stringify(addedErrors) !== JSON.stringify(persisted.diagnostics.addedErrors)) {
1048
1108
  throw new Error("Persisted diagnostic error delta is inconsistent.");
1049
1109
  }
1050
- const blocked = !persisted.allow_new_errors && addedErrors.length > 0;
1051
- if (persisted.blocked !== blocked) {
1110
+ const templateDelta = persisted.template_diagnostics;
1111
+ if (templateDelta !== undefined) {
1112
+ const templateErrors = templateDelta.added.filter((diagnostic) => diagnostic.category === "Error");
1113
+ if (JSON.stringify(templateErrors) !== JSON.stringify(templateDelta.addedErrors)) {
1114
+ throw new Error("Persisted template diagnostic error delta is inconsistent.");
1115
+ }
1116
+ }
1117
+ if ((planVersion === 3) !== (templateDelta !== undefined)) {
1118
+ throw new Error("Persisted template diagnostics are inconsistent with the plan version.");
1119
+ }
1120
+ const verdict = evaluateBlock({
1121
+ allowNewErrors: persisted.allow_new_errors,
1122
+ typescriptErrors: addedErrors.length,
1123
+ template: templateDelta,
1124
+ blockOnTemplateErrors: persisted.block_on_template_errors ?? false,
1125
+ });
1126
+ if (persisted.blocked !== verdict.blocked) {
1052
1127
  throw new Error("Persisted blocked status is inconsistent with diagnostics policy.");
1053
1128
  }
1054
1129
  const fullPreview = files.map((file) => file.diff).join("\n");
1055
1130
  const record = {
1056
1131
  ...persisted,
1057
1132
  affected_files: affectedFiles,
1058
- blocked,
1059
- block_reason: blocked
1060
- ? `${addedErrors.length} new TypeScript error(s) would be introduced. Prepare again with allow_new_errors=true only after explicit review.`
1061
- : null,
1133
+ blocked: verdict.blocked,
1134
+ block_reason: verdict.reason,
1062
1135
  preview: fullPreview.length <= MAX_INLINE_PREVIEW_CHARS ? fullPreview : null,
1063
1136
  preview_truncated: fullPreview.length > MAX_INLINE_PREVIEW_CHARS,
1064
1137
  tsConfigFilePath: context.tsConfigFilePath,
@@ -354,6 +354,10 @@ export class ProjectOperationScheduler {
354
354
  checkpoint: () => {
355
355
  this.#checkpoint(node);
356
356
  },
357
+ remainingMs: () => {
358
+ const startedAt = node.executionStartedAt ?? this.#readNow();
359
+ return Math.max(0, this.#operationDeadlineMs - (this.#readNow() - startedAt));
360
+ },
357
361
  markExecuting: () => {
358
362
  this.#checkpoint(node);
359
363
  if (node.phase !== "synchronizing") {
@@ -1,9 +1,8 @@
1
- import { readFile } from "node:fs/promises";
2
1
  import path from "node:path";
3
2
  import { Node } from "ts-morph";
4
- import { collectTemplateUsages, } from "./angular-templates.js";
3
+ import { collectTemplateUsages, TemplateScanCache, } from "./angular-templates.js";
5
4
  import { paginate } from "./pagination.js";
6
- import { NO_REQUEST_CONTEXT } from "./request-context.js";
5
+ import { isCooperativeInterruption, NO_REQUEST_CONTEXT, } from "./request-context.js";
7
6
  /**
8
7
  * Semantic references of `node` excluding the declaration itself.
9
8
  *
@@ -79,15 +78,11 @@ export function collectSymbolReferences(node, projectRoot, symbolPath, includeDe
79
78
  next_offset: page.next_offset,
80
79
  };
81
80
  }
82
- async function templateLocation(usage, detail, lineCache) {
81
+ async function templateLocation(usage, detail, templateCache) {
83
82
  let context;
84
83
  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);
84
+ const lines = await templateCache.lines(usage.absolutePath);
85
+ context = (lines[usage.line - 1] ?? "").trim().slice(0, 500);
91
86
  }
92
87
  return {
93
88
  file: usage.file,
@@ -99,29 +94,28 @@ async function templateLocation(usage, detail, lineCache) {
99
94
  ...(context === undefined ? {} : { context }),
100
95
  };
101
96
  }
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 = [];
97
+ /** Template usages of `node` as reference items, with the coverage verdict that qualifies them. */
98
+ async function collectTemplateHalf(node, context, detail, requestContext, templateCache = new TemplateScanCache(context)) {
99
+ const { coverage, usages } = await collectTemplateUsages(node, context, requestContext, undefined, templateCache);
100
+ const items = [];
115
101
  for (const usage of usages) {
116
102
  requestContext.checkpoint();
117
- templateItems.push(await templateLocation(usage, query.detail, lineCache));
103
+ items.push(await templateLocation(usage, detail, templateCache));
104
+ }
105
+ return { coverage, items };
106
+ }
107
+ /** Merges the TypeScript references of `node` with an already collected template half. */
108
+ function mergeWithTemplateHalf(node, projectRoot, query, template, requestContext) {
109
+ if (template.coverage.status === "not_applicable") {
110
+ return collectSymbolReferences(node, projectRoot, query.symbolPath, query.includeDeclaration, query.detail, query.offset, query.limit, requestContext);
118
111
  }
112
+ const ts = collectTsLocations(node, projectRoot, query.symbolPath, query.includeDeclaration, query.detail, requestContext);
119
113
  const merged = [
120
114
  ...ts.locations.map((item) => ({ ...item, origin: "compiler" })),
121
- ...templateItems,
115
+ ...template.items,
122
116
  ].sort(compareLocations);
123
117
  const affectedFiles = [
124
- ...new Set([ts.declaration, ...ts.references, ...templateItems].map((item) => item.file)),
118
+ ...new Set([ts.declaration, ...ts.references, ...template.items].map((item) => item.file)),
125
119
  ].sort();
126
120
  const page = paginate(merged, query.offset, query.limit);
127
121
  requestContext.checkpoint();
@@ -137,7 +131,38 @@ export async function collectSymbolReferencesWithTemplates(node, context, query,
137
131
  total: page.total,
138
132
  has_more: page.has_more,
139
133
  next_offset: page.next_offset,
140
- template_coverage: coverage,
141
- template_reference_count: templateItems.length,
134
+ template_coverage: template.coverage,
135
+ template_reference_count: template.items.length,
142
136
  };
143
137
  }
138
+ /**
139
+ * Collects TypeScript references and, for Angular component members, template usages.
140
+ * Without Angular evidence the result is identical to {@link collectSymbolReferences}.
141
+ */
142
+ export async function collectSymbolReferencesWithTemplates(node, context, query, requestContext = NO_REQUEST_CONTEXT) {
143
+ const template = await collectTemplateHalf(node, context, query.detail, requestContext);
144
+ return mergeWithTemplateHalf(node, context.projectRoot, query, template, requestContext);
145
+ }
146
+ /**
147
+ * Same result as {@link collectSymbolReferencesWithTemplates}, but a failing template half is
148
+ * reported as an `unfinished` / `template_unresolved` verdict next to the TypeScript references
149
+ * instead of failing the call. TypeScript failures and cooperative interruptions still throw.
150
+ *
151
+ * A caller that collects several symbols in one request passes the same `templateCache` to each
152
+ * call; it shares template reads between them and does not change any result.
153
+ */
154
+ export async function collectSymbolReferencesWithTemplateVerdict(node, context, query, requestContext = NO_REQUEST_CONTEXT, templateCache) {
155
+ let template;
156
+ try {
157
+ template = await collectTemplateHalf(node, context, query.detail, requestContext, templateCache);
158
+ }
159
+ catch (error) {
160
+ if (isCooperativeInterruption(error))
161
+ throw error;
162
+ template = {
163
+ coverage: { status: "unfinished", reason: "template_unresolved", templates: 0 },
164
+ items: [],
165
+ };
166
+ }
167
+ return mergeWithTemplateHalf(node, context.projectRoot, query, template, requestContext);
168
+ }
@@ -8,6 +8,7 @@ import { createRequestContext } from "../services/request-context.js";
8
8
  import { FRESHNESS_CAUSES, SNAPSHOT_STATES, TRUNCATION_REASONS, } from "../services/read-contracts.js";
9
9
  import { createToolErrorContext, errorResult, structuredResult } from "./result.js";
10
10
  import { RelationshipEndpointSchema } from "./relationship-schema.js";
11
+ import { ReferenceOriginSchema, templateEvidenceFields } from "./template-evidence-schema.js";
11
12
  const TOOL_NAME = "ast_explore";
12
13
  const ExploreDetailSchema = z.enum(["selectors", "summary", "context", "full"]).default("summary");
13
14
  const ReferenceDetailSchema = z.enum(["locations", "context"]).default("locations");
@@ -100,6 +101,7 @@ const ExploreReferenceSchema = z.object({
100
101
  kind: z.string(),
101
102
  is_declaration: z.boolean(),
102
103
  context: z.string().optional(),
104
+ origin: ReferenceOriginSchema.optional(),
103
105
  });
104
106
  const ExploreEvidenceSchema = z.object({
105
107
  selector: z.string(),
@@ -111,6 +113,7 @@ const ExploreEvidenceSchema = z.object({
111
113
  declaration_count: z.number().int().min(0),
112
114
  reference_count: z.number().int().min(0),
113
115
  affected_files: z.array(z.string()),
116
+ ...templateEvidenceFields,
114
117
  references: z.array(ExploreReferenceSchema),
115
118
  offset: z.number().int().min(0),
116
119
  limit: z.number().int().positive(),
@@ -4,6 +4,7 @@ import { findDeclarationByName, getSourceFileOrThrow, withProject } from "../ser
4
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
+ import { ReferenceOriginSchema, templateEvidenceFields } from "./template-evidence-schema.js";
7
8
  const TOOL_NAME = "ast_find_references";
8
9
  const ReferenceDetailSchema = z.enum(["locations", "context"]).default("locations");
9
10
  const AstFindReferencesInputSchema = z.object({
@@ -26,37 +27,20 @@ const ContextReferenceSchema = z.object({
26
27
  context: z.string(),
27
28
  });
28
29
  const ContextReferenceSchemaWithOrigin = ContextReferenceSchema.extend({
29
- origin: z.enum(["compiler", "template_external", "template_inline"]).optional(),
30
+ origin: ReferenceOriginSchema.optional(),
30
31
  });
31
32
  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
- "cross_component",
45
- ])
46
- .optional(),
47
- templates: z.number().int().min(0).optional(),
48
- });
49
33
  const ReferenceOutputBase = {
50
34
  symbol: z.string(),
51
35
  include_declaration: z.boolean(),
52
36
  declaration_count: z.number().int().min(0),
53
37
  reference_count: z.number().int().min(0),
54
38
  affected_files: z.array(z.string()),
55
- template_coverage: TemplateCoverageSchema.optional(),
56
- template_reference_count: z.number().int().min(0).optional(),
39
+ ...templateEvidenceFields,
57
40
  ...PaginationOutputSchema,
58
41
  };
59
- const ReferenceOutputSchemas = {
42
+ /** Exported so a golden test can pin the output JSON schema bytes; not a tool contract. */
43
+ export const ReferenceOutputSchemas = {
60
44
  locations: z.object({
61
45
  ...ReferenceOutputBase,
62
46
  references: z.array(LocationReferenceSchema),
@@ -69,7 +53,7 @@ const ReferenceOutputSchemas = {
69
53
  export function registerFindReferences(server) {
70
54
  server.registerTool(TOOL_NAME, {
71
55
  title: "Find semantic symbol references",
72
- 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, including consumer templates, tagged by origin with a template_coverage verdict; completed covers templates inside the Project only, incomplete coverage does not prove absence, and template errors are not diagnosed.",
56
+ 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, including consumer templates, tagged by origin with a template_coverage verdict; completed covers templates inside the Project only, incomplete coverage does not prove absence. It reports usages only; template type errors are reported by ast_rename_symbol plans in template_diagnostics.",
73
57
  inputSchema: AstFindReferencesInputSchema,
74
58
  annotations: {
75
59
  readOnlyHint: true,
@@ -34,6 +34,46 @@ export const PreparedOperationOutputSchema = z.object({
34
34
  preview: z.string().nullable(),
35
35
  preview_truncated: z.boolean(),
36
36
  });
37
+ const TemplateDiagnosticsListSchema = z.array(NormalizedDiagnosticSchema);
38
+ const NonCompletedTemplateListSchema = z.array(NormalizedDiagnosticSchema).max(0);
39
+ /** Template type-check delta of a rename; present only when the check ran or tried to. */
40
+ export const TemplateDiagnosticsSchema = z.discriminatedUnion("status", [
41
+ z
42
+ .object({
43
+ status: z.literal("completed"),
44
+ added: TemplateDiagnosticsListSchema,
45
+ removed: TemplateDiagnosticsListSchema,
46
+ added_errors: TemplateDiagnosticsListSchema,
47
+ })
48
+ .strict(),
49
+ z
50
+ .object({
51
+ status: z.enum(["unavailable", "unfinished"]),
52
+ reason: z.enum([
53
+ "compiler_cli_missing",
54
+ "compiler_unsupported",
55
+ "typescript_incompatible",
56
+ "work_exhausted",
57
+ "deadline",
58
+ "check_failed",
59
+ ]),
60
+ added: NonCompletedTemplateListSchema,
61
+ removed: NonCompletedTemplateListSchema,
62
+ added_errors: NonCompletedTemplateListSchema,
63
+ })
64
+ .strict(),
65
+ ]);
66
+ /** Only `ast_rename_symbol` can carry template diagnostics, so only its contract changes. */
67
+ export const RenameOperationOutputSchema = PreparedOperationOutputSchema.extend({
68
+ template_diagnostics: TemplateDiagnosticsSchema.optional(),
69
+ });
70
+ /** The one place that turns the record shape (`addedErrors`) into the wire shape. */
71
+ export function toTemplateDiagnosticsOutput(delta) {
72
+ const lists = { added: delta.added, removed: delta.removed, added_errors: delta.addedErrors };
73
+ return delta.status === "completed"
74
+ ? { status: "completed", ...lists }
75
+ : { status: delta.status, reason: delta.reason, ...lists };
76
+ }
37
77
  export function serializePreparedOperation(operation) {
38
78
  if (operation.status !== "prepared") {
39
79
  throw new Error(`Operation ${operation.operation_id} is not in prepared state.`);
@@ -60,5 +100,8 @@ export function serializePreparedOperation(operation) {
60
100
  block_reason: operation.block_reason,
61
101
  preview: operation.preview,
62
102
  preview_truncated: operation.preview_truncated,
103
+ ...(operation.template_diagnostics === undefined
104
+ ? {}
105
+ : { template_diagnostics: toTemplateDiagnosticsOutput(operation.template_diagnostics) }),
63
106
  };
64
107
  }
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { prepareRename } from "../services/operations.js";
3
3
  import { createRequestContext } from "../services/request-context.js";
4
- import { PreparedOperationOutputSchema, serializePreparedOperation } from "./operation-schema.js";
4
+ import { RenameOperationOutputSchema, serializePreparedOperation } from "./operation-schema.js";
5
5
  import { createToolErrorContext, errorResult, structuredResult } from "./result.js";
6
6
  const TOOL_NAME = "ast_rename_symbol";
7
7
  const AstRenameSymbolInputSchema = z.object({
@@ -17,20 +17,24 @@ const AstRenameSymbolInputSchema = z.object({
17
17
  .boolean()
18
18
  .default(false)
19
19
  .describe("Permit a plan with new TypeScript errors after explicit review."),
20
+ block_on_template_errors: z
21
+ .boolean()
22
+ .default(false)
23
+ .describe("Block the plan when a completed template check adds template errors. allow_new_errors still waives it."),
20
24
  });
21
25
  export function registerRenameSymbol(server) {
22
26
  server.registerTool(TOOL_NAME, {
23
27
  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. The plan also rewrites external Angular templates, including consumer templates when coverage is completed, hash-bound to the workspace snapshot; diagnostics stay TypeScript-only.",
28
+ 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, including consumer templates when coverage is completed, hash-bound to the workspace snapshot. The diagnostics field stays TypeScript-only; when templates are edited, template_diagnostics reports the template type-check delta separately with a typed status, and blocks only with block_on_template_errors=true.",
25
29
  inputSchema: AstRenameSymbolInputSchema,
26
- outputSchema: PreparedOperationOutputSchema,
30
+ outputSchema: RenameOperationOutputSchema,
27
31
  annotations: {
28
32
  readOnlyHint: true,
29
33
  destructiveHint: false,
30
34
  idempotentHint: false,
31
35
  openWorldHint: false,
32
36
  },
33
- }, async ({ project_root, file_path, symbol_path, new_name, dry_run, allow_new_errors }, extra) => {
37
+ }, async ({ project_root, file_path, symbol_path, new_name, dry_run, allow_new_errors, block_on_template_errors, }, extra) => {
34
38
  const requestContext = createRequestContext(extra.signal);
35
39
  try {
36
40
  if (!dry_run) {
@@ -42,6 +46,7 @@ export function registerRenameSymbol(server) {
42
46
  symbolPath: symbol_path,
43
47
  newName: new_name,
44
48
  allowNewErrors: allow_new_errors,
49
+ blockOnTemplateErrors: block_on_template_errors,
45
50
  }, requestContext);
46
51
  return structuredResult(serializePreparedOperation(operation));
47
52
  }
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+ /** Output schemas for Angular template evidence, shared by every tool that reports references. */
3
+ export const ReferenceOriginSchema = z.enum(["compiler", "template_external", "template_inline"]);
4
+ export const TemplateCoverageSchema = z.object({
5
+ status: z.enum(["completed", "unfinished", "unsupported"]),
6
+ reason: z
7
+ .enum([
8
+ "compiler_missing",
9
+ "compiler_unsupported",
10
+ "template_unresolved",
11
+ "template_unsafe",
12
+ "template_parse_error",
13
+ "size_exceeded",
14
+ "work_exhausted",
15
+ "inherited_member",
16
+ "cross_component",
17
+ ])
18
+ .optional(),
19
+ templates: z.number().int().min(0).optional(),
20
+ });
21
+ /** Present only when template coverage applies to the symbol. */
22
+ export const templateEvidenceFields = {
23
+ template_coverage: TemplateCoverageSchema.optional(),
24
+ template_reference_count: z.number().int().min(0).optional(),
25
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ast-mcp-server",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
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",
@@ -26,6 +26,9 @@
26
26
  },
27
27
  "type": "module",
28
28
  "packageManager": "yarn@4.15.0",
29
+ "workspaces": [
30
+ "test/angular-matrix/*"
31
+ ],
29
32
  "dsh": {
30
33
  "bundle": {
31
34
  "patch": "./cordis.patch.yml"
@@ -65,12 +68,14 @@
65
68
  "hono": "4.13.13",
66
69
  "minimatch": "11.0.3",
67
70
  "nanoid": "3.3.18",
68
- "qs": "6.16.1"
71
+ "qs": "6.16.1",
72
+ "@babel/core@npm:7.25.2": "npm:7.29.7",
73
+ "@babel/core@npm:7.26.9": "npm:7.29.7"
69
74
  },
70
75
  "scripts": {
71
76
  "build": "tsc",
72
77
  "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
73
- "test": "vitest run --exclude test/supervised-worker.integration.test.ts && yarn build && vitest run test/supervised-worker.integration.test.ts --no-file-parallelism",
78
+ "test": "vitest run --exclude test/supervised-worker.integration.test.ts --exclude test/angular-template-typecheck.integration.test.ts --exclude test/operations-template-typecheck.integration.test.ts && yarn build && vitest run test/supervised-worker.integration.test.ts test/angular-template-typecheck.integration.test.ts test/operations-template-typecheck.integration.test.ts --no-file-parallelism",
74
79
  "test:watch": "vitest",
75
80
  "test:mcp": "yarn build && node scripts/mcp-smoke.mjs",
76
81
  "test:lifecycle": "yarn build && node scripts/mcp-lifecycle-smoke.mjs",
@@ -86,6 +91,7 @@
86
91
  "benchmark:corpus": "yarn build && node scripts/benchmark-corpus.mjs",
87
92
  "benchmark:batch": "yarn build && node scripts/benchmark-batch.mjs",
88
93
  "benchmark:formats": "yarn build && node scripts/benchmark-formats.mjs",
94
+ "benchmark:explore-templates": "yarn build && node scripts/benchmark-explore-templates.mjs",
89
95
  "benchmark:shapes": "yarn build && node scripts/benchmark-result-shapes.mjs",
90
96
  "benchmark:agent-workflows": "yarn build && node scripts/benchmark-agent-workflows.mjs",
91
97
  "benchmark:index-storage": "yarn build && node scripts/benchmark-index-storage.mjs",
@@ -135,5 +141,5 @@
135
141
  "vitest": "^5.0.5",
136
142
  "yaml": "^2.9.0"
137
143
  },
138
- "gitHead": "0f5a3f137703fdd673640d00a04087db3b2808af"
144
+ "gitHead": "9154dc7bd17f326e04c7b7e4dbfb9fcde9a66cdf"
139
145
  }
@@ -4,7 +4,7 @@ description: "Trigger: navegar, analizar o editar TypeScript/JavaScript con el c
4
4
  license: "ISC"
5
5
  metadata:
6
6
  author: "yail"
7
- version: "4.7.0"
7
+ version: "4.9.0"
8
8
  hermes:
9
9
  tags: ["typescript", "javascript", "ast", "refactoring", "mcp"]
10
10
  homepage: "https://github.com/yailPeralta/ast-mcp-server"
@@ -49,6 +49,11 @@ In an Angular project (`@angular/core` resolvable from the tsconfig directory),
49
49
 
50
50
  Read `template_coverage` before inferring absence. Only `completed` proves every template it covers was examined. `unfinished` (for example `compiler_missing`, `compiler_unsupported`, `template_unresolved`, `template_parse_error`, `size_exceeded`, `cross_component`, `work_exhausted`) and `unsupported` (for example an inherited member) never prove that no template usage exists. `not_applicable` means the feature is off and the response is unchanged.
51
51
 
52
+ `ast_explore` reports the same template evidence in `evidence[].references` whenever it expands references (`detail: "full"` or `include_references: true`): each item carries `origin`, and the block adds `template_coverage` and `template_reference_count` with the verdict `ast_find_references` gives for the same symbol. None of these keys appear when coverage is `not_applicable`. Read `template_coverage` before trusting explore references, and treat every status other than `completed` as unknown.
53
+
54
+ - In `ast_explore`, `reference_limit` bounds the merged list of TypeScript and template items, even though its input description still says "compiler references". `reference_count` counts TypeScript items only, and a list cut by the limit raises the usual `reference_limit` omission. `ast_explore` has no reference offset: page the remaining items through `ast_find_references`.
55
+ - Any `template_coverage.status` other than `completed` adds one `template_coverage_incomplete` omission (category `incomplete`, component `references`) per symbol and sets `evidence_complete: false`. The reason code (for example `compiler_missing`) is in `template_coverage`. `omissions.details` lists the omission only when the detail limit leaves room; `omissions.counts` and `omissions.total` always count it, also when `max_bytes` drops the references block.
56
+
52
57
  Consumer templates: for a member bound through the selector (`@Input`, `@Output`, `input()`, `output()`, `model()`, a literal alias, or a literal entry of the `inputs` / `outputs` metadata), the binding keys `[x]`, `x="lit"`, `[(x)]`, `(y)` and `(xChange)` are listed with kinds `TemplateInputBinding` for `[x]`, `TemplateAttribute` for `x="lit"`, `TemplateTwoWayBinding` for `[(x)]` and `TemplateOutputBinding` for `(y)` and `(xChange)` in the templates of components whose resolved scope contains the declaring component (literal standalone `imports`, or NgModule `declarations` plus the `exports` of imported modules). For a `@Pipe` class with a literal `name`, and for its `transform` member, `| name` usages in in-scope templates are listed with kind `TemplatePipeName`. A same selector or pipe name outside that scope is ignored.
53
58
 
54
59
  For these members, `completed` means template evidence inside the Project only: every component template of the Project (the tsconfig passed as `project_root`) was read and every match resolved. `hostDirectives`, `@Directive` inputs and outputs, and non-literal scopes, aliases, metadata or pipe names stay `unfinished` with `cross_component`. Runtime bindings (`setInput`, router input binding, `createComponent` and other dynamic component APIs) and consumers outside the Project (other tsconfigs, libraries, other applications) are never examined, so `completed` does not cover them.
@@ -59,8 +64,8 @@ Pipes: renaming a `@Pipe` class applies with TypeScript edits only; the `name` l
59
64
 
60
65
  Limits to state when reporting:
61
66
 
62
- - `ast_explore` is TypeScript-only: its references omit template usages. This is tracked in issue #380. Use `ast_find_references` for template evidence; rename still blocks independently.
63
- - Diagnostics stay TypeScript-only: `ast_get_diagnostics` and the post-apply delta do not detect template errors, including in rewritten consumer templates. A template-only breakage produces no diagnostic, so run the project's Angular build or tests after a rename. Template type checking of rename plans is tracked in issue #421.
67
+ - `ast_get_impact`, `ast_find_test_candidates` and the `ast_explore` call spines stay compiler-only: they never contain a template usage. Rename blocks on its own evidence, independently of these tools.
68
+ - The `diagnostics` field stays TypeScript-only. When a rename edits a template, the plan also carries `template_diagnostics`, a best-effort template type-check delta (`status`, `reason`, `added`, `removed`, `added_errors`) from the project's own `@angular/compiler-cli`; it is absent for non-Angular projects and for renames that edit no template. `completed` is the only status with data; `unavailable` (`compiler_cli_missing`, `compiler_unsupported`, `typescript_incompatible`) and `unfinished` (`work_exhausted`, `deadline`, `check_failed`) mean unknown, so run the project's build or tests before applying. `block_on_template_errors` (default `false`) blocks only a `completed` delta with `added_errors`, and `allow_new_errors` waives it. `template_diagnostics[].file` is relative to the directory of the tsconfig, while `diagnostics[].file` is relative to `project_root`. `@angular/compiler-cli` is not a declared peer and must resolve from the project, otherwise the status is `unavailable/compiler_cli_missing`. Template-editing renames persist as plan schema v3, which is rejected on import after a downgrade.
64
69
  - Detection uses the user's own `@angular/compiler`, never a bundled one. It is an optional peer (`>=18.0.0 <23.0.0`) that the project already installs with Angular. If it is missing or out of range, coverage is `unfinished` and rename is blocked. Yarn PnP projects without `node_modules` resolve nothing and report `unfinished`.
65
70
 
66
71
  ## Contributor contract
@@ -22,7 +22,11 @@ Preferir `file_path` relativo; un suffix ambiguo debe devolver candidatos. `ast_
22
22
 
23
23
  ### Angular templates
24
24
 
25
- `ast_find_references` adds template usages (`origin: "template_external" | "template_inline"`, `template_coverage`, `template_reference_count`) only when `@angular/core` resolves; otherwise the response is unchanged. Treat `template_coverage.status` other than `completed` as not proving absence. Selector-bound members (`@Input`, `@Output`, `input()`, `output()`, `model()`, literal aliases, literal `inputs` / `outputs` metadata) also list their bindings in consumer templates (kinds `TemplateInputBinding` for `[x]`, `TemplateAttribute` for `x="lit"`, `TemplateTwoWayBinding` for `[(x)]` and `TemplateOutputBinding` for `(y)` and `(xChange)`), and `@Pipe` classes and their `transform` list `| name` usages (kind `TemplatePipeName`), for components whose resolved standalone or NgModule scope contains the declaring class. For them, `completed` covers templates inside the Project only. `hostDirectives`, `@Directive` inputs and outputs, and non-literal scopes, aliases or names stay `unfinished` with `cross_component`; runtime bindings (`setInput`, router input binding, `createComponent`) and consumers outside the Project are never examined. `ast_explore` is TypeScript-only for references (tracked in issue #380), and diagnostics stay TypeScript-only, so template errors are never detected; template type checking of rename plans is tracked in issue #421.
25
+ `ast_find_references` adds template usages (`origin: "template_external" | "template_inline"`, `template_coverage`, `template_reference_count`) only when `@angular/core` resolves; otherwise the response is unchanged. Treat `template_coverage.status` other than `completed` as not proving absence. Selector-bound members (`@Input`, `@Output`, `input()`, `output()`, `model()`, literal aliases, literal `inputs` / `outputs` metadata) also list their bindings in consumer templates (kinds `TemplateInputBinding` for `[x]`, `TemplateAttribute` for `x="lit"`, `TemplateTwoWayBinding` for `[(x)]` and `TemplateOutputBinding` for `(y)` and `(xChange)`), and `@Pipe` classes and their `transform` list `| name` usages (kind `TemplatePipeName`), for components whose resolved standalone or NgModule scope contains the declaring class. For them, `completed` covers templates inside the Project only. `hostDirectives`, `@Directive` inputs and outputs, and non-literal scopes, aliases or names stay `unfinished` with `cross_component`; runtime bindings (`setInput`, router input binding, `createComponent`) and consumers outside the Project are never examined.
26
+
27
+ `ast_explore` reports the same template evidence in `evidence[].references` whenever it expands references (`detail: "full"` or `include_references: true`): each item carries `origin`, and the block adds `template_coverage` and `template_reference_count` with the verdict `ast_find_references` gives for the same symbol. None of these keys appear when coverage is `not_applicable`. Read `template_coverage` before trusting explore references. In `ast_explore`, `reference_limit` bounds the merged list of TypeScript and template items, even though its input description still says "compiler references". `reference_count` counts TypeScript items only, and a list cut by the limit raises the usual `reference_limit` omission. `ast_explore` has no reference offset: page the remaining items through `ast_find_references`. Any `template_coverage.status` other than `completed` adds one `template_coverage_incomplete` omission (category `incomplete`, component `references`) per symbol and sets `evidence_complete: false`. The reason code (for example `compiler_missing`) is in `template_coverage`. `omissions.details` lists the omission only when the detail limit leaves room; `omissions.counts` and `omissions.total` always count it, also when `max_bytes` drops the references block. `ast_get_impact`, `ast_find_test_candidates` and the `ast_explore` call spines stay compiler-only: they never contain a template usage.
28
+
29
+ The `diagnostics` field stays TypeScript-only. When a rename edits a template, the plan also carries `template_diagnostics`, a best-effort template type-check delta (`status`, `reason`, `added`, `removed`, `added_errors`) from the project's own `@angular/compiler-cli`; it is absent for non-Angular projects and for renames that edit no template. `completed` is the only status with data; `unavailable` (`compiler_cli_missing`, `compiler_unsupported`, `typescript_incompatible`) and `unfinished` (`work_exhausted`, `deadline`, `check_failed`) mean unknown, so run the project's build or tests before applying. `block_on_template_errors` (default `false`) blocks only a `completed` delta with `added_errors`, and `allow_new_errors` waives it. `template_diagnostics[].file` is relative to the directory of the tsconfig, while `diagnostics[].file` is relative to `project_root`. `@angular/compiler-cli` is not a declared peer and must resolve from the project, otherwise the status is `unavailable/compiler_cli_missing`. Template-editing renames persist as plan schema v3, which is rejected on import after a downgrade.
26
30
 
27
31
  `ast_rename_symbol` edits external templates, including consumer templates of a selector-bound member with `completed` coverage, and returns `INCOMPLETE_EVIDENCE` (coverage `unfinished` or `unsupported`, `hostDirectives`, `@Directive` members, metadata-listed members, non-literal aliases) or `MUTATION_BLOCKED` (inline usage, template-local or binding-name collision, `[(x)]` on a non-`model()` pair, `$any`, name in the selector). Renaming a `@Pipe` class applies TypeScript edits only and keeps the `name` literal and `| name` usages; renaming `transform` returns `INCOMPLETE_EVIDENCE`. The user's `@angular/compiler` is an optional peer, never bundled; Yarn PnP projects without `node_modules` report `unfinished`. See the Angular templates section of `SKILL.md`.
28
32