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
package/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
-
## [0.
|
|
9
|
+
## [0.16.0] — not yet published (2026-11-06)
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `ast_find_references` lists the binding usages of a selector-public member (`@Input`, `@Output`, `input()`, `output()` without aliases) in the templates of consuming components whose literal `imports` include the declaring standalone component, and reports `completed` only when every component template in the Project was read and every match resolved; otherwise `unfinished` with `cross_component`, `work_exhausted` or the template's own reason. (#408).
|
|
14
|
+
- Consumer template usages are also resolved through NgModules: a consumer declared in exactly one module with literal `declarations` sees that module's declarations plus the `exports` of its `imports`, followed through re-exported modules, and a standalone consumer importing a module sees its `exports`. When the `standalone` flag is absent, `@angular/core` 18 treats the consumer as module-declared and 19 or later as standalone. Spreads, variables, project calls such as `forRoot()`, a consumer declared in several modules or in none, and chains deeper than 32 modules stay `unfinished` with `cross_component` (#408).
|
|
15
|
+
- Consumer template usages follow literal aliases (`@Input('a')`, `@Input({ alias: 'a' })`, `input(x, { alias: 'a' })`, `output({ alias: 'a' })`), `model()` and `model.required()` (a two-way `[(x)]`, `[x]` and `(xChange)`, or the alias and `aliasChange`), and members listed in literal `inputs` / `outputs` metadata (`'x'`, `'x: alias'`, `{ name, alias }`). A non-literal alias or metadata entry stays `unfinished` with `cross_component` (#408).
|
|
16
|
+
- `ast_find_references` lists `| name` usages (kind `TemplatePipeName`) of a `@Pipe` class with a literal `name` and of its `transform` member, in the templates of components whose resolved scope (standalone or NgModule) contains the pipe class. A pipe with the same name outside that scope is ignored; a non-literal `name`, an opaque consumer scope that could see the pipe, or an unreadable template stays `unfinished`. `ast_rename_symbol` now renames a `@Pipe` class with TypeScript edits only (the `name` literal and every `| name` usage stay), while renaming `transform` stays refused with `INCOMPLETE_EVIDENCE` (#408).
|
|
17
|
+
- `ast_rename_symbol` now renames a selector-public member (`@Input`, `@Output`, `input()`, `output()`, `model()`) together with its consumer bindings when coverage is `completed`: each external consumer template gets a per-usage rewrite that keeps `[` `(` `[(` `bind-` `on-` `bindon-` and the values, a `model()` event key becomes `<name>Change`, and alias-keyed bindings stay. Consumer `.html` files are listed in `affected_files`, bound by hash and rolled back like the member's own template. It returns `MUTATION_BLOCKED` before any write for an inline consumer template, `[(x)]` on a non-`model()` pair, a new name that duplicates another input, output or alias of the directive (or its derived `Change` name) or is bound by another directive matched on the same element, `$any`, or a name in the directive's own selector. Opaque scopes, `hostDirectives`, `@Directive` members, non-literal aliases, members listed in `inputs` / `outputs` metadata and pipe `transform` stay `INCOMPLETE_EVIDENCE`. Diagnostics remain TypeScript-only, so run the project's build or tests afterwards (#408).
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- The `ast_find_references` and `ast_rename_symbol` descriptions now state the cross-component contract: `ast_find_references` template usages include consumer templates and `completed` covers templates inside the Project only, and `ast_rename_symbol` also rewrites consumer templates when coverage is `completed`. Input and output schemas are unchanged. Both descriptions are re-declared in `test/fixtures/declared-tool-contracts.json`, and the tool-inventory, registry-consumer and canary digests were re-baselined. The structural-code-editing skill, its operations reference and the README document consumer usages, the constructs that stay `unfinished` or are never examined (`setInput`, router input binding, `createComponent`, `hostDirectives`, `@Directive` inputs and outputs, consumers outside the Project), the `@Pipe` class and `transform` rename rules, and that diagnostics stay TypeScript-only, with template type checking of rename plans tracked in #421 (#408).
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Selector-public component members (`@Input`, `@Output`, `input()`, `output()`, `model()`) and `@Pipe` classes reported `template_coverage: completed` (pipes reported nothing), and a rename of an `@Input` was planned without blocking although the consuming templates were never read. They now report `unfinished` with the new reason `cross_component` (own-template usages are still listed for inputs and outputs), and `ast_rename_symbol` refuses them with `INCOMPLETE_EVIDENCE` (#407).
|
|
26
|
+
- `@Directive` members declared with `@Input`, `@Output`, `input()`, `output()` or `model()` reported no `template_coverage`, and members listed in the `inputs` / `outputs` metadata arrays of a `@Component` or `@Directive` were treated as plain members, so `ast_rename_symbol` planned their rename without blocking. They now report `unfinished` with reason `cross_component` (directives with `templates: 0`; a non-literal metadata array marks every member of the class), and `ast_rename_symbol` refuses them with `INCOMPLETE_EVIDENCE` (#410).
|
|
27
|
+
- Upgrading the `structural-code-editing` skill over an unmodified install from npm `0.11.0`–`0.15.1` failed with a conflict: `0.13.0`–`0.15.1` had no recorded predecessor, and the `0.11.0`–`0.12.0` predecessors did not list `references/operations.md`. Every published byte set is now recorded, and the skill version is `4.7.0` (#425).
|
|
28
|
+
|
|
29
|
+
## [0.15.1] — published to `next`, verification failed, not promoted (2026-11-05)
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- `@angular/compiler` 18 and 19 are now supported (`>=18.0.0 <23.0.0`); an Angular 18 project previously reported `compiler_unsupported` and missed every template usage (#399).
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- `publish-next` waits up to five minutes (was two) for the registry to expose an accepted publish before giving up, and when that bound is exhausted after a publish that did not throw, it now points the maintainer at `verify-next` instead of asking for a new dispatch that would try to republish immutable bytes. `0.15.0` was reported as failed 46 seconds before the registry served it (#395).
|
|
38
|
+
|
|
39
|
+
### Release status
|
|
40
|
+
|
|
41
|
+
- npm published these immutable bytes under `next` with provenance and the expected Git commit (`20548017b7c9266160963a0272ce8757b8f42a77`, integrity `sha512-+DkpmFAHMnvfKmZhEN8Rrk1baryumHcAkXgcw5YABhC6vzaAmD1WiPLP25YzoFNzSPFgz+L11yW/Nd4Ukq2Ixw==`, publish run `41970126401`, the first `publish-next` to confirm the registry readback under the widened bound). Public-registry verification (run `41970518670`) then failed for the same reason as `0.14.3` and `0.15.0`: the consumer install resolves `minimatch@10.2.6` through `ts-morph@28.0.0`, flagged by `npm audit` for GHSA-g3hv-75wg-m249 (#361). `0.15.1` supersedes `0.15.0` on `next` and was never promoted to `latest`, tagged in Git, or released on GitHub.
|
|
42
|
+
|
|
43
|
+
## [0.15.0] — published to `next`, verification failed, not promoted (2026-11-04)
|
|
10
44
|
|
|
11
45
|
### Added
|
|
12
46
|
|
|
@@ -29,6 +63,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
29
63
|
- `ast_find_references` and `ast_rename_symbol` no longer count the declaration identifier as a reference. When a member was used before its declaration in source order, ts-morph returned the declaration identifier among the references, so the declaration appeared twice and `reference_count` was inflated by one. The declaration is now reported once and `reference_count` counts only real references (#390).
|
|
30
64
|
- The dsh-adapter smoke no longer reports a surviving Harness process tree when descendants are still exiting after a clean shutdown on a loaded runner. The lifecycle assertion now polls the owned process tree with a bounded ten-second deadline, and when survivors remain it keeps failing but lists each PID with its command line and parent chain (#363).
|
|
31
65
|
|
|
66
|
+
### Release status
|
|
67
|
+
|
|
68
|
+
- npm published these immutable bytes under `next` with provenance and the expected Git commit (`5b2d483001d21ef29a760a9c0ae538dc719f11ea`, integrity `sha512-4dUo7lyTyIGMPhWfVHHZ0BnUMXNAiSIapItorm3VTGiJXm8JyL6jW5lrNlj1LGyONAGtA6S6cHoFtiZxakGy1A==`, publish run `41905976154`). Public-registry verification (run `41906680048`) then failed for the same reason as `0.14.3`: the consumer install resolves `minimatch@10.2.6` through `ts-morph@28.0.0`, flagged by `npm audit` for GHSA-g3hv-75wg-m249 (#361). `0.15.0` was never promoted to `latest`, tagged in Git, or released on GitHub; the version that ships the ts-morph fix will supersede it.
|
|
69
|
+
|
|
32
70
|
## [0.14.3] — published to `next`, verification failed, not promoted (2026-11-01)
|
|
33
71
|
|
|
34
72
|
### Security
|
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ These fields are additive: existing edge shapes, kind strings, defaults, and pub
|
|
|
81
81
|
|
|
82
82
|
### Angular templates
|
|
83
83
|
|
|
84
|
-
In an Angular project, `ast_find_references` also reports usages of a component member in its
|
|
84
|
+
In an Angular project, `ast_find_references` also reports usages of a component member in its own and consumer templates, and `ast_rename_symbol` rewrites external templates as part of the same reviewed plan. This needs the project's own `@angular/compiler`. The server does not bundle it: it is an optional peer dependency (`>=18.0.0 <23.0.0`, majors 18 to 22) that Angular projects already install next to `@angular/core`. Nothing changes for projects without `@angular/core`.
|
|
85
85
|
|
|
86
86
|
Install or align it in the target project when the response reports `compiler_missing` or `compiler_unsupported`:
|
|
87
87
|
|
|
@@ -92,9 +92,12 @@ yarn add --dev @angular/compiler@^21 # or npm install --save-dev @angular/comp
|
|
|
92
92
|
Template evidence is explicit about what it proves:
|
|
93
93
|
|
|
94
94
|
- Each item carries `origin` (`compiler`, `template_external`, `template_inline`); the response adds `template_coverage` and `template_reference_count`. Only `template_coverage.status: "completed"` supports a claim that no template usage exists.
|
|
95
|
-
-
|
|
95
|
+
- Members bound through the selector (`@Input`, `@Output`, `input()`, `output()`, `model()`, literal aliases, or literal entries of the `inputs` / `outputs` metadata) also list their bindings (kinds `TemplateInputBinding` for `[x]`, `TemplateAttribute` for `x="lit"`, `TemplateTwoWayBinding` for `[(x)]` and `TemplateOutputBinding` for `(y)` and `(xChange)`) in consumer templates of components whose literal standalone `imports` or NgModule scope contains the declaring component. A `@Pipe` class with a literal `name` and its `transform` list `| name` usages (kind `TemplatePipeName`) in the same way. A same selector or pipe name outside that scope is ignored.
|
|
96
|
+
- For these members, `completed` means every component template inside the Project was read and every match resolved. `hostDirectives`, `@Directive` inputs and outputs, and non-literal scopes, aliases, metadata or pipe names stay `unfinished` with reason `cross_component`. Runtime bindings (`setInput`, router input binding, `createComponent`) and consumers outside the Project are never examined.
|
|
97
|
+
- Rename rewrites consumer bindings of a selector-bound member when coverage is `completed`, keeping the binding syntax, values and alias-keyed bindings. It returns `INCOMPLETE_EVIDENCE` when coverage is `unfinished` or `unsupported` (including `hostDirectives`, `@Directive` members and metadata-listed members), and `MUTATION_BLOCKED` for inline templates, a name collision with a template local or another binding of the element, `[(x)]` on a non-`model()` pair, `$any`, or a name in the selector. Inline templates are never rewritten.
|
|
98
|
+
- Renaming a `@Pipe` class applies TypeScript edits only and keeps the `name` literal and every `| name` usage; renaming `transform` returns `INCOMPLETE_EVIDENCE`.
|
|
96
99
|
- `ast_explore` stays TypeScript-only for references; template evidence there is tracked in #380. Use `ast_find_references` for template usages.
|
|
97
|
-
- Diagnostics stay TypeScript-only: template errors are not detected, so run the project's Angular build or tests after a rename.
|
|
100
|
+
- Diagnostics stay TypeScript-only: template errors are not detected, so run the project's Angular build or tests after a rename. Template type checking of rename plans is tracked in #421.
|
|
98
101
|
- Yarn PnP projects without a `node_modules` directory cannot resolve the compiler and report `unfinished`; use `nodeLinker: node-modules` for template coverage.
|
|
99
102
|
|
|
100
103
|
## Why this helps
|
|
@@ -371,7 +374,7 @@ For a local candidate, pack and install its tarball instead:
|
|
|
371
374
|
|
|
372
375
|
```bash
|
|
373
376
|
yarn pack --out ast-mcp-server-%v.tgz
|
|
374
|
-
dsh plugin --profile web add ./ast-mcp-server-0.
|
|
377
|
+
dsh plugin --profile web add ./ast-mcp-server-0.16.0.tgz
|
|
375
378
|
```
|
|
376
379
|
|
|
377
380
|
The first supported surface is **reads + prepare + preview**. Every apply path is
|
package/cordis.patch.yml
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# Mounts the packaged `ast-mcp-server` stdio command through the official
|
|
4
4
|
# `@deepseek-ai/dsh-mcp-client` bridge. Harness does not resolve MCP binaries
|
|
5
5
|
# from PATH, so the entrypoint is resolved relative to the profile directory
|
|
6
|
-
# (`baseUrl`), where `dsh plugin --profile <name> add ./ast-mcp-server-0.
|
|
6
|
+
# (`baseUrl`), where `dsh plugin --profile <name> add ./ast-mcp-server-0.16.0.tgz`
|
|
7
7
|
# installs a locally packed package as a dependency.
|
|
8
8
|
#
|
|
9
9
|
# First-slice surface: reads + prepare + preview. Every apply path is denied
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { Node, } from "ts-morph";
|
|
4
|
+
/** The text of a plain string literal, or undefined for anything computed. */
|
|
5
|
+
export function literalString(node) {
|
|
6
|
+
return Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)
|
|
7
|
+
? node.getLiteralText()
|
|
8
|
+
: undefined;
|
|
9
|
+
}
|
|
10
|
+
/** Major version of the project's resolvable `@angular/core`, or undefined when unknown. */
|
|
11
|
+
export function angularCoreMajor(context) {
|
|
12
|
+
try {
|
|
13
|
+
const manifest = createRequire(context.tsConfigFilePath).resolve("@angular/core/package.json");
|
|
14
|
+
const major = /^(\d+)\./u.exec(String(JSON.parse(readFileSync(manifest, "utf8")).version));
|
|
15
|
+
return major ? Number(major[1]) : undefined;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** True when `callee` names `exportName` imported from `@angular/core` (named, aliased, or namespace). */
|
|
22
|
+
export function isAngularCoreExport(callee, exportName) {
|
|
23
|
+
return callee
|
|
24
|
+
.getSourceFile()
|
|
25
|
+
.getImportDeclarations()
|
|
26
|
+
.filter((declaration) => declaration.getModuleSpecifierValue() === "@angular/core")
|
|
27
|
+
.some((declaration) => {
|
|
28
|
+
const namespace = declaration.getNamespaceImport();
|
|
29
|
+
if (Node.isPropertyAccessExpression(callee)) {
|
|
30
|
+
return (callee.getName() === exportName &&
|
|
31
|
+
callee.getExpression().getText() === namespace?.getText());
|
|
32
|
+
}
|
|
33
|
+
return declaration
|
|
34
|
+
.getNamedImports()
|
|
35
|
+
.some((specifier) => specifier.getName() === exportName &&
|
|
36
|
+
(specifier.getAliasNode()?.getText() ?? exportName) === callee.getText());
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export function isCoreDecorator(decorator, name) {
|
|
40
|
+
const callee = decorator.getCallExpression()?.getExpression();
|
|
41
|
+
return callee !== undefined && isAngularCoreExport(callee, name);
|
|
42
|
+
}
|
|
43
|
+
function isComponentDecorator(decorator) {
|
|
44
|
+
return isCoreDecorator(decorator, "Component");
|
|
45
|
+
}
|
|
46
|
+
export function componentDecorator(cls) {
|
|
47
|
+
return cls.getDecorators().find(isComponentDecorator);
|
|
48
|
+
}
|
|
49
|
+
/** Pipe usages live in other components' templates, which are never read. */
|
|
50
|
+
export function isPipeClass(cls) {
|
|
51
|
+
return cls.getDecorators().some((decorator) => isCoreDecorator(decorator, "Pipe"));
|
|
52
|
+
}
|
|
53
|
+
/** Directives have no template; their selector-public members are bound only in consuming templates. */
|
|
54
|
+
export function isDirectiveClass(cls) {
|
|
55
|
+
return cls.getDecorators().some((decorator) => isCoreDecorator(decorator, "Directive"));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Members listed in the `inputs` / `outputs` metadata arrays of `@Component` / `@Directive`.
|
|
59
|
+
* Fails closed: a non-literal array or element makes every member of the class public.
|
|
60
|
+
*/
|
|
61
|
+
export function isMetadataPublic(cls, memberName) {
|
|
62
|
+
const literalName = literalString;
|
|
63
|
+
const listsMember = (element) => {
|
|
64
|
+
const text = literalName(element);
|
|
65
|
+
if (text !== undefined)
|
|
66
|
+
return text.split(":")[0].trim() === memberName;
|
|
67
|
+
if (!Node.isObjectLiteralExpression(element))
|
|
68
|
+
return true;
|
|
69
|
+
const name = element.getProperty("name");
|
|
70
|
+
const value = Node.isPropertyAssignment(name) ? literalName(name.getInitializer()) : undefined;
|
|
71
|
+
return value === undefined || value === memberName;
|
|
72
|
+
};
|
|
73
|
+
return cls
|
|
74
|
+
.getDecorators()
|
|
75
|
+
.filter((decorator) => ["Component", "Directive"].some((name) => isCoreDecorator(decorator, name)))
|
|
76
|
+
.some((decorator) => {
|
|
77
|
+
const metadata = decorator.getArguments()[0];
|
|
78
|
+
if (!Node.isObjectLiteralExpression(metadata))
|
|
79
|
+
return false;
|
|
80
|
+
return ["inputs", "outputs"].some((key) => {
|
|
81
|
+
const property = metadata.getProperty(key);
|
|
82
|
+
if (!property)
|
|
83
|
+
return false;
|
|
84
|
+
const list = Node.isPropertyAssignment(property) ? property.getInitializer() : undefined;
|
|
85
|
+
return !Node.isArrayLiteralExpression(list) || list.getElements().some(listsMember);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** Members bound through the selector (`@Input`, `@Output`, `input()`, `output()`, `model()`). */
|
|
90
|
+
export function isSelectorPublic(node) {
|
|
91
|
+
const decorated = Node.isDecoratable(node) &&
|
|
92
|
+
node
|
|
93
|
+
.getDecorators()
|
|
94
|
+
.some((decorator) => ["Input", "Output"].some((name) => isCoreDecorator(decorator, name)));
|
|
95
|
+
if (decorated || !Node.isPropertyDeclaration(node))
|
|
96
|
+
return decorated;
|
|
97
|
+
const initializer = node.getInitializer();
|
|
98
|
+
if (!Node.isCallExpression(initializer))
|
|
99
|
+
return false;
|
|
100
|
+
let callee = initializer.getExpression();
|
|
101
|
+
if (Node.isPropertyAccessExpression(callee) && callee.getName() === "required") {
|
|
102
|
+
callee = callee.getExpression(); // input.required(...) / model.required(...)
|
|
103
|
+
}
|
|
104
|
+
return ["input", "output", "model"].some((name) => isAngularCoreExport(callee, name));
|
|
105
|
+
}
|
|
106
|
+
/** The alias in a literal options object (or a literal string), the fallback when absent, else undefined. */
|
|
107
|
+
function aliasOf(options, fallback) {
|
|
108
|
+
if (options === undefined)
|
|
109
|
+
return fallback;
|
|
110
|
+
const text = literalString(options);
|
|
111
|
+
if (text !== undefined)
|
|
112
|
+
return text;
|
|
113
|
+
if (!Node.isObjectLiteralExpression(options))
|
|
114
|
+
return undefined;
|
|
115
|
+
const properties = options.getProperties();
|
|
116
|
+
const computed = (property) => Node.isPropertyAssignment(property) && Node.isComputedPropertyName(property.getNameNode());
|
|
117
|
+
if (properties.some((property) => Node.isSpreadAssignment(property) || computed(property))) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
const alias = options.getProperty("alias");
|
|
121
|
+
if (alias === undefined)
|
|
122
|
+
return fallback;
|
|
123
|
+
return Node.isPropertyAssignment(alias) ? literalString(alias.getInitializer()) : undefined;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Public binding names of an `@Input` / `@Output` / `input()` / `output()` / `model()` member,
|
|
127
|
+
* following literal aliases. A `model()` binds `x` and `xChange`. Undefined when an alias or the
|
|
128
|
+
* options are not literal, because the binding name then cannot be known.
|
|
129
|
+
*/
|
|
130
|
+
export function bindingNames(node, name) {
|
|
131
|
+
if (Node.isDecoratable(node)) {
|
|
132
|
+
for (const decorator of node.getDecorators()) {
|
|
133
|
+
const kind = ["Input", "Output"].find((id) => isCoreDecorator(decorator, id));
|
|
134
|
+
if (!kind)
|
|
135
|
+
continue;
|
|
136
|
+
const args = decorator.getArguments();
|
|
137
|
+
const alias = args.length <= 1 ? aliasOf(args[0], name) : undefined;
|
|
138
|
+
if (alias === undefined || (kind === "Output" && args[0] && !literalString(args[0]))) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
return kind === "Input" ? { inputs: [alias], outputs: [] } : { inputs: [], outputs: [alias] };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const initializer = Node.isPropertyDeclaration(node) ? node.getInitializer() : undefined;
|
|
145
|
+
if (!Node.isCallExpression(initializer))
|
|
146
|
+
return undefined;
|
|
147
|
+
let callee = initializer.getExpression();
|
|
148
|
+
const required = Node.isPropertyAccessExpression(callee) && callee.getName() === "required";
|
|
149
|
+
if (required)
|
|
150
|
+
callee = callee.getExpression();
|
|
151
|
+
const kind = ["input", "model", "output"].find((id) => isAngularCoreExport(callee, id));
|
|
152
|
+
if (!kind)
|
|
153
|
+
return undefined;
|
|
154
|
+
// The initial value comes first for input() and model(); required() and output() take options only.
|
|
155
|
+
const optionsAt = kind !== "output" && !required ? 1 : 0;
|
|
156
|
+
const args = initializer.getArguments();
|
|
157
|
+
if (args.length > optionsAt + 1)
|
|
158
|
+
return undefined;
|
|
159
|
+
const alias = aliasOf(args[optionsAt], name);
|
|
160
|
+
if (alias === undefined)
|
|
161
|
+
return undefined;
|
|
162
|
+
if (kind === "input")
|
|
163
|
+
return { inputs: [alias], outputs: [] };
|
|
164
|
+
if (kind === "output")
|
|
165
|
+
return { inputs: [], outputs: [alias] };
|
|
166
|
+
return { inputs: [alias], outputs: [`${alias}Change`] };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Public names of a member listed in the `inputs` / `outputs` metadata (`'x'`, `'x: alias'`,
|
|
170
|
+
* `{ name, alias }`). Undefined when any array or element is not literal, matching #410's fail-closed rule.
|
|
171
|
+
*/
|
|
172
|
+
export function metadataBindingNames(cls, name) {
|
|
173
|
+
const found = { inputs: [], outputs: [] };
|
|
174
|
+
for (const decorator of cls.getDecorators()) {
|
|
175
|
+
if (!["Component", "Directive"].some((id) => isCoreDecorator(decorator, id)))
|
|
176
|
+
continue;
|
|
177
|
+
const metadata = decorator.getArguments()[0];
|
|
178
|
+
if (!Node.isObjectLiteralExpression(metadata))
|
|
179
|
+
continue;
|
|
180
|
+
for (const key of ["inputs", "outputs"]) {
|
|
181
|
+
const property = metadata.getProperty(key);
|
|
182
|
+
if (!property)
|
|
183
|
+
continue;
|
|
184
|
+
const list = Node.isPropertyAssignment(property) ? property.getInitializer() : undefined;
|
|
185
|
+
if (!Node.isArrayLiteralExpression(list))
|
|
186
|
+
return undefined;
|
|
187
|
+
for (const element of list.getElements()) {
|
|
188
|
+
let listed;
|
|
189
|
+
let alias;
|
|
190
|
+
const text = literalString(element);
|
|
191
|
+
if (text !== undefined) {
|
|
192
|
+
const [member = "", rest] = text.split(":");
|
|
193
|
+
listed = member.trim();
|
|
194
|
+
alias = rest === undefined ? listed : rest.trim();
|
|
195
|
+
}
|
|
196
|
+
else if (Node.isObjectLiteralExpression(element)) {
|
|
197
|
+
const nameProperty = element.getProperty("name");
|
|
198
|
+
const value = Node.isPropertyAssignment(nameProperty)
|
|
199
|
+
? literalString(nameProperty.getInitializer())
|
|
200
|
+
: undefined;
|
|
201
|
+
if (value === undefined)
|
|
202
|
+
return undefined;
|
|
203
|
+
listed = value;
|
|
204
|
+
alias = aliasOf(element, listed);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
if (listed !== name)
|
|
210
|
+
continue;
|
|
211
|
+
if (alias === undefined)
|
|
212
|
+
return undefined;
|
|
213
|
+
found[key].push(alias);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return found;
|
|
218
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// BindingType.Property / TwoWay and ParsedEventType.Regular / TwoWay are stable across v18-v22.
|
|
2
|
+
// Animation members were renumbered in v20 and are deliberately never matched.
|
|
3
|
+
const PROPERTY = 0;
|
|
4
|
+
const TWO_WAY = 5;
|
|
5
|
+
const REGULAR_EVENT = 0;
|
|
6
|
+
const TWO_WAY_EVENT = 2;
|
|
7
|
+
const KINDS = {
|
|
8
|
+
input: "TemplateInputBinding",
|
|
9
|
+
static_input: "TemplateAttribute",
|
|
10
|
+
two_way: "TemplateTwoWayBinding",
|
|
11
|
+
output: "TemplateOutputBinding",
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Cheap necessary condition: an alternative can match only if its element (or first attribute)
|
|
15
|
+
* name occurs in the text. Alternatives with neither (such as `.card`) always require a parse.
|
|
16
|
+
*/
|
|
17
|
+
export function mayMatch(compiler, selector, text) {
|
|
18
|
+
const lower = text.toLowerCase();
|
|
19
|
+
return compiler.CssSelector.parse(selector).some((alternative) => {
|
|
20
|
+
const required = alternative.element ?? alternative.attrs[0];
|
|
21
|
+
return !required || required === "*" || lower.includes(required.toLowerCase());
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function pairedTwoWayInput(target, event) {
|
|
25
|
+
return (target.outputs.includes(event) &&
|
|
26
|
+
event.endsWith("Change") &&
|
|
27
|
+
target.inputs.includes(event.slice(0, -"Change".length)));
|
|
28
|
+
}
|
|
29
|
+
/** Pure: finds the bindings of `target` in a parsed consumer template, matching by selector. */
|
|
30
|
+
export function extractConsumerUsages(compiler, nodes, text, target, tick = () => { }) {
|
|
31
|
+
const matcher = new compiler.SelectorMatcher();
|
|
32
|
+
matcher.addSelectables(compiler.CssSelector.parse(target.selector));
|
|
33
|
+
const scan = { matched: false, opaque: false, usages: [] };
|
|
34
|
+
const consider = (element) => {
|
|
35
|
+
const nodeSelector = compiler.createCssSelectorFromNode(element);
|
|
36
|
+
let hit = false;
|
|
37
|
+
matcher.match(nodeSelector, () => (hit = true));
|
|
38
|
+
if (!hit)
|
|
39
|
+
return;
|
|
40
|
+
scan.matched = true;
|
|
41
|
+
const elementMatches = (selector) => {
|
|
42
|
+
try {
|
|
43
|
+
const other = new compiler.SelectorMatcher();
|
|
44
|
+
other.addSelectables(compiler.CssSelector.parse(selector));
|
|
45
|
+
let found = false;
|
|
46
|
+
other.match(nodeSelector, () => (found = true));
|
|
47
|
+
return found;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false; // a selector the compiler cannot parse cannot match either
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const add = (binding, name, keySpan) => {
|
|
54
|
+
const start = keySpan?.start.offset;
|
|
55
|
+
const end = keySpan?.end.offset;
|
|
56
|
+
// Runtime check, not a typing: the key span must slice to exactly the binding name.
|
|
57
|
+
if (start === undefined || text.slice(start, end) !== name)
|
|
58
|
+
scan.opaque = true;
|
|
59
|
+
else {
|
|
60
|
+
scan.usages.push({
|
|
61
|
+
start,
|
|
62
|
+
end,
|
|
63
|
+
kind: KINDS[binding],
|
|
64
|
+
binding,
|
|
65
|
+
bindingName: name,
|
|
66
|
+
elementMatches,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
for (const input of element.inputs) {
|
|
71
|
+
const twoWay = input.type === TWO_WAY;
|
|
72
|
+
if ((input.type === PROPERTY || twoWay) && target.inputs.includes(input.name)) {
|
|
73
|
+
add(twoWay ? "two_way" : "input", input.name, input.keySpan);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const attribute of element.attributes) {
|
|
77
|
+
if (target.inputs.includes(attribute.name))
|
|
78
|
+
add("static_input", attribute.name, attribute.keySpan);
|
|
79
|
+
}
|
|
80
|
+
for (const output of element.outputs) {
|
|
81
|
+
// The event half of `[(x)]` on a model pair is already reported through the input half.
|
|
82
|
+
if (output.type === TWO_WAY_EVENT && pairedTwoWayInput(target, output.name))
|
|
83
|
+
continue;
|
|
84
|
+
const regular = output.type === REGULAR_EVENT || output.type === TWO_WAY_EVENT;
|
|
85
|
+
if (regular && target.outputs.includes(output.name))
|
|
86
|
+
add("output", output.name, output.keySpan);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
class Visitor extends compiler.TmplAstRecursiveVisitor {
|
|
90
|
+
visitElement(element) {
|
|
91
|
+
tick();
|
|
92
|
+
consider(element);
|
|
93
|
+
if (!element.attributes.some((attribute) => attribute.name === "ngNonBindable")) {
|
|
94
|
+
super.visitElement(element);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
visitTemplate(template) {
|
|
98
|
+
tick();
|
|
99
|
+
super.visitTemplate(template);
|
|
100
|
+
}
|
|
101
|
+
// Selectorless nodes reference classes by name, which selector matching cannot see.
|
|
102
|
+
visitComponent() {
|
|
103
|
+
scan.opaque = true;
|
|
104
|
+
}
|
|
105
|
+
visitDirective() {
|
|
106
|
+
scan.opaque = true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
compiler.tmplAstVisitAll(new Visitor(), nodes);
|
|
110
|
+
return scan;
|
|
111
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Node, SyntaxKind } from "ts-morph";
|
|
2
|
+
import { bindingNames, componentDecorator, isCoreDecorator, isMetadataPublic, isSelectorPublic, literalString, metadataBindingNames, } from "./angular-decorators.js";
|
|
3
|
+
import { memberOwner } from "./angular-templates.js";
|
|
4
|
+
import { PublicOperationalError } from "./public-errors.js";
|
|
5
|
+
const blocked = (reason) => new PublicOperationalError("MUTATION_BLOCKED", reason);
|
|
6
|
+
const CHANGE = "Change";
|
|
7
|
+
function selectorOf(cls) {
|
|
8
|
+
const decorator = componentDecorator(cls) ??
|
|
9
|
+
cls.getDecorators().find((candidate) => isCoreDecorator(candidate, "Directive"));
|
|
10
|
+
const metadata = decorator?.getArguments()[0];
|
|
11
|
+
if (!Node.isObjectLiteralExpression(metadata))
|
|
12
|
+
return undefined;
|
|
13
|
+
const property = metadata.getProperty("selector");
|
|
14
|
+
return literalString(Node.isPropertyAssignment(property) ? property.getInitializer() : undefined);
|
|
15
|
+
}
|
|
16
|
+
/** Public names of every selector-bound member of `cls` except `skip`; undefined when any is unknowable. */
|
|
17
|
+
function publicNames(cls, skip) {
|
|
18
|
+
const names = new Set();
|
|
19
|
+
for (const member of cls.getMembers()) {
|
|
20
|
+
if (member === skip)
|
|
21
|
+
continue;
|
|
22
|
+
const name = member.getName?.();
|
|
23
|
+
if (name === undefined)
|
|
24
|
+
continue;
|
|
25
|
+
const listed = isMetadataPublic(cls, name);
|
|
26
|
+
if (!listed && !isSelectorPublic(member))
|
|
27
|
+
continue;
|
|
28
|
+
// A member both decorated and listed in metadata has ambiguous binding names.
|
|
29
|
+
const found = listed && isSelectorPublic(member)
|
|
30
|
+
? undefined
|
|
31
|
+
: listed
|
|
32
|
+
? metadataBindingNames(cls, name)
|
|
33
|
+
: bindingNames(member, name);
|
|
34
|
+
if (!found)
|
|
35
|
+
return undefined;
|
|
36
|
+
[...found.inputs, ...found.outputs].forEach((entry) => names.add(entry));
|
|
37
|
+
}
|
|
38
|
+
return names;
|
|
39
|
+
}
|
|
40
|
+
/** Directives other than `target` whose selector matches the element of a consumer binding. */
|
|
41
|
+
function siblingDirectives(context, target) {
|
|
42
|
+
return context.project
|
|
43
|
+
.getSourceFiles()
|
|
44
|
+
.filter((file) => !file.isDeclarationFile() && !file.isFromExternalLibrary())
|
|
45
|
+
.flatMap((file) => file.getDescendantsOfKind(SyntaxKind.ClassDeclaration))
|
|
46
|
+
.filter((cls) => cls !== target &&
|
|
47
|
+
cls
|
|
48
|
+
.getDecorators()
|
|
49
|
+
.some((decorator) => ["Component", "Directive"].some((name) => isCoreDecorator(decorator, name))));
|
|
50
|
+
}
|
|
51
|
+
function hasHostDirectives(cls) {
|
|
52
|
+
return cls.getDecorators().some((decorator) => {
|
|
53
|
+
const metadata = decorator.getArguments()[0];
|
|
54
|
+
return Node.isObjectLiteralExpression(metadata) && !!metadata.getProperty("hostDirectives");
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Plans the template edit of every usage with its own `newText`, or throws `MUTATION_BLOCKED` when
|
|
59
|
+
* the rewrite cannot be proven safe. Must run before the TypeScript rename mutates the program.
|
|
60
|
+
* Own-template usages are the member name itself. Consumer bindings use the public binding name:
|
|
61
|
+
* an alias-keyed binding is left alone, and a `model()` event key becomes `<newName>Change`.
|
|
62
|
+
*/
|
|
63
|
+
export function planTemplateEdits(node, usages, newName, context) {
|
|
64
|
+
const owner = memberOwner(node);
|
|
65
|
+
const consumers = usages.filter((usage) => usage.role === "consumer");
|
|
66
|
+
if (!owner || !isSelectorPublic(node)) {
|
|
67
|
+
return usages.map((usage) => ({ usage, newText: newName }));
|
|
68
|
+
}
|
|
69
|
+
const names = bindingNames(node, owner.name);
|
|
70
|
+
if (!names)
|
|
71
|
+
throw blocked("The public binding names of the member cannot be determined.");
|
|
72
|
+
const isModel = names.inputs.length === 1 && names.outputs.length === 1;
|
|
73
|
+
const published = [...names.inputs, ...names.outputs];
|
|
74
|
+
const renamed = new Map([
|
|
75
|
+
[owner.name, newName],
|
|
76
|
+
...(isModel ? [[`${owner.name}${CHANGE}`, `${newName}${CHANGE}`]] : []),
|
|
77
|
+
].filter(([from]) => published.includes(from)));
|
|
78
|
+
if (renamed.size > 0)
|
|
79
|
+
assertNewNamesFree(owner.cls, node, renamed, consumers, context);
|
|
80
|
+
const edits = [];
|
|
81
|
+
for (const usage of usages) {
|
|
82
|
+
const newText = usage.role === "own" ? newName : renamed.get(usage.bindingName ?? "");
|
|
83
|
+
if (newText === undefined)
|
|
84
|
+
continue; // an alias-keyed binding still names the alias
|
|
85
|
+
if (usage.binding === "two_way" && !isModel) {
|
|
86
|
+
throw blocked("A two-way binding needs a model() member: its paired event cannot be renamed.");
|
|
87
|
+
}
|
|
88
|
+
edits.push({ usage, newText });
|
|
89
|
+
}
|
|
90
|
+
return edits;
|
|
91
|
+
}
|
|
92
|
+
function assertNewNamesFree(cls, node, renamed, consumers, context) {
|
|
93
|
+
const incoming = [...renamed.values()];
|
|
94
|
+
const others = publicNames(cls, node);
|
|
95
|
+
if (!others)
|
|
96
|
+
throw blocked("Another member's public binding names cannot be determined.");
|
|
97
|
+
if (incoming.some((name) => others.has(name))) {
|
|
98
|
+
throw blocked("The new name duplicates another input, output or alias of the same directive.");
|
|
99
|
+
}
|
|
100
|
+
const tokens = new Set((selectorOf(cls) ?? "")
|
|
101
|
+
.toLowerCase()
|
|
102
|
+
.split(/[^\w$-]+/u)
|
|
103
|
+
.filter(Boolean));
|
|
104
|
+
if ([...renamed.keys(), ...incoming].some((name) => tokens.has(name.toLowerCase()))) {
|
|
105
|
+
throw blocked("The binding name appears in the directive's own selector.");
|
|
106
|
+
}
|
|
107
|
+
const siblings = siblingDirectives(context, cls);
|
|
108
|
+
for (const usage of consumers) {
|
|
109
|
+
for (const sibling of siblings) {
|
|
110
|
+
const selector = selectorOf(sibling);
|
|
111
|
+
if (selector === undefined || !usage.elementMatches?.(selector))
|
|
112
|
+
continue;
|
|
113
|
+
const theirs = hasHostDirectives(sibling) ? undefined : publicNames(sibling);
|
|
114
|
+
if (!theirs || incoming.some((name) => theirs.has(name))) {
|
|
115
|
+
throw blocked("Another directive on the same element binds the new name or cannot be listed.");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|