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.
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, readFileSync, realpathSync } from "node:fs";
2
+ import { readFileSync, realpathSync } from "node:fs";
3
3
  import { readFile, stat } from "node:fs/promises";
4
4
  import { createRequire } from "node:module";
5
5
  import path from "node:path";
@@ -8,45 +8,17 @@ import { Node, SyntaxKind } from "ts-morph";
8
8
  import { angularCoreMajor, componentDecorator, isCoreDecorator, isDirectiveClass, isMetadataPublic, isPipeClass, isSelectorPublic, literalString, bindingNames, metadataBindingNames, } from "./angular-decorators.js";
9
9
  import { extractConsumerUsages, mayMatch, } from "./angular-template-bindings.js";
10
10
  import { createScopeResolver, MAX_SCOPE_VISITS, ScopeWorkExhausted, } from "./angular-template-scope.js";
11
+ import { isSupportedVersion, isUnderAncestorNodeModules, isWithin, resolveFrom, } from "./angular-package-resolution.js";
11
12
  import { assertSafeOperationFile } from "./safe-file.js";
12
13
  const MAX_TEMPLATE_BYTES = 512 * 1024;
13
14
  const MAX_TEMPLATE_NODES = 100_000;
14
15
  const CHECKPOINT_EVERY_NODES = 1000;
15
- const MIN_COMPILER_MAJOR = 18;
16
- const MAX_COMPILER_MAJOR = 22;
17
16
  const TWO_WAY_BINDING = 5; // BindingType.TwoWay, stable across supported majors
18
17
  const TWO_WAY_EVENT = 2; // ParsedEventType.TwoWay, stable across supported majors
19
18
  const SCAN_LIMITS = { templates: 2000, bytes: 32 * 1024 * 1024, nodes: 1_000_000 };
20
19
  const NOT_APPLICABLE = { coverage: { status: "not_applicable" }, usages: [] };
21
20
  const ASSIGNMENT_OPERATION = /^(?:=|(?:\*\*|&&|\|\||\?\?|<<|>>>|>>|[+\-*/%&|^])=)$/u;
22
21
  const compilerCache = new Map();
23
- function resolveFrom(tsConfigFilePath, request) {
24
- try {
25
- const resolved = createRequire(tsConfigFilePath).resolve(request);
26
- return existsSync(resolved) ? resolved : undefined;
27
- }
28
- catch {
29
- return undefined;
30
- }
31
- }
32
- function isWithin(parent, child) {
33
- const relative = path.relative(parent, child);
34
- return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
35
- }
36
- /** True when `packageDir` sits in a node_modules directory owned by `projectDir` or an ancestor. */
37
- function isUnderAncestorNodeModules(packageDir, projectDir) {
38
- const segments = packageDir.split(path.sep);
39
- return segments.some((segment, index) => segment === "node_modules" &&
40
- isWithin(segments.slice(0, index).join(path.sep) || path.sep, projectDir));
41
- }
42
- function isSupportedVersion(version) {
43
- const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+.*)?$/u.exec(String(version));
44
- if (!match)
45
- return false;
46
- const [major, minor, patch] = [match[1], match[2], match[3]].map(Number);
47
- const belowFloor = major === MIN_COMPILER_MAJOR && minor === 0 && patch === 0 && !!match[4];
48
- return major >= MIN_COMPILER_MAJOR && major <= MAX_COMPILER_MAJOR && !belowFloor;
49
- }
50
22
  async function loadCompiler(tsConfigFilePath) {
51
23
  const unsupported = { reason: "compiler_unsupported" };
52
24
  const manifestPath = resolveFrom(tsConfigFilePath, "@angular/compiler/package.json");
@@ -203,10 +175,10 @@ export function memberOwner(node) {
203
175
  ? { cls: owner, name: nameNode.getText() }
204
176
  : undefined;
205
177
  }
206
- function hasDerivedComponent(context, cls) {
178
+ function hasDerivedComponent(cls, cache) {
207
179
  const identity = (node) => `${node.getSourceFile().getFilePath()}:${node.getPos()}`;
208
180
  const target = identity(cls);
209
- return componentClasses(context).some((candidate) => {
181
+ return cache.components().some((candidate) => {
210
182
  let base = candidate.getBaseClass();
211
183
  for (let depth = 0; base && depth < 32; depth += 1) {
212
184
  if (identity(base) === target)
@@ -323,9 +295,12 @@ function extractUsages(compiler, nodes, query, rc, onNode = () => { }) {
323
295
  return collector.names;
324
296
  };
325
297
  class Templates extends compiler.TmplAstRecursiveVisitor {
298
+ // v18/v19 `tmplAstVisitAll` runs `visitor.visit(node) || node.visit(visitor)`; returning
299
+ // true marks the node handled so each subtree is walked once instead of twice per level.
326
300
  visit(node) {
327
301
  tick();
328
302
  node.visit(this);
303
+ return true;
329
304
  }
330
305
  view(locals, children, before) {
331
306
  const names = [...locals, ...referencesOf(children)].filter((name) => !!name);
@@ -431,6 +406,73 @@ function decodeTemplate(bytes) {
431
406
  return undefined;
432
407
  }
433
408
  }
409
+ /**
410
+ * Template work that does not depend on the queried symbol, shared by the collections of one
411
+ * request: the component list, each located template, each read and each parse. It is bound to the
412
+ * {@link ProjectContext} it was created for, and a collection over another context refuses it. It
413
+ * never outlives the request, so nothing is invalidated.
414
+ *
415
+ * Only whole results are kept. An entry is stored after its step returned, so a step that threw is
416
+ * run again by the next collection, and a scan that stopped early leaves complete entries for the
417
+ * templates it reached and none for the rest. Scan budgets are not part of it: every collection
418
+ * charges its own budget for each template, cached or not.
419
+ */
420
+ export class TemplateScanCache {
421
+ context;
422
+ #components;
423
+ #located = new Map();
424
+ /** External templates by path, so a file shared by two components is read once; inline by location. */
425
+ #reads = new Map();
426
+ #parsed = new WeakMap();
427
+ #lines = new Map();
428
+ constructor(context) {
429
+ this.context = context;
430
+ }
431
+ components() {
432
+ return (this.#components ??= componentClasses(this.context));
433
+ }
434
+ locate(cls) {
435
+ let located = this.#located.get(cls);
436
+ if (!located) {
437
+ located = locateTemplate(cls);
438
+ this.#located.set(cls, located);
439
+ }
440
+ return located;
441
+ }
442
+ async read(located) {
443
+ const key = located.container === "external" ? located.absolutePath : located;
444
+ let read = this.#reads.get(key);
445
+ if (!read) {
446
+ read = await readTemplate(this.context, located);
447
+ this.#reads.set(key, read);
448
+ }
449
+ return read;
450
+ }
451
+ /**
452
+ * The lines of the file that holds a usage, for context lines. An external template that the
453
+ * scan read is split from that text; any other file (a source file with an inline template) is
454
+ * read from disk once.
455
+ */
456
+ async lines(absolutePath) {
457
+ let lines = this.#lines.get(absolutePath);
458
+ if (!lines) {
459
+ const read = this.#reads.get(absolutePath);
460
+ const text = read && "text" in read ? read.text : await readFile(absolutePath, "utf8");
461
+ lines = text.split(/\r?\n/);
462
+ this.#lines.set(absolutePath, lines);
463
+ }
464
+ return lines;
465
+ }
466
+ /** The parse of a template returned by {@link read}, errors included. */
467
+ parse(compiler, read, absolutePath) {
468
+ let parsed = this.#parsed.get(read);
469
+ if (!parsed) {
470
+ parsed = compiler.parseTemplate(read.text, absolutePath, { preserveWhitespaces: true });
471
+ this.#parsed.set(read, parsed);
472
+ }
473
+ return parsed;
474
+ }
475
+ }
434
476
  /** Reads a located template within the per-template size cap. */
435
477
  async function readTemplate(context, located) {
436
478
  let text;
@@ -492,16 +534,11 @@ function bindingTarget(cls, node, name) {
492
534
  return undefined;
493
535
  return { selector, inputs: names.inputs, outputs: names.outputs };
494
536
  }
495
- /** Parses a consumer template, or returns undefined when the compiler reports errors. */
496
- function parseConsumer(compiler, text, absolutePath) {
497
- const parsed = compiler.parseTemplate(text, absolutePath, { preserveWhitespaces: true });
498
- return parsed.errors?.length ? undefined : parsed.nodes;
499
- }
500
537
  function selectorInspector(compiler, target) {
501
- return (text, absolutePath, tick) => {
538
+ return (text, parse, tick) => {
502
539
  if (!mayMatch(compiler, target.selector, text))
503
540
  return "skip";
504
- const nodes = parseConsumer(compiler, text, absolutePath);
541
+ const nodes = parse();
505
542
  if (!nodes)
506
543
  return "template_parse_error";
507
544
  const scan = extractConsumerUsages(compiler, nodes, text, target, tick);
@@ -514,10 +551,10 @@ function selectorInspector(compiler, target) {
514
551
  };
515
552
  }
516
553
  function pipeInspector(compiler, name, rc) {
517
- return (text, absolutePath, tick) => {
554
+ return (text, parse, tick) => {
518
555
  if (!text.includes(name))
519
556
  return "skip";
520
- const nodes = parseConsumer(compiler, text, absolutePath);
557
+ const nodes = parse();
521
558
  if (!nodes)
522
559
  return "template_parse_error";
523
560
  try {
@@ -536,7 +573,7 @@ function pipeInspector(compiler, name, rc) {
536
573
  * Scans every component template in the Project with `inspect`. Matching comes first; scope only
537
574
  * filters matches. Unreadable templates and unproven scopes are reported.
538
575
  */
539
- async function scanConsumers(context, cls, inspect, rc, limits) {
576
+ async function scanConsumers(context, cls, compiler, inspect, rc, limits, cache) {
540
577
  const remaining = { ...SCAN_LIMITS, ...limits };
541
578
  const usages = [];
542
579
  let failure;
@@ -553,25 +590,30 @@ async function scanConsumers(context, cls, inspect, rc, limits) {
553
590
  rc.checkpoint();
554
591
  };
555
592
  try {
556
- for (const consumer of componentClasses(context)) {
593
+ for (const consumer of cache.components()) {
557
594
  rc.checkpoint();
558
595
  if (--remaining.templates < 0)
559
596
  throw new WorkExhausted();
560
- const located = locateTemplate(consumer);
597
+ const located = cache.locate(consumer);
561
598
  if ("reason" in located) {
562
599
  failure ??= located.reason;
563
600
  continue;
564
601
  }
565
- const read = await readTemplate(context, located);
602
+ const read = await cache.read(located);
566
603
  if ("reason" in read) {
567
604
  failure ??= read.reason;
568
605
  continue;
569
606
  }
607
+ // The budget is charged for every template, whether or not the cache already held it.
570
608
  remaining.bytes -= read.bytes.length;
571
609
  if (remaining.bytes < 0)
572
610
  throw new WorkExhausted();
573
611
  examined += 1;
574
- const scan = inspect(read.text, located.absolutePath, tick);
612
+ const parse = () => {
613
+ const parsed = cache.parse(compiler, read, located.absolutePath);
614
+ return parsed.errors?.length ? undefined : parsed.nodes;
615
+ };
616
+ const scan = inspect(read.text, parse, tick);
575
617
  if (scan === "skip")
576
618
  continue;
577
619
  if (scan === "template_parse_error") {
@@ -608,7 +650,7 @@ function pipeName(cls) {
608
650
  return literalString(Node.isPropertyAssignment(property) ? property.getInitializer() : undefined);
609
651
  }
610
652
  /** `| name` usages of a pipe class in the templates of components that have the class in scope. */
611
- async function collectPipeUsages(cls, context, rc, limits) {
653
+ async function collectPipeUsages(cls, context, rc, limits, cache) {
612
654
  const unfinished = (reason, templates = 0) => ({
613
655
  coverage: { status: "unfinished", reason, templates },
614
656
  usages: [],
@@ -619,15 +661,23 @@ async function collectPipeUsages(cls, context, rc, limits) {
619
661
  const loaded = await loadCompiler(context.tsConfigFilePath);
620
662
  if ("reason" in loaded)
621
663
  return unfinished(loaded.reason);
622
- const scan = await scanConsumers(context, cls, pipeInspector(loaded.compiler, name, rc), rc, limits);
664
+ const scan = await scanConsumers(context, cls, loaded.compiler, pipeInspector(loaded.compiler, name, rc), rc, limits, cache);
623
665
  const coverage = scan.failure
624
666
  ? { status: "unfinished", reason: scan.failure, templates: scan.examined }
625
667
  : { status: "completed", templates: scan.examined };
626
668
  return { coverage, usages: scan.usages };
627
669
  }
628
- /** Collects usages of a component member inside its declaring component's template. */
629
- export async function collectTemplateUsages(node, context, rc, limits = {}) {
670
+ /**
671
+ * Collects usages of a component member inside its declaring component's template.
672
+ *
673
+ * Pass one `cache` to every collection of a request to share symbol-independent work between
674
+ * them; the result is the same with or without it. By default each call works alone.
675
+ */
676
+ export async function collectTemplateUsages(node, context, rc, limits = {}, cache = new TemplateScanCache(context)) {
630
677
  rc.checkpoint();
678
+ if (cache.context !== context) {
679
+ throw new Error("The template scan cache was created for another ProjectContext.");
680
+ }
631
681
  if (!hasAngularCore(context))
632
682
  return NOT_APPLICABLE;
633
683
  const fail = (status, reason) => ({
@@ -635,7 +685,7 @@ export async function collectTemplateUsages(node, context, rc, limits = {}) {
635
685
  usages: [],
636
686
  });
637
687
  if (Node.isClassDeclaration(node)) {
638
- return isPipeClass(node) ? collectPipeUsages(node, context, rc, limits) : NOT_APPLICABLE;
688
+ return isPipeClass(node) ? collectPipeUsages(node, context, rc, limits, cache) : NOT_APPLICABLE;
639
689
  }
640
690
  const owner = memberOwner(node);
641
691
  if (!owner)
@@ -645,14 +695,17 @@ export async function collectTemplateUsages(node, context, rc, limits = {}) {
645
695
  if (owner.name !== "transform" || componentDecorator(owner.cls)) {
646
696
  return fail("unfinished", "cross_component");
647
697
  }
648
- return { ...(await collectPipeUsages(owner.cls, context, rc, limits)), renameBlocked: true };
698
+ return {
699
+ ...(await collectPipeUsages(owner.cls, context, rc, limits, cache)),
700
+ renameBlocked: true,
701
+ };
649
702
  }
650
703
  const isComponent = componentDecorator(owner.cls) !== undefined;
651
704
  const selectorPublic = isSelectorPublic(node) || isMetadataPublic(owner.cls, owner.name);
652
705
  if (!isComponent && selectorPublic && isDirectiveClass(owner.cls)) {
653
706
  return fail("unfinished", "cross_component");
654
707
  }
655
- const derived = hasDerivedComponent(context, owner.cls);
708
+ const derived = hasDerivedComponent(owner.cls, cache);
656
709
  if (!isComponent && !derived)
657
710
  return NOT_APPLICABLE;
658
711
  if (derived)
@@ -660,16 +713,14 @@ export async function collectTemplateUsages(node, context, rc, limits = {}) {
660
713
  const loaded = await loadCompiler(context.tsConfigFilePath);
661
714
  if ("reason" in loaded)
662
715
  return fail("unfinished", loaded.reason);
663
- const located = locateTemplate(owner.cls);
716
+ const located = cache.locate(owner.cls);
664
717
  if ("reason" in located)
665
718
  return fail("unfinished", located.reason);
666
- const read = await readTemplate(context, located);
719
+ const read = await cache.read(located);
667
720
  if ("reason" in read)
668
721
  return fail("unfinished", read.reason);
669
722
  rc.checkpoint();
670
- const parsed = loaded.compiler.parseTemplate(read.text, located.absolutePath, {
671
- preserveWhitespaces: true,
672
- });
723
+ const parsed = cache.parse(loaded.compiler, read, located.absolutePath);
673
724
  if (parsed.errors?.length)
674
725
  return fail("unfinished", "template_parse_error");
675
726
  let raw;
@@ -694,7 +745,7 @@ export async function collectTemplateUsages(node, context, rc, limits = {}) {
694
745
  return unfinished("cross_component");
695
746
  if (!loaded.selectors)
696
747
  return unfinished("compiler_unsupported");
697
- const scan = await scanConsumers(context, owner.cls, selectorInspector(loaded.compiler, target), rc, limits);
748
+ const scan = await scanConsumers(context, owner.cls, loaded.compiler, selectorInspector(loaded.compiler, target), rc, limits, cache);
698
749
  const coverage = scan.failure
699
750
  ? { status: "unfinished", reason: scan.failure, templates: scan.examined }
700
751
  : { status: "completed", templates: scan.examined };
@@ -1,11 +1,12 @@
1
1
  import path from "node:path";
2
+ import { TemplateScanCache } from "./angular-templates.js";
2
3
  import { buildFileOutline, nodeSourceWithLocation } from "./outline.js";
3
4
  import { paginate } from "./pagination.js";
4
5
  import { resolveImpactRoot } from "./impact.js";
5
6
  import { planCallSpines } from "./call-spines.js";
6
7
  import { isCooperativeInterruption, NO_REQUEST_CONTEXT, } from "./request-context.js";
7
8
  import { findDeclarationByName, getSourceFileOrThrow, reportSymbolIndexFailure, } from "./project.js";
8
- import { collectSymbolReferences } from "./references.js";
9
+ import { collectSymbolReferencesWithTemplateVerdict, } from "./references.js";
9
10
  import { collectCompilerCallRelationships } from "./relationships.js";
10
11
  import { presentExploreClusters } from "./explore-presentation.js";
11
12
  import { searchProjectSymbolsPage, searchProjectSymbolsPageWithIndex, sourceFileSymbols, symbolMatchRank, } from "./symbols.js";
@@ -151,6 +152,8 @@ export async function buildExploreContext(context, request, requestContext = NO_
151
152
  const unresolved = [];
152
153
  const evidence = [];
153
154
  let callSpines;
155
+ // Lives for this request only: the selected symbols share template reads, never a scan budget.
156
+ const templateCache = new TemplateScanCache(effectiveContext);
154
157
  if (request.callSpines) {
155
158
  if (!request.filePath || !request.symbolPath || routed.route !== "symbol") {
156
159
  throw new Error("Call spines require an exact file_path and symbol_path.");
@@ -197,7 +200,13 @@ export async function buildExploreContext(context, request, requestContext = NO_
197
200
  }
198
201
  if (expansion.references) {
199
202
  try {
200
- references = collectSymbolReferences(node, effectiveContext.projectRoot, record.selector, true, request.referenceDetail, 0, request.referenceLimit, requestContext);
203
+ references = await collectSymbolReferencesWithTemplateVerdict(node, effectiveContext, {
204
+ symbolPath: record.selector,
205
+ includeDeclaration: true,
206
+ detail: request.referenceDetail,
207
+ offset: 0,
208
+ limit: request.referenceLimit,
209
+ }, requestContext, templateCache);
201
210
  }
202
211
  catch (error) {
203
212
  if (isCooperativeInterruption(error))
@@ -231,6 +240,16 @@ export async function buildExploreContext(context, request, requestContext = NO_
231
240
  reason: "reference_limit",
232
241
  });
233
242
  }
243
+ // Absent coverage means not applicable; anything short of completed leaves usages unknown.
244
+ const coverage = item.references?.template_coverage?.status;
245
+ if (coverage !== undefined && coverage !== "completed" && coverage !== "not_applicable") {
246
+ omissions.push({
247
+ subject: item.selector,
248
+ category: "incomplete",
249
+ component: "references",
250
+ reason: "template_coverage_incomplete",
251
+ });
252
+ }
234
253
  }
235
254
  if (callSpines?.authority_state === "untrusted") {
236
255
  omissions.push({
@@ -9,6 +9,7 @@ export const EXPLORE_OMISSION_REASONS = [
9
9
  "depth_limit",
10
10
  "node_limit",
11
11
  "edge_limit",
12
+ "template_coverage_incomplete",
12
13
  ];
13
14
  function jsonBytes(value) {
14
15
  return Buffer.byteLength(JSON.stringify(value), "utf8");
@@ -38,7 +38,7 @@ export class H05LifecycleFixtureController {
38
38
  const generationSignal = this.#generationAbort.signal, signal = AbortSignal.any([context.signal, generationSignal]);
39
39
  const checkpoint = () => { if (!this.accepts(identity) || generationSignal.aborted)
40
40
  throw new H03TimeoutFixtureError(); context.checkpoint(); };
41
- const hooked = Object.freeze({ get sequence() { return context.sequence; }, get phase() { return context.phase; }, signal, checkpoint, markExecuting: () => context.markExecuting(), enterCompletionCritical: () => { checkpoint(); context.enterCompletionCritical(); } });
41
+ const hooked = Object.freeze({ get sequence() { return context.sequence; }, get phase() { return context.phase; }, signal, checkpoint, markExecuting: () => context.markExecuting(), remainingMs: () => context.remainingMs(), enterCompletionCritical: () => { checkpoint(); context.enterCompletionCritical(); } });
42
42
  this.#active += 1;
43
43
  this.#emit({ ...eventIdentity, phase: "started" });
44
44
  try {
@@ -201,6 +201,7 @@ export class H03TimeoutFixtureController {
201
201
  signal,
202
202
  checkpoint,
203
203
  markExecuting: () => context.markExecuting(),
204
+ remainingMs: () => context.remainingMs(),
204
205
  enterCompletionCritical: () => {
205
206
  checkpoint();
206
207
  context.enterCompletionCritical();
@@ -0,0 +1,19 @@
1
+ const REVIEW_HINT = "Prepare again with allow_new_errors=true only after explicit review.";
2
+ /**
3
+ * A plan is blocked by new TypeScript errors, and, only when opted in, by new template errors of
4
+ * a `completed` check. A check that did not complete never blocks. `allow_new_errors` waives both.
5
+ */
6
+ export function evaluateBlock(input) {
7
+ const templateErrors = input.blockOnTemplateErrors && input.template?.status === "completed"
8
+ ? input.template.addedErrors.length
9
+ : 0;
10
+ const reasons = [
11
+ ...(input.typescriptErrors > 0
12
+ ? [`${input.typescriptErrors} new TypeScript error(s) would be introduced.`]
13
+ : []),
14
+ ...(templateErrors > 0 ? [`${templateErrors} new template error(s) would be introduced.`] : []),
15
+ ];
16
+ if (input.allowNewErrors || reasons.length === 0)
17
+ return { blocked: false, reason: null };
18
+ return { blocked: true, reason: `${reasons.join(" ")} ${REVIEW_HINT}` };
19
+ }
@@ -6,6 +6,8 @@ import { z } from "zod";
6
6
  import { applyOperation, configureOperationApply, exportOperationRecord, importOperationRecord, } from "./operations.js";
7
7
  import { ensurePrivateDirectory, resolveRuntimeStateDirectory, } from "./runtime-state.js";
8
8
  export const PLAN_SCHEMA_VERSION = 2;
9
+ /** Plans that carry a template type-check delta persist as this version. */
10
+ export const TEMPLATE_PLAN_SCHEMA_VERSION = 3;
9
11
  export const MAX_PLAN_BYTES = 50 * 1024 * 1024;
10
12
  const HashSchema = z.string().regex(/^[a-f0-9]{64}$/);
11
13
  const IsoDateSchema = z.string().datetime({ offset: true });
@@ -38,8 +40,27 @@ const PersistedFileSchema = AffectedFileSchema.extend({
38
40
  updated_bytes_base64: z.string(),
39
41
  mode: z.number().int().nonnegative(),
40
42
  }).strict();
41
- const PersistedOperationSchema = z
42
- .object({
43
+ const TemplateReasonSchema = z.enum([
44
+ "compiler_cli_missing",
45
+ "compiler_unsupported",
46
+ "typescript_incompatible",
47
+ "work_exhausted",
48
+ "deadline",
49
+ "check_failed",
50
+ ]);
51
+ const TemplateDiagnosticsSchema = z.discriminatedUnion("status", [
52
+ DiagnosticDeltaSchema.extend({ status: z.literal("completed") }).strict(),
53
+ z
54
+ .object({
55
+ status: z.enum(["unavailable", "unfinished"]),
56
+ reason: TemplateReasonSchema,
57
+ added: z.array(DiagnosticSchema).max(0),
58
+ removed: z.array(DiagnosticSchema).max(0),
59
+ addedErrors: z.array(DiagnosticSchema).max(0),
60
+ })
61
+ .strict(),
62
+ ]);
63
+ const PersistedOperationShape = {
43
64
  operation_id: z.string().uuid(),
44
65
  plan_hash: HashSchema,
45
66
  kind: z.enum(["rename_symbol", "replace_symbol_body", "scaffold_class"]),
@@ -60,14 +81,24 @@ const PersistedOperationSchema = z
60
81
  preview_truncated: z.boolean(),
61
82
  applied_at: IsoDateSchema.optional(),
62
83
  files: z.array(PersistedFileSchema).min(1),
84
+ };
85
+ const PersistedOperationSchema = z.object(PersistedOperationShape).strict();
86
+ const PersistedOperationV3Schema = z
87
+ .object({
88
+ ...PersistedOperationShape,
89
+ template_diagnostics: TemplateDiagnosticsSchema,
90
+ block_on_template_errors: z.boolean(),
63
91
  })
64
92
  .strict();
65
93
  const PlanEnvelopeSchema = z.discriminatedUnion("schema_version", [
66
94
  z.object({ schema_version: z.literal(1), operation: PersistedOperationSchema }).strict(),
67
95
  z.object({ schema_version: z.literal(2), operation: PersistedOperationSchema }).strict(),
96
+ z.object({ schema_version: z.literal(3), operation: PersistedOperationV3Schema }).strict(),
68
97
  ]);
69
98
  function planEnvelope(version, operation) {
70
- return version === 1 ? { schema_version: 1, operation } : { schema_version: 2, operation };
99
+ if (version === 1)
100
+ return { schema_version: 1, operation };
101
+ return version === 2 ? { schema_version: 2, operation } : { schema_version: 3, operation };
71
102
  }
72
103
  function stateDirectory(options) {
73
104
  return resolveRuntimeStateDirectory(options);
@@ -148,7 +179,10 @@ async function readPlanEnvelope(planFileInput) {
148
179
  export async function persistOperationPlan(operationId, options = {}) {
149
180
  const plansDirectory = path.join(stateDirectory(options), "plans");
150
181
  const planFile = path.join(plansDirectory, `${operationId}.astplan`);
151
- await atomicWritePlan(planFile, planEnvelope(PLAN_SCHEMA_VERSION, exportOperationRecord(operationId)));
182
+ const record = exportOperationRecord(operationId);
183
+ await atomicWritePlan(planFile, planEnvelope(record.template_diagnostics === undefined
184
+ ? PLAN_SCHEMA_VERSION
185
+ : TEMPLATE_PLAN_SCHEMA_VERSION, record));
152
186
  return planFile;
153
187
  }
154
188
  export async function applyPersistedOperation(planFileInput, planHash, options = {}) {