ast-mcp-server 0.15.1 → 0.17.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,48 +1,24 @@
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";
6
6
  import { pathToFileURL } from "node:url";
7
7
  import { Node, SyntaxKind } from "ts-morph";
8
+ import { angularCoreMajor, componentDecorator, isCoreDecorator, isDirectiveClass, isMetadataPublic, isPipeClass, isSelectorPublic, literalString, bindingNames, metadataBindingNames, } from "./angular-decorators.js";
9
+ import { extractConsumerUsages, mayMatch, } from "./angular-template-bindings.js";
10
+ import { createScopeResolver, MAX_SCOPE_VISITS, ScopeWorkExhausted, } from "./angular-template-scope.js";
11
+ import { isSupportedVersion, isUnderAncestorNodeModules, isWithin, resolveFrom, } from "./angular-package-resolution.js";
8
12
  import { assertSafeOperationFile } from "./safe-file.js";
9
13
  const MAX_TEMPLATE_BYTES = 512 * 1024;
10
14
  const MAX_TEMPLATE_NODES = 100_000;
11
15
  const CHECKPOINT_EVERY_NODES = 1000;
12
- const MIN_COMPILER_MAJOR = 18;
13
- const MAX_COMPILER_MAJOR = 22;
14
16
  const TWO_WAY_BINDING = 5; // BindingType.TwoWay, stable across supported majors
15
17
  const TWO_WAY_EVENT = 2; // ParsedEventType.TwoWay, stable across supported majors
18
+ const SCAN_LIMITS = { templates: 2000, bytes: 32 * 1024 * 1024, nodes: 1_000_000 };
16
19
  const NOT_APPLICABLE = { coverage: { status: "not_applicable" }, usages: [] };
17
20
  const ASSIGNMENT_OPERATION = /^(?:=|(?:\*\*|&&|\|\||\?\?|<<|>>>|>>|[+\-*/%&|^])=)$/u;
18
21
  const compilerCache = new Map();
19
- function resolveFrom(tsConfigFilePath, request) {
20
- try {
21
- const resolved = createRequire(tsConfigFilePath).resolve(request);
22
- return existsSync(resolved) ? resolved : undefined;
23
- }
24
- catch {
25
- return undefined;
26
- }
27
- }
28
- function isWithin(parent, child) {
29
- const relative = path.relative(parent, child);
30
- return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
31
- }
32
- /** True when `packageDir` sits in a node_modules directory owned by `projectDir` or an ancestor. */
33
- function isUnderAncestorNodeModules(packageDir, projectDir) {
34
- const segments = packageDir.split(path.sep);
35
- return segments.some((segment, index) => segment === "node_modules" &&
36
- isWithin(segments.slice(0, index).join(path.sep) || path.sep, projectDir));
37
- }
38
- function isSupportedVersion(version) {
39
- const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+.*)?$/u.exec(String(version));
40
- if (!match)
41
- return false;
42
- const [major, minor, patch] = [match[1], match[2], match[3]].map(Number);
43
- const belowFloor = major === MIN_COMPILER_MAJOR && minor === 0 && patch === 0 && !!match[4];
44
- return major >= MIN_COMPILER_MAJOR && major <= MAX_COMPILER_MAJOR && !belowFloor;
45
- }
46
22
  async function loadCompiler(tsConfigFilePath) {
47
23
  const unsupported = { reason: "compiler_unsupported" };
48
24
  const manifestPath = resolveFrom(tsConfigFilePath, "@angular/compiler/package.json");
@@ -92,7 +68,12 @@ async function loadCompiler(tsConfigFilePath) {
92
68
  compiler.PropertyRead,
93
69
  compiler.Call,
94
70
  ].every((member) => typeof member === "function");
95
- return complete ? { compiler } : unsupported;
71
+ const selectors = [
72
+ compiler.CssSelector?.parse,
73
+ compiler.SelectorMatcher,
74
+ compiler.createCssSelectorFromNode,
75
+ ].every((member) => typeof member === "function");
76
+ return complete ? { compiler, selectors } : unsupported;
96
77
  }
97
78
  catch {
98
79
  return unsupported;
@@ -101,29 +82,6 @@ async function loadCompiler(tsConfigFilePath) {
101
82
  function hasAngularCore(context) {
102
83
  return resolveFrom(context.tsConfigFilePath, "@angular/core/package.json") !== undefined;
103
84
  }
104
- function isComponentDecorator(decorator) {
105
- const callee = decorator.getCallExpression()?.getExpression();
106
- if (!callee)
107
- return false;
108
- return decorator
109
- .getSourceFile()
110
- .getImportDeclarations()
111
- .filter((declaration) => declaration.getModuleSpecifierValue() === "@angular/core")
112
- .some((declaration) => {
113
- const namespace = declaration.getNamespaceImport();
114
- if (Node.isPropertyAccessExpression(callee)) {
115
- return (callee.getName() === "Component" &&
116
- callee.getExpression().getText() === namespace?.getText());
117
- }
118
- return declaration
119
- .getNamedImports()
120
- .some((specifier) => specifier.getName() === "Component" &&
121
- (specifier.getAliasNode()?.getText() ?? "Component") === callee.getText());
122
- });
123
- }
124
- function componentDecorator(cls) {
125
- return cls.getDecorators().find(isComponentDecorator);
126
- }
127
85
  /** Finds the template of a component class from literal metadata only. */
128
86
  function locateTemplate(cls) {
129
87
  const unresolved = { reason: "template_unresolved" };
@@ -201,7 +159,7 @@ export function listTemplateFiles(context) {
201
159
  }
202
160
  return [...files].sort();
203
161
  }
204
- function memberOwner(node) {
162
+ export function memberOwner(node) {
205
163
  let owner;
206
164
  if (Node.isParameterDeclaration(node)) {
207
165
  owner = node.isParameterProperty() ? node.getParent()?.getParent() : undefined;
@@ -232,8 +190,12 @@ function hasDerivedComponent(context, cls) {
232
190
  }
233
191
  class WorkExhausted extends Error {
234
192
  }
193
+ /** A pipe name span that does not slice to the name: the usage cannot be edited exactly. */
194
+ class SpanDrift extends Error {
195
+ }
235
196
  /** Walks parsed template nodes with a scope stack so template-local names shadow members. */
236
- function extractUsages(compiler, nodes, memberName, rc) {
197
+ function extractUsages(compiler, nodes, query, rc, onNode = () => { }) {
198
+ const memberName = "member" in query ? query.member : undefined;
237
199
  const usages = new Map();
238
200
  const scopes = [];
239
201
  let visited = 0;
@@ -242,6 +204,7 @@ function extractUsages(compiler, nodes, memberName, rc) {
242
204
  throw new WorkExhausted();
243
205
  if (visited % CHECKPOINT_EVERY_NODES === 0)
244
206
  rc.checkpoint();
207
+ onNode();
245
208
  };
246
209
  const isLocal = (name) => scopes.some((scope) => scope.has(name));
247
210
  const record = (ast, kind) => {
@@ -251,13 +214,15 @@ function extractUsages(compiler, nodes, memberName, rc) {
251
214
  start: ast.nameSpan.start,
252
215
  end: ast.nameSpan.end,
253
216
  kind: merged,
217
+ role: "own",
254
218
  localsInScope: [...new Set(scopes.flatMap((scope) => [...scope]))].sort(),
255
219
  });
256
220
  };
257
221
  // Explicit `this.` bypasses template locals; ThisReceiver only extends ImplicitReceiver in some majors.
258
222
  const isMemberReceiver = (receiver) => receiver instanceof compiler.ThisReceiver ||
259
223
  (receiver instanceof compiler.ImplicitReceiver && !isLocal(memberName));
260
- const isMember = (ast) => ast instanceof compiler.PropertyRead &&
224
+ const isMember = (ast) => memberName !== undefined &&
225
+ ast instanceof compiler.PropertyRead &&
261
226
  ast.name === memberName &&
262
227
  isMemberReceiver(ast.receiver);
263
228
  class Expressions extends compiler.RecursiveAstVisitor {
@@ -287,6 +252,15 @@ function extractUsages(compiler, nodes, memberName, rc) {
287
252
  record(ast.left, "TemplatePropertyWrite");
288
253
  this.visit(ast.right, context);
289
254
  }
255
+ visitPipe(ast, context) {
256
+ if ("pipe" in query && ast.name === query.pipe) {
257
+ if (query.text.slice(ast.nameSpan.start, ast.nameSpan.end) !== query.pipe) {
258
+ throw new SpanDrift();
259
+ }
260
+ record(ast, "TemplatePipeName");
261
+ }
262
+ super.visitPipe(ast, context);
263
+ }
290
264
  visitPropertyWrite(ast, context) {
291
265
  if (ast.name === memberName && isMemberReceiver(ast.receiver)) {
292
266
  record(ast, "TemplatePropertyWrite");
@@ -321,9 +295,12 @@ function extractUsages(compiler, nodes, memberName, rc) {
321
295
  return collector.names;
322
296
  };
323
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.
324
300
  visit(node) {
325
301
  tick();
326
302
  node.visit(this);
303
+ return true;
327
304
  }
328
305
  view(locals, children, before) {
329
306
  const names = [...locals, ...referencesOf(children)].filter((name) => !!name);
@@ -429,81 +406,274 @@ function decodeTemplate(bytes) {
429
406
  return undefined;
430
407
  }
431
408
  }
432
- /** Collects usages of a component member inside its declaring component's template. */
433
- export async function collectTemplateUsages(node, context, rc) {
434
- rc.checkpoint();
435
- const owner = memberOwner(node);
436
- if (!owner || !hasAngularCore(context))
437
- return NOT_APPLICABLE;
438
- const isComponent = componentDecorator(owner.cls) !== undefined;
439
- const derived = hasDerivedComponent(context, owner.cls);
440
- if (!isComponent && !derived)
441
- return NOT_APPLICABLE;
442
- const fail = (status, reason) => ({
443
- coverage: { status, reason, templates: 0 },
444
- usages: [],
445
- });
446
- if (derived)
447
- return fail("unsupported", "inherited_member");
448
- const loaded = await loadCompiler(context.tsConfigFilePath);
449
- if ("reason" in loaded)
450
- return fail("unfinished", loaded.reason);
451
- const located = locateTemplate(owner.cls);
452
- if ("reason" in located)
453
- return fail("unfinished", located.reason);
409
+ /** Reads a located template within the per-template size cap. */
410
+ async function readTemplate(context, located) {
454
411
  let text;
455
- let offset = 0;
456
412
  let bytes;
457
413
  if (located.container === "inline") {
458
- ({ text, offset } = located);
414
+ text = located.text;
459
415
  bytes = Buffer.from(text, "utf8");
460
416
  }
461
417
  else {
462
418
  const unsafe = checkTemplatePath(context.projectRoot, located.absolutePath);
463
419
  if (unsafe)
464
- return fail("unfinished", unsafe);
420
+ return { reason: unsafe };
465
421
  if ((await stat(located.absolutePath)).size > MAX_TEMPLATE_BYTES) {
466
- return fail("unfinished", "size_exceeded");
422
+ return { reason: "size_exceeded" };
467
423
  }
468
424
  bytes = await readFile(located.absolutePath);
469
425
  const decoded = decodeTemplate(bytes);
470
426
  if (decoded === undefined)
471
- return fail("unfinished", "template_parse_error");
427
+ return { reason: "template_parse_error" };
472
428
  text = decoded;
473
429
  }
474
430
  // Shared cap: the external path also checks the size before reading, inline checks here.
475
431
  if (bytes.length > MAX_TEMPLATE_BYTES)
476
- return fail("unfinished", "size_exceeded");
432
+ return { reason: "size_exceeded" };
433
+ return { text, bytes, offset: located.container === "inline" ? located.offset : 0 };
434
+ }
435
+ function toUsages(context, cls, located, read, raw) {
436
+ const fileText = located.container === "inline" ? cls.getSourceFile().getFullText() : read.text;
437
+ const fileHash = createHash("sha256").update(read.bytes).digest("hex");
438
+ return raw.map((usage) => {
439
+ const start = usage.start + read.offset;
440
+ return {
441
+ ...usage,
442
+ absolutePath: located.absolutePath,
443
+ file: path.relative(context.projectRoot, located.absolutePath),
444
+ container: located.container,
445
+ start,
446
+ end: usage.end + read.offset,
447
+ ...lineColumn(fileText, start),
448
+ fileHash,
449
+ };
450
+ });
451
+ }
452
+ /** Selector and public binding names, or undefined when bindings cannot be named from the member. */
453
+ function bindingTarget(cls, node, name) {
454
+ const metadata = componentDecorator(cls)?.getArguments()[0];
455
+ if (!Node.isObjectLiteralExpression(metadata) || metadata.getProperty("hostDirectives")) {
456
+ return undefined;
457
+ }
458
+ const property = metadata.getProperty("selector");
459
+ const selector = literalString(Node.isPropertyAssignment(property) ? property.getInitializer() : undefined);
460
+ const names = isMetadataPublic(cls, name)
461
+ ? // A member both decorated and listed in metadata has ambiguous binding names.
462
+ isSelectorPublic(node)
463
+ ? undefined
464
+ : metadataBindingNames(cls, name)
465
+ : bindingNames(node, name);
466
+ if (!selector || !names)
467
+ return undefined;
468
+ return { selector, inputs: names.inputs, outputs: names.outputs };
469
+ }
470
+ /** Parses a consumer template, or returns undefined when the compiler reports errors. */
471
+ function parseConsumer(compiler, text, absolutePath) {
472
+ const parsed = compiler.parseTemplate(text, absolutePath, { preserveWhitespaces: true });
473
+ return parsed.errors?.length ? undefined : parsed.nodes;
474
+ }
475
+ function selectorInspector(compiler, target) {
476
+ return (text, absolutePath, tick) => {
477
+ if (!mayMatch(compiler, target.selector, text))
478
+ return "skip";
479
+ const nodes = parseConsumer(compiler, text, absolutePath);
480
+ if (!nodes)
481
+ return "template_parse_error";
482
+ const scan = extractConsumerUsages(compiler, nodes, text, target, tick);
483
+ const usages = scan.usages.map((usage) => ({
484
+ ...usage,
485
+ localsInScope: [],
486
+ role: "consumer",
487
+ }));
488
+ return { matched: scan.matched, opaque: scan.opaque, usages };
489
+ };
490
+ }
491
+ function pipeInspector(compiler, name, rc) {
492
+ return (text, absolutePath, tick) => {
493
+ if (!text.includes(name))
494
+ return "skip";
495
+ const nodes = parseConsumer(compiler, text, absolutePath);
496
+ if (!nodes)
497
+ return "template_parse_error";
498
+ try {
499
+ const raw = extractUsages(compiler, nodes, { pipe: name, text }, rc, tick);
500
+ const usages = raw.map((usage) => ({ ...usage, role: "consumer" }));
501
+ return { matched: usages.length > 0, opaque: false, usages };
502
+ }
503
+ catch (error) {
504
+ if (error instanceof SpanDrift)
505
+ return { matched: true, opaque: true, usages: [] };
506
+ throw error;
507
+ }
508
+ };
509
+ }
510
+ /**
511
+ * Scans every component template in the Project with `inspect`. Matching comes first; scope only
512
+ * filters matches. Unreadable templates and unproven scopes are reported.
513
+ */
514
+ async function scanConsumers(context, cls, inspect, rc, limits) {
515
+ const remaining = { ...SCAN_LIMITS, ...limits };
516
+ const usages = [];
517
+ let failure;
518
+ let examined = 0;
519
+ const resolve = createScopeResolver(cls, {
520
+ coreMajor: angularCoreMajor(context),
521
+ budget: { scopeVisits: MAX_SCOPE_VISITS },
522
+ rc,
523
+ });
524
+ const tick = () => {
525
+ if (--remaining.nodes < 0)
526
+ throw new WorkExhausted();
527
+ if (remaining.nodes % CHECKPOINT_EVERY_NODES === 0)
528
+ rc.checkpoint();
529
+ };
530
+ try {
531
+ for (const consumer of componentClasses(context)) {
532
+ rc.checkpoint();
533
+ if (--remaining.templates < 0)
534
+ throw new WorkExhausted();
535
+ const located = locateTemplate(consumer);
536
+ if ("reason" in located) {
537
+ failure ??= located.reason;
538
+ continue;
539
+ }
540
+ const read = await readTemplate(context, located);
541
+ if ("reason" in read) {
542
+ failure ??= read.reason;
543
+ continue;
544
+ }
545
+ remaining.bytes -= read.bytes.length;
546
+ if (remaining.bytes < 0)
547
+ throw new WorkExhausted();
548
+ examined += 1;
549
+ const scan = inspect(read.text, located.absolutePath, tick);
550
+ if (scan === "skip")
551
+ continue;
552
+ if (scan === "template_parse_error") {
553
+ failure ??= scan;
554
+ continue;
555
+ }
556
+ if (scan.opaque)
557
+ failure ??= "cross_component";
558
+ if (!scan.matched)
559
+ continue;
560
+ const verdict = resolve(consumer);
561
+ if (verdict === "opaque")
562
+ failure ??= "cross_component";
563
+ if (verdict === "includes") {
564
+ usages.push(...toUsages(context, consumer, located, read, scan.usages));
565
+ }
566
+ }
567
+ }
568
+ catch (error) {
569
+ if (error instanceof WorkExhausted || error instanceof ScopeWorkExhausted) {
570
+ return { usages: [], failure: "work_exhausted", examined };
571
+ }
572
+ throw error;
573
+ }
574
+ return { usages, failure, examined };
575
+ }
576
+ /** The literal `name` of a `@Pipe({ name })` class, or undefined when it is not a plain literal. */
577
+ function pipeName(cls) {
578
+ const decorator = cls.getDecorators().find((candidate) => isCoreDecorator(candidate, "Pipe"));
579
+ const metadata = decorator?.getArguments()[0];
580
+ if (!Node.isObjectLiteralExpression(metadata))
581
+ return undefined;
582
+ const property = metadata.getProperty("name");
583
+ return literalString(Node.isPropertyAssignment(property) ? property.getInitializer() : undefined);
584
+ }
585
+ /** `| name` usages of a pipe class in the templates of components that have the class in scope. */
586
+ async function collectPipeUsages(cls, context, rc, limits) {
587
+ const unfinished = (reason, templates = 0) => ({
588
+ coverage: { status: "unfinished", reason, templates },
589
+ usages: [],
590
+ });
591
+ const name = pipeName(cls);
592
+ if (name === undefined)
593
+ return unfinished("cross_component");
594
+ const loaded = await loadCompiler(context.tsConfigFilePath);
595
+ if ("reason" in loaded)
596
+ return unfinished(loaded.reason);
597
+ const scan = await scanConsumers(context, cls, pipeInspector(loaded.compiler, name, rc), rc, limits);
598
+ const coverage = scan.failure
599
+ ? { status: "unfinished", reason: scan.failure, templates: scan.examined }
600
+ : { status: "completed", templates: scan.examined };
601
+ return { coverage, usages: scan.usages };
602
+ }
603
+ /** Collects usages of a component member inside its declaring component's template. */
604
+ export async function collectTemplateUsages(node, context, rc, limits = {}) {
477
605
  rc.checkpoint();
478
- const parsed = loaded.compiler.parseTemplate(text, located.absolutePath, {
606
+ if (!hasAngularCore(context))
607
+ return NOT_APPLICABLE;
608
+ const fail = (status, reason) => ({
609
+ coverage: { status, reason, templates: 0 },
610
+ usages: [],
611
+ });
612
+ if (Node.isClassDeclaration(node)) {
613
+ return isPipeClass(node) ? collectPipeUsages(node, context, rc, limits) : NOT_APPLICABLE;
614
+ }
615
+ const owner = memberOwner(node);
616
+ if (!owner)
617
+ return NOT_APPLICABLE;
618
+ if (isPipeClass(owner.cls)) {
619
+ // Only `transform` is the pipe contract: it reports the pipe's usages but a rename stays refused.
620
+ if (owner.name !== "transform" || componentDecorator(owner.cls)) {
621
+ return fail("unfinished", "cross_component");
622
+ }
623
+ return { ...(await collectPipeUsages(owner.cls, context, rc, limits)), renameBlocked: true };
624
+ }
625
+ const isComponent = componentDecorator(owner.cls) !== undefined;
626
+ const selectorPublic = isSelectorPublic(node) || isMetadataPublic(owner.cls, owner.name);
627
+ if (!isComponent && selectorPublic && isDirectiveClass(owner.cls)) {
628
+ return fail("unfinished", "cross_component");
629
+ }
630
+ const derived = hasDerivedComponent(context, owner.cls);
631
+ if (!isComponent && !derived)
632
+ return NOT_APPLICABLE;
633
+ if (derived)
634
+ return fail("unsupported", "inherited_member");
635
+ const loaded = await loadCompiler(context.tsConfigFilePath);
636
+ if ("reason" in loaded)
637
+ return fail("unfinished", loaded.reason);
638
+ const located = locateTemplate(owner.cls);
639
+ if ("reason" in located)
640
+ return fail("unfinished", located.reason);
641
+ const read = await readTemplate(context, located);
642
+ if ("reason" in read)
643
+ return fail("unfinished", read.reason);
644
+ rc.checkpoint();
645
+ const parsed = loaded.compiler.parseTemplate(read.text, located.absolutePath, {
479
646
  preserveWhitespaces: true,
480
647
  });
481
648
  if (parsed.errors?.length)
482
649
  return fail("unfinished", "template_parse_error");
483
650
  let raw;
484
651
  try {
485
- raw = extractUsages(loaded.compiler, parsed.nodes, owner.name, rc);
652
+ raw = extractUsages(loaded.compiler, parsed.nodes, { member: owner.name }, rc);
486
653
  }
487
654
  catch (error) {
488
655
  if (error instanceof WorkExhausted)
489
656
  return fail("unfinished", "work_exhausted");
490
657
  throw error;
491
658
  }
492
- const fileText = located.container === "inline" ? owner.cls.getSourceFile().getFullText() : text;
493
- const fileHash = createHash("sha256").update(bytes).digest("hex");
494
- const usages = raw.map((usage) => {
495
- const start = usage.start + offset;
496
- return {
497
- absolutePath: located.absolutePath,
498
- file: path.relative(context.projectRoot, located.absolutePath),
499
- container: located.container,
500
- start,
501
- end: usage.end + offset,
502
- ...lineColumn(fileText, start),
503
- kind: usage.kind,
504
- localsInScope: usage.localsInScope,
505
- fileHash,
506
- };
659
+ const usages = toUsages(context, owner.cls, located, read, raw);
660
+ if (!selectorPublic)
661
+ return { coverage: { status: "completed", templates: 1 }, usages };
662
+ // Selector-public members are also bound in consuming templates: scan every component template.
663
+ const target = bindingTarget(owner.cls, node, owner.name);
664
+ const unfinished = (reason, templates = 1) => ({
665
+ coverage: { status: "unfinished", reason, templates },
666
+ usages,
507
667
  });
508
- return { coverage: { status: "completed", templates: 1 }, usages };
668
+ if (!target)
669
+ return unfinished("cross_component");
670
+ if (!loaded.selectors)
671
+ return unfinished("compiler_unsupported");
672
+ const scan = await scanConsumers(context, owner.cls, selectorInspector(loaded.compiler, target), rc, limits);
673
+ const coverage = scan.failure
674
+ ? { status: "unfinished", reason: scan.failure, templates: scan.examined }
675
+ : { status: "completed", templates: scan.examined };
676
+ // Names listed in `inputs` / `outputs` metadata are strings that a TypeScript rename cannot follow.
677
+ const renameBlocked = isMetadataPublic(owner.cls, owner.name);
678
+ return { coverage, usages: [...usages, ...scan.usages], renameBlocked };
509
679
  }
@@ -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 = {}) {