ast-mcp-server 0.15.1 → 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.
@@ -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,6 +5,9 @@ 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;
@@ -13,6 +16,7 @@ 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
- return complete ? { compiler } : unsupported;
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, memberName, rc) {
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) => ast instanceof compiler.PropertyRead &&
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
- /** Collects usages of a component member inside its declaring component's template. */
433
- export async function collectTemplateUsages(node, context, rc) {
434
- rc.checkpoint();
435
- const owner = memberOwner(node);
436
- if (!owner || !hasAngularCore(context))
437
- return NOT_APPLICABLE;
438
- const isComponent = componentDecorator(owner.cls) !== undefined;
439
- const derived = hasDerivedComponent(context, owner.cls);
440
- if (!isComponent && !derived)
441
- return NOT_APPLICABLE;
442
- const fail = (status, reason) => ({
443
- coverage: { status, reason, templates: 0 },
444
- usages: [],
445
- });
446
- if (derived)
447
- return fail("unsupported", "inherited_member");
448
- const loaded = await loadCompiler(context.tsConfigFilePath);
449
- if ("reason" in loaded)
450
- return fail("unfinished", loaded.reason);
451
- const located = locateTemplate(owner.cls);
452
- if ("reason" in located)
453
- return fail("unfinished", located.reason);
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
- ({ text, offset } = located);
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 fail("unfinished", unsafe);
445
+ return { reason: unsafe };
465
446
  if ((await stat(located.absolutePath)).size > MAX_TEMPLATE_BYTES) {
466
- return fail("unfinished", "size_exceeded");
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 fail("unfinished", "template_parse_error");
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 fail("unfinished", "size_exceeded");
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 fileText = located.container === "inline" ? owner.cls.getSourceFile().getFullText() : text;
493
- const fileHash = createHash("sha256").update(bytes).digest("hex");
494
- const usages = raw.map((usage) => {
495
- const start = usage.start + offset;
496
- return {
497
- absolutePath: located.absolutePath,
498
- file: path.relative(context.projectRoot, located.absolutePath),
499
- container: located.container,
500
- start,
501
- end: usage.end + offset,
502
- ...lineColumn(fileText, start),
503
- kind: usage.kind,
504
- localsInScope: usage.localsInScope,
505
- fileHash,
506
- };
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
- return { coverage: { status: "completed", templates: 1 }, usages };
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 } = await collectTemplateUsages(node, context, rc);
422
- assertTemplateRenameAllowed(coverage.status, usages, args.newName);
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 + usages.length,
429
- auxiliaryEdits: usages.map((usage) => ({
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: args.newName,
447
+ newText,
434
448
  expectedHash: usage.fileHash,
435
449
  })),
436
450
  };