ast-mcp-server 0.16.0 → 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.
package/CHANGELOG.md CHANGED
@@ -6,7 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [0.16.0] — not yet published (2026-11-06)
9
+ ## [0.17.0] — not yet published (2026-11-09)
10
+
11
+ ### Added
12
+
13
+ - `ast_rename_symbol` runs an Angular template type check for renames that edit templates and reports the result as `template_diagnostics` (`status`, `reason`, `added`, `removed`, `added_errors`). The check is on by default and best-effort: it runs ngtsc in a one-shot child process that never writes to the workspace, within a time budget, and `unavailable` or `unfinished` statuses mean unknown, so run the project's build or tests. The field is absent for non-Angular projects and renames that edit no template; the `diagnostics` field stays TypeScript-only (#421).
14
+ - New `ast_rename_symbol` input `block_on_template_errors` (default `false`). It blocks the plan only when a `completed` check adds template errors, and `allow_new_errors` waives it (#421).
15
+ - The check needs `@angular/compiler-cli` to resolve from the project (same version as `@angular/compiler`, TypeScript within its peer range); it is not a declared peer, and without it the status is `unavailable` with `compiler_cli_missing`. `template_diagnostics[].file` is relative to the directory of the tsconfig, while `diagnostics[].file` is relative to `project_root` (#421).
16
+ - Spike results and the design decision are in `docs/spikes/0421-template-type-check.md` and `docs/adr/0017-template-type-check-worker.md` (#421).
17
+
18
+ ### Changed
19
+
20
+ - Plans from a template-editing rename persist as plan schema v3 (the hash adds `template_diagnostics` and `block_on_template_errors`), even when the status is `unavailable` or `unfinished`. Plans without a check keep schema v2 and the same hash. After a downgrade or revert, schema v3 plans are rejected on import (#421).
21
+ - The `ast_rename_symbol` and `ast_find_references` tool descriptions no longer say template errors are not diagnosed; `ast_find_references` still reports usages only and points to `template_diagnostics` (#421).
22
+
23
+ ### Fixed
24
+
25
+ - With `@angular/compiler` 18 or 19, template usage scans walked every subtree twice per nesting level, so `ast_find_references` on a `@Pipe` class or a component member reported `unfinished` with `work_exhausted` on deeply nested templates, and `ast_rename_symbol` refused the rename. Each template is now walked once on every supported major (#441).
26
+ - Upgrading the `structural-code-editing` skill over an unmodified install from npm `0.16.0` would have failed with a conflict: its content changed after `0.16.0` (#443) while the skill version stayed `4.7.0`, and the `0.16.0` byte set had no recorded predecessor. The published `0.16.0` bytes are now recorded, and the skill version is `4.8.0` (#447).
27
+
28
+ ## [0.16.0] — published to `next`, verification failed, not promoted (2026-11-07)
10
29
 
11
30
  ### Added
12
31
 
@@ -26,6 +45,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
26
45
  - `@Directive` members declared with `@Input`, `@Output`, `input()`, `output()` or `model()` reported no `template_coverage`, and members listed in the `inputs` / `outputs` metadata arrays of a `@Component` or `@Directive` were treated as plain members, so `ast_rename_symbol` planned their rename without blocking. They now report `unfinished` with reason `cross_component` (directives with `templates: 0`; a non-literal metadata array marks every member of the class), and `ast_rename_symbol` refuses them with `INCOMPLETE_EVIDENCE` (#410).
27
46
  - Upgrading the `structural-code-editing` skill over an unmodified install from npm `0.11.0`–`0.15.1` failed with a conflict: `0.13.0`–`0.15.1` had no recorded predecessor, and the `0.11.0`–`0.12.0` predecessors did not list `references/operations.md`. Every published byte set is now recorded, and the skill version is `4.7.0` (#425).
28
47
 
48
+ ### Release status
49
+
50
+ - npm published these immutable bytes under `next` with provenance and the expected Git commit (`0f5a3f137703fdd673640d00a04087db3b2808af`, integrity `sha512-eyKYMudp0DisVhVJctgwXwZERA2GGsNPP8eU+P/Zh7A9aSf/0mGG9PoGY6qxtBAMW+1FJd+nFLizqo8Go7NLZA==`, publish run `42329276499`). Public-registry verification (run `42331039791`) then failed for the same reason as `0.14.3`–`0.15.1`: the consumer install resolves `minimatch@10.2.6` through `ts-morph@28.0.0`, flagged by `npm audit` for GHSA-g3hv-75wg-m249 (#361). `0.16.0` supersedes `0.15.1` on `next` and was not promoted to `latest`, tagged in Git, or released on GitHub.
51
+
29
52
  ## [0.15.1] — published to `next`, verification failed, not promoted (2026-11-05)
30
53
 
31
54
  ### Changed
package/README.md CHANGED
@@ -97,7 +97,11 @@ Template evidence is explicit about what it proves:
97
97
  - Rename rewrites consumer bindings of a selector-bound member when coverage is `completed`, keeping the binding syntax, values and alias-keyed bindings. It returns `INCOMPLETE_EVIDENCE` when coverage is `unfinished` or `unsupported` (including `hostDirectives`, `@Directive` members and metadata-listed members), and `MUTATION_BLOCKED` for inline templates, a name collision with a template local or another binding of the element, `[(x)]` on a non-`model()` pair, `$any`, or a name in the selector. Inline templates are never rewritten.
98
98
  - Renaming a `@Pipe` class applies TypeScript edits only and keeps the `name` literal and every `| name` usage; renaming `transform` returns `INCOMPLETE_EVIDENCE`.
99
99
  - `ast_explore` stays TypeScript-only for references; template evidence there is tracked in #380. Use `ast_find_references` for template usages.
100
- - Diagnostics stay TypeScript-only: template errors are not detected, so run the project's Angular build or tests after a rename. Template type checking of rename plans is tracked in #421.
100
+ - The `diagnostics` field stays TypeScript-only. When a rename edits a template, the plan also carries `template_diagnostics`: a best-effort Angular template type-check delta (`status`, `reason` when not `completed`, `added`, `removed`, `added_errors`) computed by the project's own `@angular/compiler-cli` in a separate child process that never writes to the workspace. It is absent for non-Angular projects and for renames that edit no template. `status` is `completed`, `unavailable` (`compiler_cli_missing`, `compiler_unsupported`, `typescript_incompatible`) or `unfinished` (`work_exhausted`, `deadline`, `check_failed`); the arrays are empty unless it is `completed`.
101
+ - Treat `unavailable` and `unfinished` as unknown, never as clean: run the project's Angular build or tests before applying. `block_on_template_errors` (default `false`) blocks the plan only when the status is `completed` and `added_errors` is not empty; `allow_new_errors` waives it. Without the input the delta is reported but never blocks.
102
+ - `template_diagnostics[].file` is relative to the directory of the tsconfig, while `diagnostics[].file` is relative to `project_root`; they differ when the tsconfig is not at the project root.
103
+ - `@angular/compiler-cli` is not a declared peer. It must resolve from the project, with the same version as `@angular/compiler` and a TypeScript inside its supported range; otherwise the status is `unavailable` with `compiler_cli_missing`.
104
+ - Any template-editing rename emits `template_diagnostics` and persists as plan schema v3, even when the status is `unavailable` or `unfinished`. Plans without the field stay schema v2. After a downgrade or revert, schema v3 plans are rejected on import. See [ADR 0017](docs/adr/0017-template-type-check-worker.md) and the [spike](docs/spikes/0421-template-type-check.md).
101
105
  - Yarn PnP projects without a `node_modules` directory cannot resolve the compiler and report `unfinished`; use `nodeLinker: node-modules` for template coverage.
102
106
 
103
107
  ## Why this helps
@@ -374,7 +378,7 @@ For a local candidate, pack and install its tarball instead:
374
378
 
375
379
  ```bash
376
380
  yarn pack --out ast-mcp-server-%v.tgz
377
- dsh plugin --profile web add ./ast-mcp-server-0.16.0.tgz
381
+ dsh plugin --profile web add ./ast-mcp-server-0.17.0.tgz
378
382
  ```
379
383
 
380
384
  The first supported surface is **reads + prepare + preview**. Every apply path is
package/cordis.patch.yml CHANGED
@@ -3,7 +3,7 @@
3
3
  # Mounts the packaged `ast-mcp-server` stdio command through the official
4
4
  # `@deepseek-ai/dsh-mcp-client` bridge. Harness does not resolve MCP binaries
5
5
  # from PATH, so the entrypoint is resolved relative to the profile directory
6
- # (`baseUrl`), where `dsh plugin --profile <name> add ./ast-mcp-server-0.16.0.tgz`
6
+ # (`baseUrl`), where `dsh plugin --profile <name> add ./ast-mcp-server-0.17.0.tgz`
7
7
  # installs a locally packed package as a dependency.
8
8
  #
9
9
  # First-slice surface: reads + prepare + preview. Every apply path is denied
@@ -0,0 +1,19 @@
1
+ import fs from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { runTemplateTypeCheck } from "./services/angular-template-typecheck-core.js";
4
+ import { RequestSchema } from "./services/angular-template-typecheck-protocol.js";
5
+ /** Validates one IPC request and runs the core; it never throws. */
6
+ export async function handleRequest(raw, run = runTemplateTypeCheck) {
7
+ const parsed = RequestSchema.safeParse(raw);
8
+ return parsed.success ? run(parsed.data) : { v: 1, ok: false, reason: "check_failed" };
9
+ }
10
+ // One-shot child: serve a single request, reply, exit. A lost parent ends the process too.
11
+ const entry = process.argv[1];
12
+ if (process.send &&
13
+ entry &&
14
+ fs.realpathSync(entry) === fs.realpathSync(fileURLToPath(import.meta.url))) {
15
+ process.once("disconnect", () => process.exit(1));
16
+ process.once("message", (raw) => {
17
+ void handleRequest(raw).then((reply) => process.send(reply, () => process.exit(0)));
18
+ });
19
+ }
@@ -0,0 +1,62 @@
1
+ import { existsSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ export const MIN_COMPILER_MAJOR = 18;
5
+ export const MAX_COMPILER_MAJOR = 22;
6
+ /** Resolves `request` the way the project's own tooling would, or `undefined` when absent. */
7
+ export function resolveFrom(tsConfigFilePath, request) {
8
+ try {
9
+ const resolved = createRequire(tsConfigFilePath).resolve(request);
10
+ return existsSync(resolved) ? resolved : undefined;
11
+ }
12
+ catch {
13
+ return undefined;
14
+ }
15
+ }
16
+ export function isWithin(parent, child) {
17
+ const relative = path.relative(parent, child);
18
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
19
+ }
20
+ /** True when `packageDir` sits in a node_modules directory owned by `projectDir` or an ancestor. */
21
+ export function isUnderAncestorNodeModules(packageDir, projectDir) {
22
+ const segments = packageDir.split(path.sep);
23
+ return segments.some((segment, index) => segment === "node_modules" &&
24
+ isWithin(segments.slice(0, index).join(path.sep) || path.sep, projectDir));
25
+ }
26
+ export function isSupportedVersion(version) {
27
+ const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+.*)?$/u.exec(String(version));
28
+ if (!match)
29
+ return false;
30
+ const [major, minor, patch] = [match[1], match[2], match[3]].map(Number);
31
+ const belowFloor = major === MIN_COMPILER_MAJOR && minor === 0 && patch === 0 && !!match[4];
32
+ return major >= MIN_COMPILER_MAJOR && major <= MAX_COMPILER_MAJOR && !belowFloor;
33
+ }
34
+ const MAX_COMPARATORS = 4;
35
+ const COMPARATOR = /^(>=|<)(\d+)\.(\d+)(?:\.(\d+))?$/u;
36
+ const PLAIN_VERSION = /^(\d+)\.(\d+)\.(\d+)$/u;
37
+ function compareTriples(left, right) {
38
+ return left[0] - right[0] || left[1] - right[1] || left[2] - right[2];
39
+ }
40
+ /**
41
+ * Evaluates a plain `x.y.z` version against a space-joined list of `>=x.y[.z]` and `<x.y[.z]`
42
+ * comparators, the only grammar the Angular peer ranges need. Anything else (`||`, `^`, `~`,
43
+ * wildcards, prereleases, other operators) is `unsupported` so callers fail closed.
44
+ */
45
+ export function rangeVerdict(version, range) {
46
+ const plain = PLAIN_VERSION.exec(version);
47
+ const tokens = range.trim().split(/\s+/u);
48
+ if (!plain || tokens.length === 0 || tokens.length > MAX_COMPARATORS)
49
+ return "unsupported";
50
+ const actual = [Number(plain[1]), Number(plain[2]), Number(plain[3])];
51
+ let satisfied = true;
52
+ for (const token of tokens) {
53
+ const match = COMPARATOR.exec(token);
54
+ if (!match)
55
+ return "unsupported";
56
+ const bound = [Number(match[2]), Number(match[3]), Number(match[4] ?? 0)];
57
+ const order = compareTriples(actual, bound);
58
+ if (match[1] === ">=" ? order < 0 : order >= 0)
59
+ satisfied = false;
60
+ }
61
+ return satisfied ? "satisfied" : "unsatisfied";
62
+ }
@@ -0,0 +1,139 @@
1
+ import { createRequire } from "node:module";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { MAX_MESSAGE_CHARS, } from "./angular-template-typecheck-protocol.js";
5
+ /** Loads the TypeScript that ngtsc itself resolves, then the pre-validated compiler-cli entry. */
6
+ export async function loadToolchain(cliEntry) {
7
+ const tsEntry = createRequire(cliEntry).resolve("typescript");
8
+ const ts = (await import(pathToFileURL(tsEntry).href)).default;
9
+ const cli = (await import(pathToFileURL(cliEntry).href));
10
+ return { ts, cli };
11
+ }
12
+ /** True when the public surface this driver needs is present (never touches `private/*`). */
13
+ export function probeCompilerApi(cli) {
14
+ return (typeof cli.NgtscProgram === "function" &&
15
+ typeof cli.readConfiguration === "function" &&
16
+ typeof cli.createCompilerHost === "function" &&
17
+ typeof cli.NgtscProgram.prototype?.getNgSemanticDiagnostics === "function");
18
+ }
19
+ /** Forces the options that make an emit or a build-info write impossible. */
20
+ export function withNoEmit(options) {
21
+ return {
22
+ ...options,
23
+ noEmit: true,
24
+ incremental: false,
25
+ composite: false,
26
+ tsBuildInfoFile: undefined,
27
+ };
28
+ }
29
+ /** Scope B: an edited template or a scoped component file, never anything under node_modules. */
30
+ export function isInScope(fileName, scopeFiles) {
31
+ return scopeFiles.has(fileName) && !fileName.split(/[\\/]/u).includes("node_modules");
32
+ }
33
+ export function toWireDiagnostic(ts, root, diagnostic) {
34
+ const { file } = diagnostic;
35
+ const start = diagnostic.start ?? null;
36
+ const position = file && start !== null ? file.getLineAndCharacterOfPosition(start) : null;
37
+ return {
38
+ code: diagnostic.code,
39
+ category: ts.DiagnosticCategory[diagnostic.category] ?? String(diagnostic.category),
40
+ file: file ? path.relative(root, file.fileName) : "",
41
+ line: position ? position.line + 1 : null,
42
+ column: position ? position.character + 1 : null,
43
+ message: ts
44
+ .flattenDiagnosticMessageText(diagnostic.messageText, "\n")
45
+ .slice(0, MAX_MESSAGE_CHARS),
46
+ start,
47
+ length: diagnostic.length ?? null,
48
+ };
49
+ }
50
+ /**
51
+ * Host over the disk with an optional in-memory overlay. Unchanged files come from one cache
52
+ * shared by both runs, so the after run reuses the identical SourceFile objects and only parses
53
+ * overlay files again.
54
+ */
55
+ function createCheckHost({ ts, cli }, options, overlay, shared) {
56
+ const tsHost = ts.createCompilerHost(options, true);
57
+ const { readFile, fileExists, getSourceFile } = tsHost;
58
+ const fresh = new Map();
59
+ tsHost.writeFile = () => {
60
+ throw new Error("The template type check must not write files.");
61
+ };
62
+ tsHost.readFile = (name) => overlay?.get(name) ?? readFile.call(tsHost, name);
63
+ tsHost.fileExists = (name) => overlay?.has(name) === true || fileExists.call(tsHost, name);
64
+ tsHost.getSourceFile = (name, languageVersion, ...rest) => {
65
+ const text = overlay?.get(name);
66
+ if (text !== undefined) {
67
+ if (!fresh.has(name))
68
+ fresh.set(name, ts.createSourceFile(name, text, languageVersion, true));
69
+ return fresh.get(name);
70
+ }
71
+ if (!shared.has(name)) {
72
+ shared.set(name, getSourceFile.call(tsHost, name, languageVersion, ...rest));
73
+ }
74
+ return shared.get(name);
75
+ };
76
+ const ngHost = cli.createCompilerHost({ options, tsHost });
77
+ if (overlay) {
78
+ // With `oldProgram` reuse ngtsc re-analyzes a component only if it is told which resources changed.
79
+ const resources = new Set([...overlay.keys()].filter((name) => !/\.[cm]?tsx?$/u.test(name)));
80
+ ngHost.getModifiedResourceFiles = () => resources;
81
+ }
82
+ return ngHost;
83
+ }
84
+ async function checkSide(toolchain, config, request, overlay, shared, oldProgram) {
85
+ const host = createCheckHost(toolchain, config.options, overlay, shared);
86
+ const program = new toolchain.cli.NgtscProgram(config.rootNames, config.options, host, oldProgram);
87
+ await program.loadNgStructureAsync();
88
+ const compiler = program.compiler;
89
+ if (typeof compiler?.getComponentsWithTemplateFile !== "function")
90
+ return "unsupported";
91
+ const scope = new Set(request.templates);
92
+ for (const template of request.templates) {
93
+ for (const component of compiler.getComponentsWithTemplateFile(template)) {
94
+ scope.add(component.getSourceFile().fileName);
95
+ }
96
+ }
97
+ const root = path.dirname(request.tsconfig);
98
+ const diagnostics = program
99
+ .getNgSemanticDiagnostics()
100
+ .filter((diagnostic) => diagnostic.file && isInScope(diagnostic.file.fileName, scope))
101
+ .map((diagnostic) => toWireDiagnostic(toolchain.ts, root, diagnostic));
102
+ return { program, diagnostics };
103
+ }
104
+ const fail = (reason) => ({
105
+ v: 1,
106
+ ok: false,
107
+ reason,
108
+ });
109
+ /** Runs ngtsc on the disk state, then on disk plus overlay, and returns both scoped lists. */
110
+ export async function runTemplateTypeCheck(request, load = loadToolchain) {
111
+ try {
112
+ const toolchain = await load(request.cliEntry);
113
+ if (!probeCompilerApi(toolchain.cli))
114
+ return fail("compiler_unsupported");
115
+ const read = toolchain.cli.readConfiguration(request.tsconfig);
116
+ if (read.errors.length > 0)
117
+ return fail("check_failed");
118
+ const config = { options: withNoEmit(read.options), rootNames: read.rootNames };
119
+ const shared = new Map();
120
+ const overlay = new Map(request.overlay.map((file) => [file.absolutePath, file.text]));
121
+ const before = await checkSide(toolchain, config, request, undefined, shared, undefined);
122
+ if (before === "unsupported")
123
+ return fail("compiler_unsupported");
124
+ if (before.diagnostics.length > request.maxDiagnostics)
125
+ return fail("work_exhausted");
126
+ const after = await checkSide(toolchain, config, request, overlay, shared, before.program);
127
+ if (after === "unsupported")
128
+ return fail("compiler_unsupported");
129
+ if (after.diagnostics.length > request.maxDiagnostics)
130
+ return fail("work_exhausted");
131
+ return { v: 1, ok: true, before: before.diagnostics, after: after.diagnostics };
132
+ }
133
+ catch (error) {
134
+ const message = error instanceof Error ? error.message : "";
135
+ return fail(message.startsWith("The Angular Compiler requires TypeScript")
136
+ ? "typescript_incompatible"
137
+ : "check_failed");
138
+ }
139
+ }
@@ -0,0 +1,75 @@
1
+ import path from "node:path";
2
+ import { z } from "zod";
3
+ /** Caps shared by both ends of the one-shot IPC channel (v1). */
4
+ export const MAX_WIRE_DIAGNOSTICS = 5000;
5
+ export const MAX_OVERLAY_BYTES = 32 * 1024 * 1024;
6
+ export const MAX_TEMPLATES = 2000;
7
+ export const MAX_OVERLAY_FILES = 10_000;
8
+ const MAX_PATH_CHARS = 4096;
9
+ export const MAX_MESSAGE_CHARS = 8192;
10
+ const absolutePath = z
11
+ .string()
12
+ .max(MAX_PATH_CHARS)
13
+ .refine((value) => path.isAbsolute(value), "must be an absolute path");
14
+ export const RequestSchema = z
15
+ .object({
16
+ v: z.literal(1),
17
+ tsconfig: absolutePath,
18
+ cliEntry: absolutePath,
19
+ templates: z.array(absolutePath).max(MAX_TEMPLATES),
20
+ overlay: z
21
+ .array(z.object({ absolutePath, text: z.string() }).strict())
22
+ .max(MAX_OVERLAY_FILES)
23
+ .refine((files) => files.reduce((total, file) => total + Buffer.byteLength(file.text, "utf8"), 0) <=
24
+ MAX_OVERLAY_BYTES, "overlay exceeds the byte cap"),
25
+ maxDiagnostics: z.number().int().min(1).max(MAX_WIRE_DIAGNOSTICS),
26
+ })
27
+ .strict();
28
+ const position = z.number().int().nonnegative().nullable();
29
+ export const WireDiagnosticSchema = z
30
+ .object({
31
+ code: z.number().int(),
32
+ category: z.string().max(32),
33
+ /** Project-relative path. */
34
+ file: z.string().max(MAX_PATH_CHARS),
35
+ line: position,
36
+ column: position,
37
+ message: z.string().max(MAX_MESSAGE_CHARS),
38
+ start: position,
39
+ length: position,
40
+ })
41
+ .strict();
42
+ /** Only a successful reply carries diagnostics, so a non-completed status can never include any. */
43
+ export const ResponseSchema = z.discriminatedUnion("ok", [
44
+ z
45
+ .object({
46
+ v: z.literal(1),
47
+ ok: z.literal(true),
48
+ before: z.array(WireDiagnosticSchema).max(MAX_WIRE_DIAGNOSTICS),
49
+ after: z.array(WireDiagnosticSchema).max(MAX_WIRE_DIAGNOSTICS),
50
+ })
51
+ .strict(),
52
+ z
53
+ .object({
54
+ v: z.literal(1),
55
+ ok: z.literal(false),
56
+ reason: z.enum([
57
+ "compiler_unsupported",
58
+ "typescript_incompatible",
59
+ "work_exhausted",
60
+ "check_failed",
61
+ ]),
62
+ })
63
+ .strict(),
64
+ ]);
65
+ /** Separates an over-cap reply (work bound) from a malformed one (check failure). */
66
+ export function parseResponse(value) {
67
+ if (typeof value === "object" && value !== null) {
68
+ const { before, after } = value;
69
+ const oversizedSide = (side) => Array.isArray(side) && side.length > MAX_WIRE_DIAGNOSTICS;
70
+ if (oversizedSide(before) || oversizedSide(after))
71
+ return { status: "oversized" };
72
+ }
73
+ const parsed = ResponseSchema.safeParse(value);
74
+ return parsed.success ? { status: "valid", response: parsed.data } : { status: "malformed" };
75
+ }
@@ -0,0 +1,262 @@
1
+ import { fork } from "node:child_process";
2
+ import { readFileSync, realpathSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { isSupportedVersion, isUnderAncestorNodeModules, isWithin, rangeVerdict, resolveFrom, } from "./angular-package-resolution.js";
7
+ import { MAX_OVERLAY_BYTES, MAX_OVERLAY_FILES, MAX_TEMPLATES, MAX_WIRE_DIAGNOSTICS, parseResponse, } from "./angular-template-typecheck-protocol.js";
8
+ import { compareObservedDiagnostics, } from "./diagnostics.js";
9
+ import { NO_REQUEST_CONTEXT, RequestContextError } from "./request-context.js";
10
+ const UNSUPPORTED = { ok: false, reason: "compiler_unsupported" };
11
+ /** Locates an installed package through the shared guard, or reports why it cannot be trusted. */
12
+ function locatePackage(tsConfigFilePath, name, projectDir) {
13
+ const manifestPath = resolveFrom(tsConfigFilePath, `${name}/package.json`);
14
+ if (!manifestPath) {
15
+ try {
16
+ createRequire(tsConfigFilePath).resolve(`${name}/package.json`);
17
+ }
18
+ catch (error) {
19
+ if (error.code === "MODULE_NOT_FOUND")
20
+ return "missing";
21
+ }
22
+ return "unsupported";
23
+ }
24
+ try {
25
+ const dir = realpathSync(path.dirname(manifestPath));
26
+ if (!isUnderAncestorNodeModules(dir, projectDir))
27
+ return "unsupported";
28
+ const manifest = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"));
29
+ return manifest?.name === name ? { dir, manifest } : "unsupported";
30
+ }
31
+ catch {
32
+ return "unsupported";
33
+ }
34
+ }
35
+ function majorOf(version) {
36
+ return Number(String(version).split(".")[0]);
37
+ }
38
+ /** In-process checks before any spawn; TypeScript is resolved from the compiler-cli, as ngtsc does. */
39
+ export function preflightTemplateTypeCheck(tsConfigFilePath) {
40
+ let projectDir;
41
+ try {
42
+ projectDir = realpathSync(path.dirname(tsConfigFilePath));
43
+ }
44
+ catch {
45
+ return UNSUPPORTED;
46
+ }
47
+ const cli = locatePackage(tsConfigFilePath, "@angular/compiler-cli", projectDir);
48
+ if (cli === "missing")
49
+ return { ok: false, reason: "compiler_cli_missing" };
50
+ if (cli === "unsupported")
51
+ return UNSUPPORTED;
52
+ const compiler = locatePackage(tsConfigFilePath, "@angular/compiler", projectDir);
53
+ const core = locatePackage(tsConfigFilePath, "@angular/core", projectDir);
54
+ if (typeof compiler === "string" || typeof core === "string")
55
+ return UNSUPPORTED;
56
+ const version = cli.manifest.version;
57
+ if (!isSupportedVersion(version) || compiler.manifest.version !== version)
58
+ return UNSUPPORTED;
59
+ if (!isSupportedVersion(core.manifest.version) ||
60
+ majorOf(core.manifest.version) !== majorOf(version)) {
61
+ return UNSUPPORTED;
62
+ }
63
+ const entry = resolveFrom(tsConfigFilePath, "@angular/compiler-cli");
64
+ let cliEntry;
65
+ try {
66
+ cliEntry = entry && realpathSync(entry);
67
+ }
68
+ catch {
69
+ return UNSUPPORTED;
70
+ }
71
+ if (!cliEntry || !isWithin(cli.dir, cliEntry))
72
+ return UNSUPPORTED;
73
+ const peer = cli.manifest.peerDependencies?.typescript;
74
+ const typescript = resolveFrom(path.join(cli.dir, "package.json"), "typescript/package.json");
75
+ if (typeof peer !== "string")
76
+ return UNSUPPORTED;
77
+ if (!typescript)
78
+ return { ok: false, reason: "typescript_incompatible" };
79
+ let typescriptVersion;
80
+ try {
81
+ typescriptVersion = JSON.parse(readFileSync(typescript, "utf8")).version;
82
+ }
83
+ catch {
84
+ return UNSUPPORTED;
85
+ }
86
+ const verdict = rangeVerdict(String(typescriptVersion), peer);
87
+ if (verdict === "unsupported")
88
+ return UNSUPPORTED;
89
+ if (verdict === "unsatisfied")
90
+ return { ok: false, reason: "typescript_incompatible" };
91
+ return { ok: true, cliDir: cli.dir, cliEntry };
92
+ }
93
+ function emptyDelta(status, reason) {
94
+ return { status, reason, added: [], removed: [], addedErrors: [] };
95
+ }
96
+ const MAX_CHECK_MS = 30_000;
97
+ const MIN_CHECK_MS = 2_000;
98
+ const RESERVE_MS = 10_000;
99
+ const CHILD_HEAP_MB = 1024;
100
+ const EXIT_SETTLE_MS = 500;
101
+ const OOM_EXIT_CODE = 134;
102
+ const DEFAULT_ENTRY = fileURLToPath(new URL("../angular-template-typecheck-entry.js", import.meta.url));
103
+ const UNBOUNDED_CONTEXT = {
104
+ signal: NO_REQUEST_CONTEXT.signal,
105
+ checkpoint: () => NO_REQUEST_CONTEXT.checkpoint(),
106
+ remainingMs: () => MAX_CHECK_MS + RESERVE_MS,
107
+ };
108
+ /** Time the child may run: capped, and leaving a reserve for the rest of the operation. */
109
+ export function checkBudgetMs(remainingMs) {
110
+ return Math.min(MAX_CHECK_MS, remainingMs - RESERVE_MS);
111
+ }
112
+ function outcomeOf(code, signal, reply) {
113
+ if (signal === "SIGABRT" || code === OOM_EXIT_CODE) {
114
+ return { kind: "reason", reason: "work_exhausted" };
115
+ }
116
+ if (code !== 0)
117
+ return { kind: "reason", reason: "check_failed" };
118
+ const parsed = parseResponse(reply);
119
+ if (parsed.status === "valid")
120
+ return { kind: "reply", response: parsed.response };
121
+ return {
122
+ kind: "reason",
123
+ reason: parsed.status === "oversized" ? "work_exhausted" : "check_failed",
124
+ };
125
+ }
126
+ /** Runs one child to completion; it is always reaped before this settles. */
127
+ function runChild(request, budgetMs, ctx, deps) {
128
+ return new Promise((resolve, reject) => {
129
+ let child;
130
+ try {
131
+ child = (deps.spawn ?? fork)(deps.entry ?? DEFAULT_ENTRY, [], {
132
+ execPath: process.execPath,
133
+ execArgv: [`--max-old-space-size=${CHILD_HEAP_MB}`],
134
+ env: {},
135
+ serialization: "advanced",
136
+ stdio: ["ignore", "ignore", "ignore", "ipc"],
137
+ });
138
+ }
139
+ catch {
140
+ return resolve({ kind: "reason", reason: "check_failed" });
141
+ }
142
+ let reply;
143
+ let killedFor;
144
+ let failed = false;
145
+ let settled = false;
146
+ const kill = (why) => {
147
+ killedFor ??= why;
148
+ child.kill("SIGKILL");
149
+ };
150
+ const settle = (result) => {
151
+ if (settled)
152
+ return;
153
+ settled = true;
154
+ clearTimeout(timer);
155
+ ctx.signal.removeEventListener("abort", onAbort);
156
+ if (result instanceof Error)
157
+ reject(result);
158
+ else
159
+ resolve(result);
160
+ };
161
+ const onAbort = () => kill("abort");
162
+ const timer = setTimeout(() => kill("deadline"), budgetMs);
163
+ ctx.signal.addEventListener("abort", onAbort, { once: true });
164
+ child.once("message", (message) => {
165
+ reply = message;
166
+ });
167
+ child.once("error", () => {
168
+ failed = true;
169
+ // No pid means the spawn itself failed and no exit will follow; otherwise reap first.
170
+ if (child.pid === undefined)
171
+ settle({ kind: "reason", reason: "check_failed" });
172
+ else
173
+ child.kill("SIGKILL");
174
+ });
175
+ child.once("exit", (code, signal) => {
176
+ const finish = () => {
177
+ if (killedFor === "abort")
178
+ settle(abortError(ctx));
179
+ else if (killedFor === "deadline")
180
+ settle({ kind: "deadline" });
181
+ else if (failed)
182
+ settle({ kind: "reason", reason: "check_failed" });
183
+ else
184
+ settle(outcomeOf(code, signal, reply));
185
+ };
186
+ // Pending IPC messages are delivered before the channel reports closed.
187
+ if (!child.connected)
188
+ return finish();
189
+ child.once("disconnect", finish);
190
+ setTimeout(finish, EXIT_SETTLE_MS).unref();
191
+ });
192
+ if (ctx.signal.aborted)
193
+ return onAbort();
194
+ child.send(request, () => undefined);
195
+ });
196
+ }
197
+ function abortError(ctx) {
198
+ try {
199
+ ctx.checkpoint();
200
+ }
201
+ catch (error) {
202
+ return error;
203
+ }
204
+ return new RequestContextError("REQUEST_CANCELLED");
205
+ }
206
+ function observations(diagnostics) {
207
+ return diagnostics.map(({ start, length, ...shown }, ordinal) => ({
208
+ public: {
209
+ code: shown.code,
210
+ category: shown.category,
211
+ file: shown.file,
212
+ line: shown.line,
213
+ column: shown.column,
214
+ message: shown.message,
215
+ },
216
+ start,
217
+ length,
218
+ ordinal,
219
+ }));
220
+ }
221
+ function diagnosticsOf(response, changes, ctx) {
222
+ if (!response.ok) {
223
+ const reason = response.reason;
224
+ return emptyDelta(reason === "compiler_unsupported" || reason === "typescript_incompatible"
225
+ ? "unavailable"
226
+ : "unfinished", reason);
227
+ }
228
+ const delta = compareObservedDiagnostics(observations(response.before), observations(response.after), changes, ctx);
229
+ return { status: "completed", ...delta };
230
+ }
231
+ /**
232
+ * Entry point of the template type check: preflight and limits in-process, then one forked child
233
+ * under a wall-clock budget. A client abort kills the child and rethrows `REQUEST_CANCELLED`.
234
+ */
235
+ export async function checkTemplateTypes(input, ctx = UNBOUNDED_CONTEXT, deps = {}) {
236
+ ctx.checkpoint();
237
+ const preflight = preflightTemplateTypeCheck(input.tsConfigFilePath);
238
+ if (!preflight.ok)
239
+ return emptyDelta("unavailable", preflight.reason);
240
+ const overlayBytes = input.overlay.reduce((total, file) => total + Buffer.byteLength(file.text, "utf8"), 0);
241
+ if (input.templates.length > MAX_TEMPLATES ||
242
+ input.overlay.length > MAX_OVERLAY_FILES ||
243
+ overlayBytes > MAX_OVERLAY_BYTES) {
244
+ return emptyDelta("unfinished", "work_exhausted");
245
+ }
246
+ const budgetMs = checkBudgetMs(ctx.remainingMs());
247
+ if (budgetMs < MIN_CHECK_MS)
248
+ return emptyDelta("unfinished", "deadline");
249
+ const outcome = await runChild({
250
+ v: 1,
251
+ tsconfig: input.tsConfigFilePath,
252
+ cliEntry: preflight.cliEntry,
253
+ templates: [...input.templates],
254
+ overlay: input.overlay.map(({ absolutePath, text }) => ({ absolutePath, text })),
255
+ maxDiagnostics: MAX_WIRE_DIAGNOSTICS,
256
+ }, budgetMs, ctx, deps);
257
+ if (outcome.kind === "deadline")
258
+ return emptyDelta("unfinished", "deadline");
259
+ if (outcome.kind === "reason")
260
+ return emptyDelta("unfinished", outcome.reason);
261
+ return diagnosticsOf(outcome.response, input.changes, ctx);
262
+ }
@@ -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");
@@ -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);
@@ -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 = {}) {
@@ -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") {
@@ -69,7 +69,7 @@ const ReferenceOutputSchemas = {
69
69
  export function registerFindReferences(server) {
70
70
  server.registerTool(TOOL_NAME, {
71
71
  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.",
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. It reports usages only; template type errors are reported by ast_rename_symbol plans in template_diagnostics.",
73
73
  inputSchema: AstFindReferencesInputSchema,
74
74
  annotations: {
75
75
  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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ast-mcp-server",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",
@@ -135,5 +140,5 @@
135
140
  "vitest": "^5.0.5",
136
141
  "yaml": "^2.9.0"
137
142
  },
138
- "gitHead": "0f5a3f137703fdd673640d00a04087db3b2808af"
143
+ "gitHead": "e49987bd1fd36432aa76ecb27ca8359eedb613ad"
139
144
  }
@@ -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.8.0"
8
8
  hermes:
9
9
  tags: ["typescript", "javascript", "ast", "refactoring", "mcp"]
10
10
  homepage: "https://github.com/yailPeralta/ast-mcp-server"
@@ -60,7 +60,7 @@ Pipes: renaming a `@Pipe` class applies with TypeScript edits only; the `name` l
60
60
  Limits to state when reporting:
61
61
 
62
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.
63
+ - 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
64
  - 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
65
 
66
66
  ## Contributor contract
@@ -22,7 +22,9 @@ 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. `ast_explore` is TypeScript-only for references (tracked in issue #380).
26
+
27
+ 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
28
 
27
29
  `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
30
 
@@ -2,15 +2,15 @@
2
2
  "schema_version": 2,
3
3
  "algorithm": "sha256",
4
4
  "current": {
5
- "version": "4.7.0",
5
+ "version": "4.8.0",
6
6
  "files": [
7
7
  {
8
8
  "path": "SKILL.md",
9
- "sha256": "2606e5fc607a3063789c7c02c49aaf8b3129824fa5527b8654e50e95ed2a8556"
9
+ "sha256": "15e5d25553a3ebce86365ff7b43ed61e7df11dd6ad658e83ec6e43cd84118762"
10
10
  },
11
11
  {
12
12
  "path": "references/operations.md",
13
- "sha256": "c744553832f55d0630d5ff64d3b5f201b8e87c323d6b7bfeb84eb1778e775b7d"
13
+ "sha256": "dd633a24e9aabeb1d9e9d1f93e261906e7b30eb721fadfb3d317d6acc60db2ca"
14
14
  }
15
15
  ]
16
16
  },
@@ -191,6 +191,20 @@
191
191
  }
192
192
  ],
193
193
  "npm_versions": ["0.15.1"]
194
+ },
195
+ {
196
+ "version": "4.7.0",
197
+ "files": [
198
+ {
199
+ "path": "SKILL.md",
200
+ "sha256": "2606e5fc607a3063789c7c02c49aaf8b3129824fa5527b8654e50e95ed2a8556"
201
+ },
202
+ {
203
+ "path": "references/operations.md",
204
+ "sha256": "c744553832f55d0630d5ff64d3b5f201b8e87c323d6b7bfeb84eb1778e775b7d"
205
+ }
206
+ ],
207
+ "npm_versions": ["0.16.0"]
194
208
  }
195
209
  ]
196
210
  }