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.
- package/CHANGELOG.md +48 -1
- package/README.md +11 -4
- package/cordis.patch.yml +1 -1
- package/dist/angular-template-typecheck-entry.js +19 -0
- package/dist/services/angular-decorators.js +218 -0
- package/dist/services/angular-package-resolution.js +62 -0
- package/dist/services/angular-template-bindings.js +111 -0
- package/dist/services/angular-template-rename.js +119 -0
- package/dist/services/angular-template-scope.js +163 -0
- package/dist/services/angular-template-typecheck-core.js +139 -0
- package/dist/services/angular-template-typecheck-protocol.js +75 -0
- package/dist/services/angular-template-typecheck.js +262 -0
- package/dist/services/angular-templates.js +273 -103
- package/dist/services/h03-timeout-fixture.js +2 -1
- package/dist/services/operation-block-policy.js +19 -0
- package/dist/services/operation-plan-file.js +38 -4
- package/dist/services/operations.js +109 -22
- package/dist/services/project-operation-scheduler.js +4 -0
- package/dist/tools/find_references.js +2 -1
- package/dist/tools/operation-schema.js +43 -0
- package/dist/tools/rename_symbol.js +9 -4
- package/package.json +9 -4
- package/skills/structural-code-editing/SKILL.md +11 -5
- package/skills/structural-code-editing/references/operations.md +4 -2
- package/skills/structural-code-editing/releases.json +113 -7
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { Node, SyntaxKind } from "ts-morph";
|
|
2
|
+
import { componentDecorator, isAngularCoreExport, isCoreDecorator } from "./angular-decorators.js";
|
|
3
|
+
export const MAX_SCOPE_VISITS = 10_000;
|
|
4
|
+
export const MAX_SCOPE_DEPTH = 32;
|
|
5
|
+
export class ScopeWorkExhausted extends Error {
|
|
6
|
+
}
|
|
7
|
+
/** Follows aliases to the declarations a node refers to. */
|
|
8
|
+
function declarationsOf(node) {
|
|
9
|
+
const symbol = node.getSymbol();
|
|
10
|
+
const resolved = symbol?.isAlias() ? (symbol.getAliasedSymbol() ?? symbol) : symbol;
|
|
11
|
+
return resolved?.getDeclarations() ?? [];
|
|
12
|
+
}
|
|
13
|
+
const isExternal = (declaration) => declaration.getSourceFile().isFromExternalLibrary();
|
|
14
|
+
function ngModuleMetadata(cls) {
|
|
15
|
+
const decorator = cls.getDecorators().find((candidate) => isCoreDecorator(candidate, "NgModule"));
|
|
16
|
+
if (!decorator)
|
|
17
|
+
return undefined;
|
|
18
|
+
const metadata = decorator.getArguments()[0];
|
|
19
|
+
return Node.isObjectLiteralExpression(metadata) ? metadata : "opaque";
|
|
20
|
+
}
|
|
21
|
+
/** Elements of a metadata array: none when the key is absent, `opaque` when not an array literal. */
|
|
22
|
+
function listOf(metadata, key) {
|
|
23
|
+
const property = metadata.getProperty(key);
|
|
24
|
+
if (!property)
|
|
25
|
+
return [];
|
|
26
|
+
const list = Node.isPropertyAssignment(property) ? property.getInitializer() : undefined;
|
|
27
|
+
return Node.isArrayLiteralExpression(list) ? list.getElements() : "opaque";
|
|
28
|
+
}
|
|
29
|
+
function merge(results) {
|
|
30
|
+
if (results.includes("target"))
|
|
31
|
+
return "target";
|
|
32
|
+
return results.includes("opaque") ? "opaque" : "other";
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolves, with ts-morph only, whether the target is in a consumer's scope. Memoized per
|
|
36
|
+
* request. Anything that cannot be proven is `opaque`; the caller decides what that costs.
|
|
37
|
+
*/
|
|
38
|
+
export function createScopeResolver(target, options) {
|
|
39
|
+
const memo = new Map();
|
|
40
|
+
const tick = () => {
|
|
41
|
+
if (--options.budget.scopeVisits < 0)
|
|
42
|
+
throw new ScopeWorkExhausted();
|
|
43
|
+
options.rc.checkpoint();
|
|
44
|
+
};
|
|
45
|
+
const referenceOf = (expression) => {
|
|
46
|
+
tick();
|
|
47
|
+
if (Node.isCallExpression(expression)) {
|
|
48
|
+
const callee = expression.getExpression();
|
|
49
|
+
const [arrow] = expression.getArguments();
|
|
50
|
+
if (isAngularCoreExport(callee, "forwardRef")) {
|
|
51
|
+
return Node.isArrowFunction(arrow) && !Node.isBlock(arrow.getBody())
|
|
52
|
+
? referenceOf(arrow.getBody())
|
|
53
|
+
: "opaque";
|
|
54
|
+
}
|
|
55
|
+
let root = callee;
|
|
56
|
+
while (Node.isPropertyAccessExpression(root))
|
|
57
|
+
root = root.getExpression();
|
|
58
|
+
// A library cannot export project classes, so its calls never add the target.
|
|
59
|
+
const declarations = Node.isIdentifier(root) ? declarationsOf(root) : [];
|
|
60
|
+
return declarations.length > 0 && declarations.every(isExternal) ? "external" : "opaque";
|
|
61
|
+
}
|
|
62
|
+
if (!Node.isIdentifier(expression))
|
|
63
|
+
return "opaque";
|
|
64
|
+
const declarations = declarationsOf(expression);
|
|
65
|
+
const [declaration] = declarations;
|
|
66
|
+
if (declarations.length !== 1 || !Node.isClassDeclaration(declaration))
|
|
67
|
+
return "opaque";
|
|
68
|
+
return isExternal(declaration) ? "external" : declaration;
|
|
69
|
+
};
|
|
70
|
+
/** What an element of `imports` / `exports` makes visible: itself, or a module's exports. */
|
|
71
|
+
const contribution = (expression, trail, depth) => {
|
|
72
|
+
const reference = referenceOf(expression);
|
|
73
|
+
if (reference === "external")
|
|
74
|
+
return "other";
|
|
75
|
+
if (reference === "opaque")
|
|
76
|
+
return "opaque";
|
|
77
|
+
if (reference === target)
|
|
78
|
+
return "target";
|
|
79
|
+
const metadata = ngModuleMetadata(reference);
|
|
80
|
+
if (!metadata)
|
|
81
|
+
return "other";
|
|
82
|
+
if (trail.has(reference))
|
|
83
|
+
return "other";
|
|
84
|
+
if (depth > MAX_SCOPE_DEPTH || metadata === "opaque")
|
|
85
|
+
return "opaque";
|
|
86
|
+
if (reference.getSourceFile().isDeclarationFile())
|
|
87
|
+
return "opaque";
|
|
88
|
+
trail.add(reference);
|
|
89
|
+
const exported = listOf(metadata, "exports");
|
|
90
|
+
if (exported === "opaque")
|
|
91
|
+
return "opaque";
|
|
92
|
+
return merge(exported.map((element) => contribution(element, trail, depth + 1)));
|
|
93
|
+
};
|
|
94
|
+
const scopeOf = (imports, trail) => merge(imports.map((element) => contribution(element, trail, 1)));
|
|
95
|
+
const verdictOf = (found) => found === "target" ? "includes" : found === "opaque" ? "opaque" : "excludes";
|
|
96
|
+
let declaring;
|
|
97
|
+
/** Every project NgModule with the classes it declares; built once, on first module consumer. */
|
|
98
|
+
const declaringModules = () => {
|
|
99
|
+
if (declaring)
|
|
100
|
+
return declaring;
|
|
101
|
+
declaring = { modules: [], opaque: false };
|
|
102
|
+
const classes = target
|
|
103
|
+
.getProject()
|
|
104
|
+
.getSourceFiles()
|
|
105
|
+
.filter((file) => !file.isDeclarationFile() &&
|
|
106
|
+
!file.isFromExternalLibrary() &&
|
|
107
|
+
file.getFullText().includes("NgModule"))
|
|
108
|
+
.flatMap((file) => file.getDescendantsOfKind(SyntaxKind.ClassDeclaration));
|
|
109
|
+
for (const cls of classes) {
|
|
110
|
+
const metadata = ngModuleMetadata(cls);
|
|
111
|
+
if (!metadata)
|
|
112
|
+
continue;
|
|
113
|
+
const list = metadata === "opaque" ? "opaque" : listOf(metadata, "declarations");
|
|
114
|
+
const references = list === "opaque" ? [] : list.map(referenceOf);
|
|
115
|
+
if (metadata === "opaque" || list === "opaque" || references.includes("opaque")) {
|
|
116
|
+
declaring.opaque = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
declaring.modules.push({
|
|
120
|
+
metadata,
|
|
121
|
+
declared: new Set(references.filter((reference) => typeof reference !== "string")),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return declaring;
|
|
125
|
+
};
|
|
126
|
+
const resolve = (consumer) => {
|
|
127
|
+
if (consumer === target)
|
|
128
|
+
return "includes";
|
|
129
|
+
const metadata = componentDecorator(consumer)?.getArguments()[0];
|
|
130
|
+
if (!Node.isObjectLiteralExpression(metadata))
|
|
131
|
+
return "opaque";
|
|
132
|
+
const flag = metadata.getProperty("standalone");
|
|
133
|
+
const initializer = Node.isPropertyAssignment(flag) ? flag.getInitializer() : undefined;
|
|
134
|
+
if (flag && !Node.isTrueLiteral(initializer) && !Node.isFalseLiteral(initializer)) {
|
|
135
|
+
return "opaque";
|
|
136
|
+
}
|
|
137
|
+
const standalone = flag
|
|
138
|
+
? Node.isTrueLiteral(initializer)
|
|
139
|
+
: options.coreMajor !== undefined && options.coreMajor >= 19;
|
|
140
|
+
const trail = new Set();
|
|
141
|
+
if (!standalone) {
|
|
142
|
+
if (!flag && options.coreMajor === undefined)
|
|
143
|
+
return "opaque";
|
|
144
|
+
const { modules, opaque } = declaringModules();
|
|
145
|
+
const owners = modules.filter((module) => module.declared.has(consumer));
|
|
146
|
+
if (opaque || owners.length !== 1)
|
|
147
|
+
return "opaque";
|
|
148
|
+
const [owner] = owners;
|
|
149
|
+
if (owner.declared.has(target))
|
|
150
|
+
return "includes";
|
|
151
|
+
const imports = listOf(owner.metadata, "imports");
|
|
152
|
+
return imports === "opaque" ? "opaque" : verdictOf(scopeOf(imports, trail));
|
|
153
|
+
}
|
|
154
|
+
const imports = listOf(metadata, "imports");
|
|
155
|
+
return imports === "opaque" ? "opaque" : verdictOf(scopeOf(imports, trail));
|
|
156
|
+
};
|
|
157
|
+
return (consumer) => {
|
|
158
|
+
let verdict = memo.get(consumer);
|
|
159
|
+
if (!verdict)
|
|
160
|
+
memo.set(consumer, (verdict = resolve(consumer)));
|
|
161
|
+
return verdict;
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -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
|
+
}
|