ast-mcp-server 0.14.3 → 0.15.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 +28 -1
- package/README.md +28 -4
- package/cordis.patch.yml +1 -1
- package/dist/services/angular-templates.js +509 -0
- package/dist/services/operations.js +107 -39
- package/dist/services/project.js +46 -4
- package/dist/services/references.js +94 -5
- package/dist/services/safe-file.js +29 -0
- package/dist/services/workspace.js +9 -1
- package/dist/tools/find_references.js +43 -9
- package/dist/tools/rename_symbol.js +1 -1
- package/package.json +14 -2
- package/skills/structural-code-editing/SKILL.md +14 -0
- package/skills/structural-code-editing/references/operations.md +6 -0
- package/skills/structural-code-editing/releases.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,30 @@ 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.15.0] — not yet published (2026-11-04)
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Angular template references (#370). In projects where `@angular/core` resolves, `ast_find_references` reports usages of a component member in its external (`templateUrl`) and inline templates. Items carry `origin` (`compiler`, `template_external`, `template_inline`), and the response adds `template_coverage` and `template_reference_count`; `reference_count` stays TypeScript-only and `total` counts both. Only `template_coverage.status: "completed"` supports an absence claim.
|
|
14
|
+
- `ast_rename_symbol` rewrites external Angular templates in the same reviewed, hash-bound plan, with rollback covering the templates. It returns `INCOMPLETE_EVIDENCE` when coverage is `unfinished` or `unsupported`, and `MUTATION_BLOCKED` for inline-template usages, a new name that collides with a template local, or `$any`. Apply remains sequential, not globally atomic.
|
|
15
|
+
- `@angular/compiler` (`>=20.0.0 <23.0.0`) is an optional peer dependency resolved from the target project; it is never bundled. `yarn test:package` now asserts it is absent from the packed `dependencies` and from a consumer install. Yarn PnP projects without `node_modules` report `unfinished` coverage.
|
|
16
|
+
- Workspace snapshots and the file watcher include Angular template files when Angular is detected; workspaces without `@angular/core` hash identically to before.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Documented limits: `ast_explore` references stay TypeScript-only (template evidence is tracked as a follow-up issue) and diagnostics, including the post-apply delta, stay TypeScript-only, so template-only errors are not detected.
|
|
21
|
+
- The `ast_find_references` and `ast_rename_symbol` descriptions now state the Angular template behavior: `ast_find_references` reports template usages tagged by origin with a `template_coverage` verdict and does not diagnose template errors, and `ast_rename_symbol` also rewrites external templates hash-bound to the workspace snapshot while diagnostics stay TypeScript-only. Input and output schemas are unchanged. The dsh-adapter smoke now declares both descriptions in `test/fixtures/declared-tool-contracts.json` (every other tool must still equal the public 0.13.0 contract), and the tool-inventory, registry-consumer and canary digests were re-baselined (#383).
|
|
22
|
+
|
|
23
|
+
- The issue #103 `node:test` suites now run once per CI run in a dedicated `fixture` job on pinned Node 24.16.0, in parallel with `quality`, instead of once per `quality` matrix entry; the workflow policy check pins both job chains (#368).
|
|
24
|
+
- The CI `quality` job now runs every committed `node:test` suite from the issue #103 chain: `npm-pack-json`, `acquisition`, `setup`, `test-lock`, `prepared-runner` and `prepared-install-build` join the exact-Node fixture command after the four suites it already ran, and the workflow policy check pins the extended command (#364).
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- A `project_root` directory whose `tsconfig.json` is solution-style (`"files": []` or no `files`/`include`, plus a non-empty `references` array, as Angular CLI and other `tsc -b` layouts ship) no longer loads as a silent empty project with `source_count: 0`. Resolution now fails closed with a `PROJECT_NOT_FOUND` error that names the referenced configs and asks for one of them as `project_root`; passing a referenced `tsconfig.*.json` file directly is unchanged, and references are never followed automatically (#389).
|
|
29
|
+
- `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
|
+
- 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
|
+
|
|
32
|
+
## [0.14.3] — published to `next`, verification failed, not promoted (2026-11-01)
|
|
10
33
|
|
|
11
34
|
### Security
|
|
12
35
|
|
|
@@ -17,6 +40,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
17
40
|
|
|
18
41
|
- `ast_get_impact` with `direction: "incoming"` no longer returns an empty, incomplete result on larger projects. The compiler resolver now finds incoming references through the language service instead of walking every AST node, so its work scales with the number of references rather than the project size, and the project-wide computed-call scan runs on its own budget so it can no longer starve the reference lookup (#353).
|
|
19
42
|
|
|
43
|
+
### Release status
|
|
44
|
+
|
|
45
|
+
- npm published these immutable bytes under `next` with provenance and the expected Git commit. Public-registry verification then failed because the consumer install resolves `ts-morph@28.0.0 → @ts-morph/common@0.29.0 → minimatch@10.2.6`, vulnerable to GHSA-g3hv-75wg-m249, and the root `minimatch` resolution does not reach consumers (#361, upstream dsherret/ts-morph#1697); `0.14.3` was never promoted to `latest`, tagged in Git, or released on GitHub.
|
|
46
|
+
|
|
20
47
|
## [0.14.2] — published to `latest` and `next`, registry verified (2026-10-28)
|
|
21
48
|
|
|
22
49
|
### Security
|
package/README.md
CHANGED
|
@@ -79,6 +79,24 @@ Impact has two independent completeness channels:
|
|
|
79
79
|
|
|
80
80
|
These fields are additive: existing edge shapes, kind strings, defaults, and public errors remain stable. MCP `ast_get_impact` JSON and TOON represent the same logical coverage/work result; candidate MCP output remains canonical JSON, while read-only batch output may encode that same candidate result as TOON. Property, element, dynamic, computed-key, and external-alternative dispatch remains edge-free and unfinished—this contract does not certify the deferred #219 or #220 classifiers.
|
|
81
81
|
|
|
82
|
+
### Angular templates
|
|
83
|
+
|
|
84
|
+
In an Angular project, `ast_find_references` also reports usages of a component member in its template, 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 (`>=20.0.0 <23.0.0`, majors 20 to 22) that Angular projects already install next to `@angular/core`. Nothing changes for projects without `@angular/core`.
|
|
85
|
+
|
|
86
|
+
Install or align it in the target project when the response reports `compiler_missing` or `compiler_unsupported`:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
yarn add --dev @angular/compiler@^21 # or npm install --save-dev @angular/compiler@^21
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Template evidence is explicit about what it proves:
|
|
93
|
+
|
|
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
|
+
- Rename returns `INCOMPLETE_EVIDENCE` when coverage is `unfinished` or `unsupported`, and `MUTATION_BLOCKED` for inline templates, a new name that collides with a template local, or `$any`. Inline templates are never rewritten.
|
|
96
|
+
- `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.
|
|
98
|
+
- Yarn PnP projects without a `node_modules` directory cannot resolve the compiler and report `unfinished`; use `nodeLinker: node-modules` for template coverage.
|
|
99
|
+
|
|
82
100
|
## Why this helps
|
|
83
101
|
|
|
84
102
|
- **Less context:** the agent retrieves the smallest structural unit that answers the question instead of loading the complete file by default.
|
|
@@ -333,7 +351,7 @@ hermes mcp add ast --command node --env AST_MCP_APPLY_GUARD=allow --args /absolu
|
|
|
333
351
|
hermes mcp test ast
|
|
334
352
|
```
|
|
335
353
|
|
|
336
|
-
Project-scoped tools accept `project_root`, either the project directory or an explicit `tsconfig.json` path. The server contains no repository-specific paths.
|
|
354
|
+
Project-scoped tools accept `project_root`, either the project directory or an explicit `tsconfig.json` path. A directory whose `tsconfig.json` is solution-style (no `files`/`include`, only `references`, as Angular CLI generates) is rejected with an error naming the referenced configs; pass one of them, such as `tsconfig.app.json`, as `project_root` instead. The server contains no repository-specific paths.
|
|
337
355
|
|
|
338
356
|
### DeepSeek Harness (Developer Preview)
|
|
339
357
|
|
|
@@ -353,7 +371,7 @@ For a local candidate, pack and install its tarball instead:
|
|
|
353
371
|
|
|
354
372
|
```bash
|
|
355
373
|
yarn pack --out ast-mcp-server-%v.tgz
|
|
356
|
-
dsh plugin --profile web add ./ast-mcp-server-0.
|
|
374
|
+
dsh plugin --profile web add ./ast-mcp-server-0.15.0.tgz
|
|
357
375
|
```
|
|
358
376
|
|
|
359
377
|
The first supported surface is **reads + prepare + preview**. Every apply path is
|
|
@@ -374,8 +392,14 @@ the official bridge drops MCP tool annotations
|
|
|
374
392
|
integrity, packs the candidate, builds the pinned Harness and bridge from source, and proves
|
|
375
393
|
`tools.mode: native`, the 15-tool scoped catalog, complete executable input schemas,
|
|
376
394
|
read/prepare/preview, all three invalid `ast_explore` combinations, apply absence, and rejected direct apply.
|
|
377
|
-
The schema gate freezes the public empty `ast_explore` contract as RED
|
|
378
|
-
registry/native definitions to match
|
|
395
|
+
The schema gate freezes the public empty `ast_explore` contract as RED and requires the candidate
|
|
396
|
+
registry/native definitions to match. For every other tool, the candidate description and input
|
|
397
|
+
schema must equal the public baseline or its committed declaration in
|
|
398
|
+
`test/fixtures/declared-tool-contracts.json`; a declaration that equals the public contract fails
|
|
399
|
+
as stale, and no tool may loosen `additionalProperties`. To change a tool contract, declare the new
|
|
400
|
+
description and semantic input schema in that fixture, change the source, and re-baseline the three
|
|
401
|
+
inventory pins (`test/fixtures/tool-inventory.json`, `scripts/registry-consumer-smoke.mjs`,
|
|
402
|
+
`scripts/canary-local-mcp.mjs`), taking the canary value from its own run. A deterministic
|
|
379
403
|
two-step model then invokes
|
|
380
404
|
`mcp__ast__ast_get_project_status` through a real headless Agent/Session: the public baseline
|
|
381
405
|
must reproduce the empty-result marker, while the corrected candidate must deliver lossless
|
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.15.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,509 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { Node, SyntaxKind } from "ts-morph";
|
|
8
|
+
import { assertSafeOperationFile } from "./safe-file.js";
|
|
9
|
+
const MAX_TEMPLATE_BYTES = 512 * 1024;
|
|
10
|
+
const MAX_TEMPLATE_NODES = 100_000;
|
|
11
|
+
const CHECKPOINT_EVERY_NODES = 1000;
|
|
12
|
+
const MIN_COMPILER_MAJOR = 20;
|
|
13
|
+
const MAX_COMPILER_MAJOR = 22;
|
|
14
|
+
const TWO_WAY_BINDING = 5; // BindingType.TwoWay, stable across supported majors
|
|
15
|
+
const TWO_WAY_EVENT = 2; // ParsedEventType.TwoWay, stable across supported majors
|
|
16
|
+
const NOT_APPLICABLE = { coverage: { status: "not_applicable" }, usages: [] };
|
|
17
|
+
const ASSIGNMENT_OPERATION = /^(?:=|(?:\*\*|&&|\|\||\?\?|<<|>>>|>>|[+\-*/%&|^])=)$/u;
|
|
18
|
+
const compilerCache = new Map();
|
|
19
|
+
function resolveFrom(tsConfigFilePath, request) {
|
|
20
|
+
try {
|
|
21
|
+
const resolved = createRequire(tsConfigFilePath).resolve(request);
|
|
22
|
+
return existsSync(resolved) ? resolved : undefined;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function isWithin(parent, child) {
|
|
29
|
+
const relative = path.relative(parent, child);
|
|
30
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
31
|
+
}
|
|
32
|
+
/** True when `packageDir` sits in a node_modules directory owned by `projectDir` or an ancestor. */
|
|
33
|
+
function isUnderAncestorNodeModules(packageDir, projectDir) {
|
|
34
|
+
const segments = packageDir.split(path.sep);
|
|
35
|
+
return segments.some((segment, index) => segment === "node_modules" &&
|
|
36
|
+
isWithin(segments.slice(0, index).join(path.sep) || path.sep, projectDir));
|
|
37
|
+
}
|
|
38
|
+
function isSupportedVersion(version) {
|
|
39
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+.*)?$/u.exec(String(version));
|
|
40
|
+
if (!match)
|
|
41
|
+
return false;
|
|
42
|
+
const [major, minor, patch] = [match[1], match[2], match[3]].map(Number);
|
|
43
|
+
const belowFloor = major === MIN_COMPILER_MAJOR && minor === 0 && patch === 0 && !!match[4];
|
|
44
|
+
return major >= MIN_COMPILER_MAJOR && major <= MAX_COMPILER_MAJOR && !belowFloor;
|
|
45
|
+
}
|
|
46
|
+
async function loadCompiler(tsConfigFilePath) {
|
|
47
|
+
const unsupported = { reason: "compiler_unsupported" };
|
|
48
|
+
const manifestPath = resolveFrom(tsConfigFilePath, "@angular/compiler/package.json");
|
|
49
|
+
if (!manifestPath) {
|
|
50
|
+
try {
|
|
51
|
+
createRequire(tsConfigFilePath).resolve("@angular/compiler/package.json");
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error.code === "MODULE_NOT_FOUND") {
|
|
55
|
+
return { reason: "compiler_missing" };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return unsupported;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const packageDir = realpathSync(path.dirname(manifestPath));
|
|
62
|
+
if (!isUnderAncestorNodeModules(packageDir, realpathSync(path.dirname(tsConfigFilePath)))) {
|
|
63
|
+
return unsupported;
|
|
64
|
+
}
|
|
65
|
+
const manifest = JSON.parse(readFileSync(path.join(packageDir, "package.json"), "utf8"));
|
|
66
|
+
const entry = resolveFrom(tsConfigFilePath, "@angular/compiler");
|
|
67
|
+
const realEntry = entry && realpathSync(entry);
|
|
68
|
+
if (manifest.name !== "@angular/compiler" || !isSupportedVersion(manifest.version)) {
|
|
69
|
+
return unsupported;
|
|
70
|
+
}
|
|
71
|
+
if (!realEntry || !isWithin(packageDir, realEntry))
|
|
72
|
+
return unsupported;
|
|
73
|
+
const key = `${realEntry}@${manifest.version}`;
|
|
74
|
+
let loading = compilerCache.get(key);
|
|
75
|
+
if (!loading) {
|
|
76
|
+
loading = import(pathToFileURL(realEntry).href);
|
|
77
|
+
compilerCache.set(key, loading);
|
|
78
|
+
}
|
|
79
|
+
const compiler = await loading.catch((error) => {
|
|
80
|
+
compilerCache.delete(key);
|
|
81
|
+
throw error;
|
|
82
|
+
});
|
|
83
|
+
const hasWriteNode = compiler.Binary !== undefined || compiler.PropertyWrite !== undefined;
|
|
84
|
+
const complete = hasWriteNode &&
|
|
85
|
+
[
|
|
86
|
+
compiler.parseTemplate,
|
|
87
|
+
compiler.tmplAstVisitAll,
|
|
88
|
+
compiler.TmplAstRecursiveVisitor,
|
|
89
|
+
compiler.RecursiveAstVisitor,
|
|
90
|
+
compiler.ImplicitReceiver,
|
|
91
|
+
compiler.ThisReceiver,
|
|
92
|
+
compiler.PropertyRead,
|
|
93
|
+
compiler.Call,
|
|
94
|
+
].every((member) => typeof member === "function");
|
|
95
|
+
return complete ? { compiler } : unsupported;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return unsupported;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function hasAngularCore(context) {
|
|
102
|
+
return resolveFrom(context.tsConfigFilePath, "@angular/core/package.json") !== undefined;
|
|
103
|
+
}
|
|
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
|
+
/** Finds the template of a component class from literal metadata only. */
|
|
128
|
+
function locateTemplate(cls) {
|
|
129
|
+
const unresolved = { reason: "template_unresolved" };
|
|
130
|
+
const metadata = componentDecorator(cls)?.getArguments()[0];
|
|
131
|
+
if (!Node.isObjectLiteralExpression(metadata) || metadata.getProperty("interpolation")) {
|
|
132
|
+
return unresolved;
|
|
133
|
+
}
|
|
134
|
+
const initializer = (name) => {
|
|
135
|
+
const property = metadata.getProperty(name);
|
|
136
|
+
if (!property)
|
|
137
|
+
return undefined;
|
|
138
|
+
return Node.isPropertyAssignment(property) ? (property.getInitializer() ?? null) : null;
|
|
139
|
+
};
|
|
140
|
+
const literalText = (node) => {
|
|
141
|
+
const literal = node ?? undefined;
|
|
142
|
+
return Node.isStringLiteral(literal) || Node.isNoSubstitutionTemplateLiteral(literal)
|
|
143
|
+
? literal.getLiteralText()
|
|
144
|
+
: undefined;
|
|
145
|
+
};
|
|
146
|
+
const sourceFile = cls.getSourceFile();
|
|
147
|
+
const templateUrl = initializer("templateUrl");
|
|
148
|
+
if (templateUrl !== undefined) {
|
|
149
|
+
const url = literalText(templateUrl);
|
|
150
|
+
if (url === undefined)
|
|
151
|
+
return unresolved;
|
|
152
|
+
return {
|
|
153
|
+
container: "external",
|
|
154
|
+
absolutePath: path.resolve(path.dirname(sourceFile.getFilePath()), url),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const template = initializer("template");
|
|
158
|
+
const text = literalText(template);
|
|
159
|
+
if (!template || text === undefined)
|
|
160
|
+
return unresolved;
|
|
161
|
+
const offset = template.getStart() + 1;
|
|
162
|
+
// Escapes or CRLF make cooked text diverge from source offsets, so spans would be inexact.
|
|
163
|
+
if (sourceFile.getFullText().slice(offset, template.getEnd() - 1) !== text)
|
|
164
|
+
return unresolved;
|
|
165
|
+
return { container: "inline", absolutePath: sourceFile.getFilePath(), text, offset };
|
|
166
|
+
}
|
|
167
|
+
function checkTemplatePath(projectRoot, absolutePath) {
|
|
168
|
+
if (!isWithin(projectRoot, absolutePath) || absolutePath === projectRoot)
|
|
169
|
+
return "template_unsafe";
|
|
170
|
+
try {
|
|
171
|
+
assertSafeOperationFile(projectRoot, absolutePath, path.relative(projectRoot, absolutePath));
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
return error.code === "ENOENT"
|
|
176
|
+
? "template_unresolved"
|
|
177
|
+
: "template_unsafe";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function componentClasses(context) {
|
|
181
|
+
return context.project
|
|
182
|
+
.getSourceFiles()
|
|
183
|
+
.filter((file) => !file.isDeclarationFile() &&
|
|
184
|
+
!file.isFromExternalLibrary() &&
|
|
185
|
+
file.getFullText().includes("Component"))
|
|
186
|
+
.flatMap((file) => file.getDescendantsOfKind(SyntaxKind.ClassDeclaration))
|
|
187
|
+
.filter((cls) => componentDecorator(cls) !== undefined);
|
|
188
|
+
}
|
|
189
|
+
/** Existing, contained external template files of every component. Sync and compiler-free. */
|
|
190
|
+
export function listTemplateFiles(context) {
|
|
191
|
+
if (!hasAngularCore(context))
|
|
192
|
+
return [];
|
|
193
|
+
const files = new Set();
|
|
194
|
+
for (const cls of componentClasses(context)) {
|
|
195
|
+
const located = locateTemplate(cls);
|
|
196
|
+
if ("container" in located &&
|
|
197
|
+
located.container === "external" &&
|
|
198
|
+
checkTemplatePath(context.projectRoot, located.absolutePath) === undefined) {
|
|
199
|
+
files.add(located.absolutePath);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return [...files].sort();
|
|
203
|
+
}
|
|
204
|
+
function memberOwner(node) {
|
|
205
|
+
let owner;
|
|
206
|
+
if (Node.isParameterDeclaration(node)) {
|
|
207
|
+
owner = node.isParameterProperty() ? node.getParent()?.getParent() : undefined;
|
|
208
|
+
}
|
|
209
|
+
else if (Node.isPropertyDeclaration(node) ||
|
|
210
|
+
Node.isMethodDeclaration(node) ||
|
|
211
|
+
Node.isGetAccessorDeclaration(node) ||
|
|
212
|
+
Node.isSetAccessorDeclaration(node)) {
|
|
213
|
+
owner = node.isStatic() ? undefined : node.getParent();
|
|
214
|
+
}
|
|
215
|
+
const nameNode = node.getNameNode?.();
|
|
216
|
+
return Node.isClassDeclaration(owner) && Node.isIdentifier(nameNode)
|
|
217
|
+
? { cls: owner, name: nameNode.getText() }
|
|
218
|
+
: undefined;
|
|
219
|
+
}
|
|
220
|
+
function hasDerivedComponent(context, cls) {
|
|
221
|
+
const identity = (node) => `${node.getSourceFile().getFilePath()}:${node.getPos()}`;
|
|
222
|
+
const target = identity(cls);
|
|
223
|
+
return componentClasses(context).some((candidate) => {
|
|
224
|
+
let base = candidate.getBaseClass();
|
|
225
|
+
for (let depth = 0; base && depth < 32; depth += 1) {
|
|
226
|
+
if (identity(base) === target)
|
|
227
|
+
return true;
|
|
228
|
+
base = base.getBaseClass();
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
class WorkExhausted extends Error {
|
|
234
|
+
}
|
|
235
|
+
/** Walks parsed template nodes with a scope stack so template-local names shadow members. */
|
|
236
|
+
function extractUsages(compiler, nodes, memberName, rc) {
|
|
237
|
+
const usages = new Map();
|
|
238
|
+
const scopes = [];
|
|
239
|
+
let visited = 0;
|
|
240
|
+
const tick = () => {
|
|
241
|
+
if (++visited > MAX_TEMPLATE_NODES)
|
|
242
|
+
throw new WorkExhausted();
|
|
243
|
+
if (visited % CHECKPOINT_EVERY_NODES === 0)
|
|
244
|
+
rc.checkpoint();
|
|
245
|
+
};
|
|
246
|
+
const isLocal = (name) => scopes.some((scope) => scope.has(name));
|
|
247
|
+
const record = (ast, kind) => {
|
|
248
|
+
const key = `${ast.nameSpan.start}:${ast.nameSpan.end}`;
|
|
249
|
+
const merged = usages.get(key)?.kind === "TemplatePropertyWrite" ? "TemplatePropertyWrite" : kind;
|
|
250
|
+
usages.set(key, {
|
|
251
|
+
start: ast.nameSpan.start,
|
|
252
|
+
end: ast.nameSpan.end,
|
|
253
|
+
kind: merged,
|
|
254
|
+
localsInScope: [...new Set(scopes.flatMap((scope) => [...scope]))].sort(),
|
|
255
|
+
});
|
|
256
|
+
};
|
|
257
|
+
// Explicit `this.` bypasses template locals; ThisReceiver only extends ImplicitReceiver in some majors.
|
|
258
|
+
const isMemberReceiver = (receiver) => receiver instanceof compiler.ThisReceiver ||
|
|
259
|
+
(receiver instanceof compiler.ImplicitReceiver && !isLocal(memberName));
|
|
260
|
+
const isMember = (ast) => ast instanceof compiler.PropertyRead &&
|
|
261
|
+
ast.name === memberName &&
|
|
262
|
+
isMemberReceiver(ast.receiver);
|
|
263
|
+
class Expressions extends compiler.RecursiveAstVisitor {
|
|
264
|
+
writeRoot;
|
|
265
|
+
visit(ast, context) {
|
|
266
|
+
tick();
|
|
267
|
+
ast.visit(this, context);
|
|
268
|
+
}
|
|
269
|
+
visitPropertyRead(ast, context) {
|
|
270
|
+
if (isMember(ast))
|
|
271
|
+
record(ast, ast === this.writeRoot ? "TemplatePropertyWrite" : "TemplatePropertyRead");
|
|
272
|
+
super.visitPropertyRead(ast, context);
|
|
273
|
+
}
|
|
274
|
+
visitCall(ast, context) {
|
|
275
|
+
if (!isMember(ast.receiver))
|
|
276
|
+
return super.visitCall(ast, context);
|
|
277
|
+
record(ast.receiver, "TemplateCall");
|
|
278
|
+
ast.args.forEach((arg) => this.visit(arg, context));
|
|
279
|
+
}
|
|
280
|
+
visitSafeCall(ast, context) {
|
|
281
|
+
this.visitCall(ast, context);
|
|
282
|
+
}
|
|
283
|
+
visitBinary(ast, context) {
|
|
284
|
+
if (!ASSIGNMENT_OPERATION.test(ast.operation) || !isMember(ast.left)) {
|
|
285
|
+
return super.visitBinary(ast, context);
|
|
286
|
+
}
|
|
287
|
+
record(ast.left, "TemplatePropertyWrite");
|
|
288
|
+
this.visit(ast.right, context);
|
|
289
|
+
}
|
|
290
|
+
visitPropertyWrite(ast, context) {
|
|
291
|
+
if (ast.name === memberName && isMemberReceiver(ast.receiver)) {
|
|
292
|
+
record(ast, "TemplatePropertyWrite");
|
|
293
|
+
}
|
|
294
|
+
this.visit(ast.receiver, context);
|
|
295
|
+
this.visit(ast.value, context);
|
|
296
|
+
}
|
|
297
|
+
visitArrowFunction(ast, context) {
|
|
298
|
+
scopes.push(new Set(ast.parameters.map((parameter) => parameter.name)));
|
|
299
|
+
this.visit(ast.body, context);
|
|
300
|
+
scopes.pop();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const expressions = new Expressions();
|
|
304
|
+
// References belong to the view that declares them and are visible before the declaration.
|
|
305
|
+
class References extends compiler.TmplAstRecursiveVisitor {
|
|
306
|
+
names = [];
|
|
307
|
+
visitReference(reference) {
|
|
308
|
+
this.names.push(reference.name);
|
|
309
|
+
}
|
|
310
|
+
visitTemplate(template) {
|
|
311
|
+
template.references.forEach((reference) => this.visitReference(reference));
|
|
312
|
+
}
|
|
313
|
+
visitIfBlock = () => { };
|
|
314
|
+
visitForLoopBlock = () => { };
|
|
315
|
+
visitSwitchBlock = () => { };
|
|
316
|
+
visitDeferredBlock = () => { };
|
|
317
|
+
}
|
|
318
|
+
const referencesOf = (children) => {
|
|
319
|
+
const collector = new References();
|
|
320
|
+
compiler.tmplAstVisitAll(collector, children);
|
|
321
|
+
return collector.names;
|
|
322
|
+
};
|
|
323
|
+
class Templates extends compiler.TmplAstRecursiveVisitor {
|
|
324
|
+
visit(node) {
|
|
325
|
+
tick();
|
|
326
|
+
node.visit(this);
|
|
327
|
+
}
|
|
328
|
+
view(locals, children, before) {
|
|
329
|
+
const names = [...locals, ...referencesOf(children)].filter((name) => !!name);
|
|
330
|
+
scopes.push(new Set(names));
|
|
331
|
+
before?.();
|
|
332
|
+
compiler.tmplAstVisitAll(this, children);
|
|
333
|
+
scopes.pop();
|
|
334
|
+
}
|
|
335
|
+
expression(value, writeRoot) {
|
|
336
|
+
if (!value?.ast)
|
|
337
|
+
return;
|
|
338
|
+
expressions.writeRoot = writeRoot && value.ast;
|
|
339
|
+
expressions.visit(value.ast);
|
|
340
|
+
expressions.writeRoot = undefined;
|
|
341
|
+
}
|
|
342
|
+
visitBoundText(text) {
|
|
343
|
+
this.expression(text.value);
|
|
344
|
+
}
|
|
345
|
+
visitBoundAttribute(attribute) {
|
|
346
|
+
this.expression(attribute.value, attribute.type === TWO_WAY_BINDING);
|
|
347
|
+
}
|
|
348
|
+
visitBoundEvent(event) {
|
|
349
|
+
if (event.type === TWO_WAY_EVENT)
|
|
350
|
+
return; // covered by the paired two-way input
|
|
351
|
+
scopes.push(new Set(["$event"]));
|
|
352
|
+
this.expression(event.handler);
|
|
353
|
+
scopes.pop();
|
|
354
|
+
}
|
|
355
|
+
visitTemplate(template) {
|
|
356
|
+
for (const list of [
|
|
357
|
+
template.templateAttrs,
|
|
358
|
+
template.inputs,
|
|
359
|
+
template.outputs,
|
|
360
|
+
template.directives,
|
|
361
|
+
]) {
|
|
362
|
+
compiler.tmplAstVisitAll(this, list ?? []);
|
|
363
|
+
}
|
|
364
|
+
this.view(template.variables.map((variable) => variable.name), template.children);
|
|
365
|
+
}
|
|
366
|
+
visitIfBlockBranch(branch) {
|
|
367
|
+
this.expression(branch.expression);
|
|
368
|
+
this.view([branch.expressionAlias?.name], branch.children);
|
|
369
|
+
}
|
|
370
|
+
visitForLoopBlock(block) {
|
|
371
|
+
this.expression(block.expression);
|
|
372
|
+
const locals = [
|
|
373
|
+
block.item.name,
|
|
374
|
+
...block.contextVariables.map((variable) => variable.name),
|
|
375
|
+
];
|
|
376
|
+
this.view(locals, block.children, () => this.expression(block.trackBy));
|
|
377
|
+
if (block.empty)
|
|
378
|
+
this.visit(block.empty);
|
|
379
|
+
}
|
|
380
|
+
visitForLoopBlockEmpty(block) {
|
|
381
|
+
this.view([], block.children);
|
|
382
|
+
}
|
|
383
|
+
visitSwitchBlock(block) {
|
|
384
|
+
this.expression(block.expression);
|
|
385
|
+
const groups = block.groups ?? block.cases.map((c) => ({ cases: [c], children: c.children }));
|
|
386
|
+
for (const group of groups) {
|
|
387
|
+
group.cases.forEach((c) => this.expression(c.expression));
|
|
388
|
+
this.view([], group.children);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
visitDeferredBlock(block) {
|
|
392
|
+
for (const triggers of [block.triggers, block.prefetchTriggers, block.hydrateTriggers]) {
|
|
393
|
+
Object.values(triggers ?? {}).forEach((trigger) => this.expression(trigger?.value));
|
|
394
|
+
}
|
|
395
|
+
this.view([], block.children);
|
|
396
|
+
[block.placeholder, block.loading, block.error].forEach((sub) => sub && this.visit(sub));
|
|
397
|
+
}
|
|
398
|
+
visitDeferredBlockPlaceholder(block) {
|
|
399
|
+
this.view([], block.children);
|
|
400
|
+
}
|
|
401
|
+
visitDeferredBlockLoading(block) {
|
|
402
|
+
this.view([], block.children);
|
|
403
|
+
}
|
|
404
|
+
visitDeferredBlockError(block) {
|
|
405
|
+
this.view([], block.children);
|
|
406
|
+
}
|
|
407
|
+
visitLetDeclaration(declaration) {
|
|
408
|
+
this.expression(declaration.value);
|
|
409
|
+
scopes[scopes.length - 1]?.add(declaration.name);
|
|
410
|
+
}
|
|
411
|
+
visitIcu(icu) {
|
|
412
|
+
[...Object.values(icu.vars ?? {}), ...Object.values(icu.placeholders ?? {})].forEach((text) => this.expression(text.value));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
new Templates().view([], nodes);
|
|
416
|
+
return [...usages.values()].sort((left, right) => left.start - right.start);
|
|
417
|
+
}
|
|
418
|
+
function lineColumn(text, offset) {
|
|
419
|
+
const before = text.slice(0, offset);
|
|
420
|
+
const lineStart = before.lastIndexOf("\n") + 1;
|
|
421
|
+
return { line: before.split("\n").length, column: offset - lineStart + 1 };
|
|
422
|
+
}
|
|
423
|
+
function decodeTemplate(bytes) {
|
|
424
|
+
try {
|
|
425
|
+
const utf16 = (bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff);
|
|
426
|
+
return utf16 ? undefined : new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
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);
|
|
454
|
+
let text;
|
|
455
|
+
let offset = 0;
|
|
456
|
+
let bytes;
|
|
457
|
+
if (located.container === "inline") {
|
|
458
|
+
({ text, offset } = located);
|
|
459
|
+
bytes = Buffer.from(text, "utf8");
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
const unsafe = checkTemplatePath(context.projectRoot, located.absolutePath);
|
|
463
|
+
if (unsafe)
|
|
464
|
+
return fail("unfinished", unsafe);
|
|
465
|
+
if ((await stat(located.absolutePath)).size > MAX_TEMPLATE_BYTES) {
|
|
466
|
+
return fail("unfinished", "size_exceeded");
|
|
467
|
+
}
|
|
468
|
+
bytes = await readFile(located.absolutePath);
|
|
469
|
+
const decoded = decodeTemplate(bytes);
|
|
470
|
+
if (decoded === undefined)
|
|
471
|
+
return fail("unfinished", "template_parse_error");
|
|
472
|
+
text = decoded;
|
|
473
|
+
}
|
|
474
|
+
// Shared cap: the external path also checks the size before reading, inline checks here.
|
|
475
|
+
if (bytes.length > MAX_TEMPLATE_BYTES)
|
|
476
|
+
return fail("unfinished", "size_exceeded");
|
|
477
|
+
rc.checkpoint();
|
|
478
|
+
const parsed = loaded.compiler.parseTemplate(text, located.absolutePath, {
|
|
479
|
+
preserveWhitespaces: true,
|
|
480
|
+
});
|
|
481
|
+
if (parsed.errors?.length)
|
|
482
|
+
return fail("unfinished", "template_parse_error");
|
|
483
|
+
let raw;
|
|
484
|
+
try {
|
|
485
|
+
raw = extractUsages(loaded.compiler, parsed.nodes, owner.name, rc);
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
if (error instanceof WorkExhausted)
|
|
489
|
+
return fail("unfinished", "work_exhausted");
|
|
490
|
+
throw error;
|
|
491
|
+
}
|
|
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
|
+
};
|
|
507
|
+
});
|
|
508
|
+
return { coverage: { status: "completed", templates: 1 }, usages };
|
|
509
|
+
}
|