ast-mcp-server 0.15.0 → 0.16.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 +39 -1
- package/README.md +7 -4
- package/cordis.patch.yml +1 -1
- package/dist/services/angular-decorators.js +218 -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-templates.js +269 -74
- package/dist/services/operations.js +21 -7
- package/dist/tools/find_references.js +2 -1
- package/dist/tools/rename_symbol.js +1 -1
- package/package.json +5 -3
- package/skills/structural-code-editing/SKILL.md +12 -6
- package/skills/structural-code-editing/references/operations.md +2 -2
- package/skills/structural-code-editing/releases.json +99 -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
|
+
}
|
|
@@ -5,14 +5,18 @@ import { createRequire } from "node:module";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
import { Node, SyntaxKind } from "ts-morph";
|
|
8
|
+
import { angularCoreMajor, componentDecorator, isCoreDecorator, isDirectiveClass, isMetadataPublic, isPipeClass, isSelectorPublic, literalString, bindingNames, metadataBindingNames, } from "./angular-decorators.js";
|
|
9
|
+
import { extractConsumerUsages, mayMatch, } from "./angular-template-bindings.js";
|
|
10
|
+
import { createScopeResolver, MAX_SCOPE_VISITS, ScopeWorkExhausted, } from "./angular-template-scope.js";
|
|
8
11
|
import { assertSafeOperationFile } from "./safe-file.js";
|
|
9
12
|
const MAX_TEMPLATE_BYTES = 512 * 1024;
|
|
10
13
|
const MAX_TEMPLATE_NODES = 100_000;
|
|
11
14
|
const CHECKPOINT_EVERY_NODES = 1000;
|
|
12
|
-
const MIN_COMPILER_MAJOR =
|
|
15
|
+
const MIN_COMPILER_MAJOR = 18;
|
|
13
16
|
const MAX_COMPILER_MAJOR = 22;
|
|
14
17
|
const TWO_WAY_BINDING = 5; // BindingType.TwoWay, stable across supported majors
|
|
15
18
|
const TWO_WAY_EVENT = 2; // ParsedEventType.TwoWay, stable across supported majors
|
|
19
|
+
const SCAN_LIMITS = { templates: 2000, bytes: 32 * 1024 * 1024, nodes: 1_000_000 };
|
|
16
20
|
const NOT_APPLICABLE = { coverage: { status: "not_applicable" }, usages: [] };
|
|
17
21
|
const ASSIGNMENT_OPERATION = /^(?:=|(?:\*\*|&&|\|\||\?\?|<<|>>>|>>|[+\-*/%&|^])=)$/u;
|
|
18
22
|
const compilerCache = new Map();
|
|
@@ -92,7 +96,12 @@ async function loadCompiler(tsConfigFilePath) {
|
|
|
92
96
|
compiler.PropertyRead,
|
|
93
97
|
compiler.Call,
|
|
94
98
|
].every((member) => typeof member === "function");
|
|
95
|
-
|
|
99
|
+
const selectors = [
|
|
100
|
+
compiler.CssSelector?.parse,
|
|
101
|
+
compiler.SelectorMatcher,
|
|
102
|
+
compiler.createCssSelectorFromNode,
|
|
103
|
+
].every((member) => typeof member === "function");
|
|
104
|
+
return complete ? { compiler, selectors } : unsupported;
|
|
96
105
|
}
|
|
97
106
|
catch {
|
|
98
107
|
return unsupported;
|
|
@@ -101,29 +110,6 @@ async function loadCompiler(tsConfigFilePath) {
|
|
|
101
110
|
function hasAngularCore(context) {
|
|
102
111
|
return resolveFrom(context.tsConfigFilePath, "@angular/core/package.json") !== undefined;
|
|
103
112
|
}
|
|
104
|
-
function isComponentDecorator(decorator) {
|
|
105
|
-
const callee = decorator.getCallExpression()?.getExpression();
|
|
106
|
-
if (!callee)
|
|
107
|
-
return false;
|
|
108
|
-
return decorator
|
|
109
|
-
.getSourceFile()
|
|
110
|
-
.getImportDeclarations()
|
|
111
|
-
.filter((declaration) => declaration.getModuleSpecifierValue() === "@angular/core")
|
|
112
|
-
.some((declaration) => {
|
|
113
|
-
const namespace = declaration.getNamespaceImport();
|
|
114
|
-
if (Node.isPropertyAccessExpression(callee)) {
|
|
115
|
-
return (callee.getName() === "Component" &&
|
|
116
|
-
callee.getExpression().getText() === namespace?.getText());
|
|
117
|
-
}
|
|
118
|
-
return declaration
|
|
119
|
-
.getNamedImports()
|
|
120
|
-
.some((specifier) => specifier.getName() === "Component" &&
|
|
121
|
-
(specifier.getAliasNode()?.getText() ?? "Component") === callee.getText());
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
function componentDecorator(cls) {
|
|
125
|
-
return cls.getDecorators().find(isComponentDecorator);
|
|
126
|
-
}
|
|
127
113
|
/** Finds the template of a component class from literal metadata only. */
|
|
128
114
|
function locateTemplate(cls) {
|
|
129
115
|
const unresolved = { reason: "template_unresolved" };
|
|
@@ -201,7 +187,7 @@ export function listTemplateFiles(context) {
|
|
|
201
187
|
}
|
|
202
188
|
return [...files].sort();
|
|
203
189
|
}
|
|
204
|
-
function memberOwner(node) {
|
|
190
|
+
export function memberOwner(node) {
|
|
205
191
|
let owner;
|
|
206
192
|
if (Node.isParameterDeclaration(node)) {
|
|
207
193
|
owner = node.isParameterProperty() ? node.getParent()?.getParent() : undefined;
|
|
@@ -232,8 +218,12 @@ function hasDerivedComponent(context, cls) {
|
|
|
232
218
|
}
|
|
233
219
|
class WorkExhausted extends Error {
|
|
234
220
|
}
|
|
221
|
+
/** A pipe name span that does not slice to the name: the usage cannot be edited exactly. */
|
|
222
|
+
class SpanDrift extends Error {
|
|
223
|
+
}
|
|
235
224
|
/** Walks parsed template nodes with a scope stack so template-local names shadow members. */
|
|
236
|
-
function extractUsages(compiler, nodes,
|
|
225
|
+
function extractUsages(compiler, nodes, query, rc, onNode = () => { }) {
|
|
226
|
+
const memberName = "member" in query ? query.member : undefined;
|
|
237
227
|
const usages = new Map();
|
|
238
228
|
const scopes = [];
|
|
239
229
|
let visited = 0;
|
|
@@ -242,6 +232,7 @@ function extractUsages(compiler, nodes, memberName, rc) {
|
|
|
242
232
|
throw new WorkExhausted();
|
|
243
233
|
if (visited % CHECKPOINT_EVERY_NODES === 0)
|
|
244
234
|
rc.checkpoint();
|
|
235
|
+
onNode();
|
|
245
236
|
};
|
|
246
237
|
const isLocal = (name) => scopes.some((scope) => scope.has(name));
|
|
247
238
|
const record = (ast, kind) => {
|
|
@@ -251,13 +242,15 @@ function extractUsages(compiler, nodes, memberName, rc) {
|
|
|
251
242
|
start: ast.nameSpan.start,
|
|
252
243
|
end: ast.nameSpan.end,
|
|
253
244
|
kind: merged,
|
|
245
|
+
role: "own",
|
|
254
246
|
localsInScope: [...new Set(scopes.flatMap((scope) => [...scope]))].sort(),
|
|
255
247
|
});
|
|
256
248
|
};
|
|
257
249
|
// Explicit `this.` bypasses template locals; ThisReceiver only extends ImplicitReceiver in some majors.
|
|
258
250
|
const isMemberReceiver = (receiver) => receiver instanceof compiler.ThisReceiver ||
|
|
259
251
|
(receiver instanceof compiler.ImplicitReceiver && !isLocal(memberName));
|
|
260
|
-
const isMember = (ast) =>
|
|
252
|
+
const isMember = (ast) => memberName !== undefined &&
|
|
253
|
+
ast instanceof compiler.PropertyRead &&
|
|
261
254
|
ast.name === memberName &&
|
|
262
255
|
isMemberReceiver(ast.receiver);
|
|
263
256
|
class Expressions extends compiler.RecursiveAstVisitor {
|
|
@@ -287,6 +280,15 @@ function extractUsages(compiler, nodes, memberName, rc) {
|
|
|
287
280
|
record(ast.left, "TemplatePropertyWrite");
|
|
288
281
|
this.visit(ast.right, context);
|
|
289
282
|
}
|
|
283
|
+
visitPipe(ast, context) {
|
|
284
|
+
if ("pipe" in query && ast.name === query.pipe) {
|
|
285
|
+
if (query.text.slice(ast.nameSpan.start, ast.nameSpan.end) !== query.pipe) {
|
|
286
|
+
throw new SpanDrift();
|
|
287
|
+
}
|
|
288
|
+
record(ast, "TemplatePipeName");
|
|
289
|
+
}
|
|
290
|
+
super.visitPipe(ast, context);
|
|
291
|
+
}
|
|
290
292
|
visitPropertyWrite(ast, context) {
|
|
291
293
|
if (ast.name === memberName && isMemberReceiver(ast.receiver)) {
|
|
292
294
|
record(ast, "TemplatePropertyWrite");
|
|
@@ -429,81 +431,274 @@ function decodeTemplate(bytes) {
|
|
|
429
431
|
return undefined;
|
|
430
432
|
}
|
|
431
433
|
}
|
|
432
|
-
/**
|
|
433
|
-
|
|
434
|
-
rc.checkpoint();
|
|
435
|
-
const owner = memberOwner(node);
|
|
436
|
-
if (!owner || !hasAngularCore(context))
|
|
437
|
-
return NOT_APPLICABLE;
|
|
438
|
-
const isComponent = componentDecorator(owner.cls) !== undefined;
|
|
439
|
-
const derived = hasDerivedComponent(context, owner.cls);
|
|
440
|
-
if (!isComponent && !derived)
|
|
441
|
-
return NOT_APPLICABLE;
|
|
442
|
-
const fail = (status, reason) => ({
|
|
443
|
-
coverage: { status, reason, templates: 0 },
|
|
444
|
-
usages: [],
|
|
445
|
-
});
|
|
446
|
-
if (derived)
|
|
447
|
-
return fail("unsupported", "inherited_member");
|
|
448
|
-
const loaded = await loadCompiler(context.tsConfigFilePath);
|
|
449
|
-
if ("reason" in loaded)
|
|
450
|
-
return fail("unfinished", loaded.reason);
|
|
451
|
-
const located = locateTemplate(owner.cls);
|
|
452
|
-
if ("reason" in located)
|
|
453
|
-
return fail("unfinished", located.reason);
|
|
434
|
+
/** Reads a located template within the per-template size cap. */
|
|
435
|
+
async function readTemplate(context, located) {
|
|
454
436
|
let text;
|
|
455
|
-
let offset = 0;
|
|
456
437
|
let bytes;
|
|
457
438
|
if (located.container === "inline") {
|
|
458
|
-
|
|
439
|
+
text = located.text;
|
|
459
440
|
bytes = Buffer.from(text, "utf8");
|
|
460
441
|
}
|
|
461
442
|
else {
|
|
462
443
|
const unsafe = checkTemplatePath(context.projectRoot, located.absolutePath);
|
|
463
444
|
if (unsafe)
|
|
464
|
-
return
|
|
445
|
+
return { reason: unsafe };
|
|
465
446
|
if ((await stat(located.absolutePath)).size > MAX_TEMPLATE_BYTES) {
|
|
466
|
-
return
|
|
447
|
+
return { reason: "size_exceeded" };
|
|
467
448
|
}
|
|
468
449
|
bytes = await readFile(located.absolutePath);
|
|
469
450
|
const decoded = decodeTemplate(bytes);
|
|
470
451
|
if (decoded === undefined)
|
|
471
|
-
return
|
|
452
|
+
return { reason: "template_parse_error" };
|
|
472
453
|
text = decoded;
|
|
473
454
|
}
|
|
474
455
|
// Shared cap: the external path also checks the size before reading, inline checks here.
|
|
475
456
|
if (bytes.length > MAX_TEMPLATE_BYTES)
|
|
476
|
-
return
|
|
457
|
+
return { reason: "size_exceeded" };
|
|
458
|
+
return { text, bytes, offset: located.container === "inline" ? located.offset : 0 };
|
|
459
|
+
}
|
|
460
|
+
function toUsages(context, cls, located, read, raw) {
|
|
461
|
+
const fileText = located.container === "inline" ? cls.getSourceFile().getFullText() : read.text;
|
|
462
|
+
const fileHash = createHash("sha256").update(read.bytes).digest("hex");
|
|
463
|
+
return raw.map((usage) => {
|
|
464
|
+
const start = usage.start + read.offset;
|
|
465
|
+
return {
|
|
466
|
+
...usage,
|
|
467
|
+
absolutePath: located.absolutePath,
|
|
468
|
+
file: path.relative(context.projectRoot, located.absolutePath),
|
|
469
|
+
container: located.container,
|
|
470
|
+
start,
|
|
471
|
+
end: usage.end + read.offset,
|
|
472
|
+
...lineColumn(fileText, start),
|
|
473
|
+
fileHash,
|
|
474
|
+
};
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
/** Selector and public binding names, or undefined when bindings cannot be named from the member. */
|
|
478
|
+
function bindingTarget(cls, node, name) {
|
|
479
|
+
const metadata = componentDecorator(cls)?.getArguments()[0];
|
|
480
|
+
if (!Node.isObjectLiteralExpression(metadata) || metadata.getProperty("hostDirectives")) {
|
|
481
|
+
return undefined;
|
|
482
|
+
}
|
|
483
|
+
const property = metadata.getProperty("selector");
|
|
484
|
+
const selector = literalString(Node.isPropertyAssignment(property) ? property.getInitializer() : undefined);
|
|
485
|
+
const names = isMetadataPublic(cls, name)
|
|
486
|
+
? // A member both decorated and listed in metadata has ambiguous binding names.
|
|
487
|
+
isSelectorPublic(node)
|
|
488
|
+
? undefined
|
|
489
|
+
: metadataBindingNames(cls, name)
|
|
490
|
+
: bindingNames(node, name);
|
|
491
|
+
if (!selector || !names)
|
|
492
|
+
return undefined;
|
|
493
|
+
return { selector, inputs: names.inputs, outputs: names.outputs };
|
|
494
|
+
}
|
|
495
|
+
/** Parses a consumer template, or returns undefined when the compiler reports errors. */
|
|
496
|
+
function parseConsumer(compiler, text, absolutePath) {
|
|
497
|
+
const parsed = compiler.parseTemplate(text, absolutePath, { preserveWhitespaces: true });
|
|
498
|
+
return parsed.errors?.length ? undefined : parsed.nodes;
|
|
499
|
+
}
|
|
500
|
+
function selectorInspector(compiler, target) {
|
|
501
|
+
return (text, absolutePath, tick) => {
|
|
502
|
+
if (!mayMatch(compiler, target.selector, text))
|
|
503
|
+
return "skip";
|
|
504
|
+
const nodes = parseConsumer(compiler, text, absolutePath);
|
|
505
|
+
if (!nodes)
|
|
506
|
+
return "template_parse_error";
|
|
507
|
+
const scan = extractConsumerUsages(compiler, nodes, text, target, tick);
|
|
508
|
+
const usages = scan.usages.map((usage) => ({
|
|
509
|
+
...usage,
|
|
510
|
+
localsInScope: [],
|
|
511
|
+
role: "consumer",
|
|
512
|
+
}));
|
|
513
|
+
return { matched: scan.matched, opaque: scan.opaque, usages };
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
function pipeInspector(compiler, name, rc) {
|
|
517
|
+
return (text, absolutePath, tick) => {
|
|
518
|
+
if (!text.includes(name))
|
|
519
|
+
return "skip";
|
|
520
|
+
const nodes = parseConsumer(compiler, text, absolutePath);
|
|
521
|
+
if (!nodes)
|
|
522
|
+
return "template_parse_error";
|
|
523
|
+
try {
|
|
524
|
+
const raw = extractUsages(compiler, nodes, { pipe: name, text }, rc, tick);
|
|
525
|
+
const usages = raw.map((usage) => ({ ...usage, role: "consumer" }));
|
|
526
|
+
return { matched: usages.length > 0, opaque: false, usages };
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
if (error instanceof SpanDrift)
|
|
530
|
+
return { matched: true, opaque: true, usages: [] };
|
|
531
|
+
throw error;
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Scans every component template in the Project with `inspect`. Matching comes first; scope only
|
|
537
|
+
* filters matches. Unreadable templates and unproven scopes are reported.
|
|
538
|
+
*/
|
|
539
|
+
async function scanConsumers(context, cls, inspect, rc, limits) {
|
|
540
|
+
const remaining = { ...SCAN_LIMITS, ...limits };
|
|
541
|
+
const usages = [];
|
|
542
|
+
let failure;
|
|
543
|
+
let examined = 0;
|
|
544
|
+
const resolve = createScopeResolver(cls, {
|
|
545
|
+
coreMajor: angularCoreMajor(context),
|
|
546
|
+
budget: { scopeVisits: MAX_SCOPE_VISITS },
|
|
547
|
+
rc,
|
|
548
|
+
});
|
|
549
|
+
const tick = () => {
|
|
550
|
+
if (--remaining.nodes < 0)
|
|
551
|
+
throw new WorkExhausted();
|
|
552
|
+
if (remaining.nodes % CHECKPOINT_EVERY_NODES === 0)
|
|
553
|
+
rc.checkpoint();
|
|
554
|
+
};
|
|
555
|
+
try {
|
|
556
|
+
for (const consumer of componentClasses(context)) {
|
|
557
|
+
rc.checkpoint();
|
|
558
|
+
if (--remaining.templates < 0)
|
|
559
|
+
throw new WorkExhausted();
|
|
560
|
+
const located = locateTemplate(consumer);
|
|
561
|
+
if ("reason" in located) {
|
|
562
|
+
failure ??= located.reason;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const read = await readTemplate(context, located);
|
|
566
|
+
if ("reason" in read) {
|
|
567
|
+
failure ??= read.reason;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
remaining.bytes -= read.bytes.length;
|
|
571
|
+
if (remaining.bytes < 0)
|
|
572
|
+
throw new WorkExhausted();
|
|
573
|
+
examined += 1;
|
|
574
|
+
const scan = inspect(read.text, located.absolutePath, tick);
|
|
575
|
+
if (scan === "skip")
|
|
576
|
+
continue;
|
|
577
|
+
if (scan === "template_parse_error") {
|
|
578
|
+
failure ??= scan;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (scan.opaque)
|
|
582
|
+
failure ??= "cross_component";
|
|
583
|
+
if (!scan.matched)
|
|
584
|
+
continue;
|
|
585
|
+
const verdict = resolve(consumer);
|
|
586
|
+
if (verdict === "opaque")
|
|
587
|
+
failure ??= "cross_component";
|
|
588
|
+
if (verdict === "includes") {
|
|
589
|
+
usages.push(...toUsages(context, consumer, located, read, scan.usages));
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch (error) {
|
|
594
|
+
if (error instanceof WorkExhausted || error instanceof ScopeWorkExhausted) {
|
|
595
|
+
return { usages: [], failure: "work_exhausted", examined };
|
|
596
|
+
}
|
|
597
|
+
throw error;
|
|
598
|
+
}
|
|
599
|
+
return { usages, failure, examined };
|
|
600
|
+
}
|
|
601
|
+
/** The literal `name` of a `@Pipe({ name })` class, or undefined when it is not a plain literal. */
|
|
602
|
+
function pipeName(cls) {
|
|
603
|
+
const decorator = cls.getDecorators().find((candidate) => isCoreDecorator(candidate, "Pipe"));
|
|
604
|
+
const metadata = decorator?.getArguments()[0];
|
|
605
|
+
if (!Node.isObjectLiteralExpression(metadata))
|
|
606
|
+
return undefined;
|
|
607
|
+
const property = metadata.getProperty("name");
|
|
608
|
+
return literalString(Node.isPropertyAssignment(property) ? property.getInitializer() : undefined);
|
|
609
|
+
}
|
|
610
|
+
/** `| name` usages of a pipe class in the templates of components that have the class in scope. */
|
|
611
|
+
async function collectPipeUsages(cls, context, rc, limits) {
|
|
612
|
+
const unfinished = (reason, templates = 0) => ({
|
|
613
|
+
coverage: { status: "unfinished", reason, templates },
|
|
614
|
+
usages: [],
|
|
615
|
+
});
|
|
616
|
+
const name = pipeName(cls);
|
|
617
|
+
if (name === undefined)
|
|
618
|
+
return unfinished("cross_component");
|
|
619
|
+
const loaded = await loadCompiler(context.tsConfigFilePath);
|
|
620
|
+
if ("reason" in loaded)
|
|
621
|
+
return unfinished(loaded.reason);
|
|
622
|
+
const scan = await scanConsumers(context, cls, pipeInspector(loaded.compiler, name, rc), rc, limits);
|
|
623
|
+
const coverage = scan.failure
|
|
624
|
+
? { status: "unfinished", reason: scan.failure, templates: scan.examined }
|
|
625
|
+
: { status: "completed", templates: scan.examined };
|
|
626
|
+
return { coverage, usages: scan.usages };
|
|
627
|
+
}
|
|
628
|
+
/** Collects usages of a component member inside its declaring component's template. */
|
|
629
|
+
export async function collectTemplateUsages(node, context, rc, limits = {}) {
|
|
630
|
+
rc.checkpoint();
|
|
631
|
+
if (!hasAngularCore(context))
|
|
632
|
+
return NOT_APPLICABLE;
|
|
633
|
+
const fail = (status, reason) => ({
|
|
634
|
+
coverage: { status, reason, templates: 0 },
|
|
635
|
+
usages: [],
|
|
636
|
+
});
|
|
637
|
+
if (Node.isClassDeclaration(node)) {
|
|
638
|
+
return isPipeClass(node) ? collectPipeUsages(node, context, rc, limits) : NOT_APPLICABLE;
|
|
639
|
+
}
|
|
640
|
+
const owner = memberOwner(node);
|
|
641
|
+
if (!owner)
|
|
642
|
+
return NOT_APPLICABLE;
|
|
643
|
+
if (isPipeClass(owner.cls)) {
|
|
644
|
+
// Only `transform` is the pipe contract: it reports the pipe's usages but a rename stays refused.
|
|
645
|
+
if (owner.name !== "transform" || componentDecorator(owner.cls)) {
|
|
646
|
+
return fail("unfinished", "cross_component");
|
|
647
|
+
}
|
|
648
|
+
return { ...(await collectPipeUsages(owner.cls, context, rc, limits)), renameBlocked: true };
|
|
649
|
+
}
|
|
650
|
+
const isComponent = componentDecorator(owner.cls) !== undefined;
|
|
651
|
+
const selectorPublic = isSelectorPublic(node) || isMetadataPublic(owner.cls, owner.name);
|
|
652
|
+
if (!isComponent && selectorPublic && isDirectiveClass(owner.cls)) {
|
|
653
|
+
return fail("unfinished", "cross_component");
|
|
654
|
+
}
|
|
655
|
+
const derived = hasDerivedComponent(context, owner.cls);
|
|
656
|
+
if (!isComponent && !derived)
|
|
657
|
+
return NOT_APPLICABLE;
|
|
658
|
+
if (derived)
|
|
659
|
+
return fail("unsupported", "inherited_member");
|
|
660
|
+
const loaded = await loadCompiler(context.tsConfigFilePath);
|
|
661
|
+
if ("reason" in loaded)
|
|
662
|
+
return fail("unfinished", loaded.reason);
|
|
663
|
+
const located = locateTemplate(owner.cls);
|
|
664
|
+
if ("reason" in located)
|
|
665
|
+
return fail("unfinished", located.reason);
|
|
666
|
+
const read = await readTemplate(context, located);
|
|
667
|
+
if ("reason" in read)
|
|
668
|
+
return fail("unfinished", read.reason);
|
|
477
669
|
rc.checkpoint();
|
|
478
|
-
const parsed = loaded.compiler.parseTemplate(text, located.absolutePath, {
|
|
670
|
+
const parsed = loaded.compiler.parseTemplate(read.text, located.absolutePath, {
|
|
479
671
|
preserveWhitespaces: true,
|
|
480
672
|
});
|
|
481
673
|
if (parsed.errors?.length)
|
|
482
674
|
return fail("unfinished", "template_parse_error");
|
|
483
675
|
let raw;
|
|
484
676
|
try {
|
|
485
|
-
raw = extractUsages(loaded.compiler, parsed.nodes, owner.name, rc);
|
|
677
|
+
raw = extractUsages(loaded.compiler, parsed.nodes, { member: owner.name }, rc);
|
|
486
678
|
}
|
|
487
679
|
catch (error) {
|
|
488
680
|
if (error instanceof WorkExhausted)
|
|
489
681
|
return fail("unfinished", "work_exhausted");
|
|
490
682
|
throw error;
|
|
491
683
|
}
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
start,
|
|
501
|
-
end: usage.end + offset,
|
|
502
|
-
...lineColumn(fileText, start),
|
|
503
|
-
kind: usage.kind,
|
|
504
|
-
localsInScope: usage.localsInScope,
|
|
505
|
-
fileHash,
|
|
506
|
-
};
|
|
684
|
+
const usages = toUsages(context, owner.cls, located, read, raw);
|
|
685
|
+
if (!selectorPublic)
|
|
686
|
+
return { coverage: { status: "completed", templates: 1 }, usages };
|
|
687
|
+
// Selector-public members are also bound in consuming templates: scan every component template.
|
|
688
|
+
const target = bindingTarget(owner.cls, node, owner.name);
|
|
689
|
+
const unfinished = (reason, templates = 1) => ({
|
|
690
|
+
coverage: { status: "unfinished", reason, templates },
|
|
691
|
+
usages,
|
|
507
692
|
});
|
|
508
|
-
|
|
693
|
+
if (!target)
|
|
694
|
+
return unfinished("cross_component");
|
|
695
|
+
if (!loaded.selectors)
|
|
696
|
+
return unfinished("compiler_unsupported");
|
|
697
|
+
const scan = await scanConsumers(context, owner.cls, selectorInspector(loaded.compiler, target), rc, limits);
|
|
698
|
+
const coverage = scan.failure
|
|
699
|
+
? { status: "unfinished", reason: scan.failure, templates: scan.examined }
|
|
700
|
+
: { status: "completed", templates: scan.examined };
|
|
701
|
+
// Names listed in `inputs` / `outputs` metadata are strings that a TypeScript rename cannot follow.
|
|
702
|
+
const renameBlocked = isMetadataPublic(owner.cls, owner.name);
|
|
703
|
+
return { coverage, usages: [...usages, ...scan.usages], renameBlocked };
|
|
509
704
|
}
|
|
@@ -4,6 +4,8 @@ import { open, readFile, stat } from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { createTwoFilesPatch } from "diff";
|
|
6
6
|
import { LanguageVariant, Node, ScriptTarget, SyntaxKind, ts } from "ts-morph";
|
|
7
|
+
import { isPipeClass } from "./angular-decorators.js";
|
|
8
|
+
import { planTemplateEdits } from "./angular-template-rename.js";
|
|
7
9
|
import { collectTemplateUsages } from "./angular-templates.js";
|
|
8
10
|
import { findNonDeclarationReferences } from "./references.js";
|
|
9
11
|
import { compareObservedDiagnostics, observeDiagnostic, } from "./diagnostics.js";
|
|
@@ -390,8 +392,8 @@ function publicOperation(record) {
|
|
|
390
392
|
}
|
|
391
393
|
const TEMPLATE_ANY_CAST = "$any";
|
|
392
394
|
/** Fails closed before any edit when template evidence is incomplete or the new name would be captured. */
|
|
393
|
-
function assertTemplateRenameAllowed(coverageStatus, usages, newName) {
|
|
394
|
-
if (coverageStatus === "unfinished" || coverageStatus === "unsupported") {
|
|
395
|
+
function assertTemplateRenameAllowed(coverageStatus, usages, newName, renameBlocked = false) {
|
|
396
|
+
if (renameBlocked || coverageStatus === "unfinished" || coverageStatus === "unsupported") {
|
|
395
397
|
throw new PublicOperationalError("INCOMPLETE_EVIDENCE", "Template coverage is incomplete, so the rename cannot be proven safe.");
|
|
396
398
|
}
|
|
397
399
|
if (usages.some((usage) => usage.container === "inline")) {
|
|
@@ -417,20 +419,32 @@ export async function prepareRename(args, requestContext = NO_REQUEST_CONTEXT) {
|
|
|
417
419
|
if (!Node.isRenameable(node)) {
|
|
418
420
|
throw new Error(`Symbol "${args.symbolPath}" (${node.getKindName()}) does not support structural rename.`);
|
|
419
421
|
}
|
|
422
|
+
// Templates bind a pipe by its `name` literal, never by class, so a class rename has no
|
|
423
|
+
// template edit and needs no template evidence.
|
|
424
|
+
const pipeClass = Node.isClassDeclaration(node) && isPipeClass(node);
|
|
420
425
|
// Template usages must be collected before the rename mutates the program.
|
|
421
|
-
const { coverage, usages } =
|
|
422
|
-
|
|
426
|
+
const { coverage, usages, renameBlocked } = pipeClass
|
|
427
|
+
? {
|
|
428
|
+
coverage: { status: "not_applicable" },
|
|
429
|
+
usages: [],
|
|
430
|
+
renameBlocked: false,
|
|
431
|
+
}
|
|
432
|
+
: await collectTemplateUsages(node, context, rc);
|
|
433
|
+
assertTemplateRenameAllowed(coverage.status, usages, args.newName, renameBlocked);
|
|
434
|
+
const edits = coverage.status === "completed" || usages.length > 0
|
|
435
|
+
? planTemplateEdits(node, usages, args.newName, context)
|
|
436
|
+
: [];
|
|
423
437
|
const tsReferenceCount = Node.isReferenceFindable(node)
|
|
424
438
|
? findNonDeclarationReferences(node).length
|
|
425
439
|
: 0;
|
|
426
440
|
node.rename(args.newName);
|
|
427
441
|
return {
|
|
428
|
-
referenceCount: tsReferenceCount +
|
|
429
|
-
auxiliaryEdits:
|
|
442
|
+
referenceCount: tsReferenceCount + edits.length,
|
|
443
|
+
auxiliaryEdits: edits.map(({ usage, newText }) => ({
|
|
430
444
|
absolutePath: usage.absolutePath,
|
|
431
445
|
start: usage.start,
|
|
432
446
|
end: usage.end,
|
|
433
|
-
newText
|
|
447
|
+
newText,
|
|
434
448
|
expectedHash: usage.fileHash,
|
|
435
449
|
})),
|
|
436
450
|
};
|