@angular-modernizer/api 0.1.3 → 0.2.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.
Files changed (31) hide show
  1. package/dist/index.d.ts +4 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/investigation/call-graph-builder.d.ts +95 -8
  6. package/dist/investigation/call-graph-builder.d.ts.map +1 -1
  7. package/dist/investigation/call-graph-builder.js +424 -81
  8. package/dist/investigation/call-graph-builder.js.map +1 -1
  9. package/dist/investigation/codebase-searcher.d.ts +68 -0
  10. package/dist/investigation/codebase-searcher.d.ts.map +1 -1
  11. package/dist/investigation/codebase-searcher.js +154 -10
  12. package/dist/investigation/codebase-searcher.js.map +1 -1
  13. package/dist/investigation/template-usage-finder.d.ts +64 -0
  14. package/dist/investigation/template-usage-finder.d.ts.map +1 -0
  15. package/dist/investigation/template-usage-finder.js +279 -0
  16. package/dist/investigation/template-usage-finder.js.map +1 -0
  17. package/dist/investigation/template-usage-scanner.d.ts +69 -0
  18. package/dist/investigation/template-usage-scanner.d.ts.map +1 -0
  19. package/dist/investigation/template-usage-scanner.js +375 -0
  20. package/dist/investigation/template-usage-scanner.js.map +1 -0
  21. package/dist/investigation/usage-finder.d.ts +51 -5
  22. package/dist/investigation/usage-finder.d.ts.map +1 -1
  23. package/dist/investigation/usage-finder.js +129 -36
  24. package/dist/investigation/usage-finder.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/index.ts +16 -0
  27. package/src/investigation/call-graph-builder.ts +528 -103
  28. package/src/investigation/codebase-searcher.ts +240 -11
  29. package/src/investigation/template-usage-finder.ts +389 -0
  30. package/src/investigation/template-usage-scanner.ts +529 -0
  31. package/src/investigation/usage-finder.ts +194 -54
@@ -12,6 +12,13 @@
12
12
  * `search`. For `symbol` mode only top-level named declarations are indexed;
13
13
  * local variables and anonymous expressions are not returned.
14
14
  *
15
+ * When `rootDir` is given, `text` and `pattern` modes additionally scan files
16
+ * under that directory which are NOT part of the ts-morph program: Angular
17
+ * templates (`.html`) and `.ts` files outside the tsconfig (for example
18
+ * `src/i18n/*.ts`). `node_modules`, `dist`, `.angular`, `coverage`, `out-tsc`
19
+ * and hidden directories are skipped. Use `searchWithCoverage` to learn how
20
+ * many files were actually searched.
21
+ *
15
22
  * @example
16
23
  * ```typescript
17
24
  * const searcher = new CodebaseSearcher(project);
@@ -27,7 +34,7 @@
27
34
  * ```
28
35
  */
29
36
 
30
- import type { Project } from 'ts-morph';
37
+ import type { Project, SourceFile } from 'ts-morph';
31
38
 
32
39
  /** The kind of named declaration returned in symbol-mode results. */
33
40
  export type SymbolKind =
@@ -57,6 +64,36 @@ export interface SearchResult {
57
64
  * Absent in `text` and `pattern` modes.
58
65
  */
59
66
  symbolKind?: SymbolKind;
67
+
68
+ /**
69
+ * `program` when the file is part of the ts-morph program, `non-program`
70
+ * when it was found by the file-system scan (templates, non-program `.ts`).
71
+ */
72
+ source?: 'program' | 'non-program';
73
+ }
74
+
75
+ /** Describes which files a search actually covered. */
76
+ export interface SearchCoverage {
77
+ /** Number of program source files searched (after the `include` filter). */
78
+ programFileCount: number;
79
+
80
+ /** Number of non-program files searched (templates, non-program `.ts`). */
81
+ extraFileCount: number;
82
+
83
+ /** Whether `.html` templates under `rootDir` were scanned. */
84
+ templatesScanned: boolean;
85
+
86
+ /** Whether `.ts` files outside the program were scanned. */
87
+ nonProgramFilesScanned: boolean;
88
+
89
+ /** Directory names that are never scanned. */
90
+ excludedDirectories: string[];
91
+ }
92
+
93
+ /** Result of `CodebaseSearcher.searchWithCoverage()`. */
94
+ export interface SearchWithCoverageResult {
95
+ results: SearchResult[];
96
+ coverage: SearchCoverage;
60
97
  }
61
98
 
62
99
  /** Options for `CodebaseSearcher.search()`. */
@@ -80,6 +117,65 @@ export interface SearchOptions {
80
117
  * returned.
81
118
  */
82
119
  maxResults?: number;
120
+
121
+ /**
122
+ * Project root used to discover files that are not part of the ts-morph
123
+ * program. When omitted only program files are searched.
124
+ */
125
+ rootDir?: string;
126
+
127
+ /**
128
+ * Scan `.html` files under `rootDir` (text/pattern modes only).
129
+ * Defaults to `true` when `rootDir` is set.
130
+ */
131
+ includeTemplates?: boolean;
132
+
133
+ /**
134
+ * Scan `.ts` files under `rootDir` that are not part of the program
135
+ * (text/pattern modes only). Defaults to `true` when `rootDir` is set.
136
+ */
137
+ includeNonProgramFiles?: boolean;
138
+ }
139
+
140
+ /** Directory names never descended into by the non-program file scan. */
141
+ export const EXCLUDED_SEARCH_DIRECTORIES = [
142
+ 'node_modules',
143
+ 'dist',
144
+ '.angular',
145
+ 'coverage',
146
+ 'out-tsc',
147
+ ] as const;
148
+
149
+ /** A searchable file: path plus lazily loaded text. */
150
+ interface SearchableFile {
151
+ path: string;
152
+ text: () => string;
153
+ source: 'program' | 'non-program';
154
+ }
155
+
156
+ function normalizePath(path: string): string {
157
+ return path.replaceAll('\\', '/');
158
+ }
159
+
160
+ function joinPath(dir: string, name: string): string {
161
+ const normalizedName = normalizePath(name);
162
+ if (normalizedName.startsWith('/') || /^[A-Za-z]:\//.test(normalizedName)) {
163
+ return normalizedName;
164
+ }
165
+ const base = normalizePath(dir).replace(/\/+$/, '');
166
+ return `${base}/${normalizedName}`;
167
+ }
168
+
169
+ function baseName(path: string): string {
170
+ const parts = normalizePath(path).split('/');
171
+ return parts[parts.length - 1] ?? path;
172
+ }
173
+
174
+ function isExcludedDirectory(name: string): boolean {
175
+ return (
176
+ name.startsWith('.') ||
177
+ (EXCLUDED_SEARCH_DIRECTORIES as readonly string[]).includes(name)
178
+ );
83
179
  }
84
180
 
85
181
  /**
@@ -116,7 +212,28 @@ export class CodebaseSearcher {
116
212
  * ```
117
213
  */
118
214
  search(query: string, options: SearchOptions): SearchResult[] {
119
- const sourceFiles = this.project
215
+ return this.searchWithCoverage(query, options).results;
216
+ }
217
+
218
+ /**
219
+ * Like `search`, but also reports which files were covered. Callers should
220
+ * surface `coverage` whenever a search returns 0 hits: an empty result only
221
+ * proves absence within the covered files.
222
+ *
223
+ * @example
224
+ * ```typescript
225
+ * const { results, coverage } = searcher.searchWithCoverage('i18n.key', {
226
+ * mode: 'text',
227
+ * rootDir: '/project',
228
+ * });
229
+ * // coverage.programFileCount, coverage.extraFileCount
230
+ * ```
231
+ */
232
+ searchWithCoverage(
233
+ query: string,
234
+ options: SearchOptions,
235
+ ): SearchWithCoverageResult {
236
+ const programFiles = this.project
120
237
  .getSourceFiles()
121
238
  .filter(
122
239
  (sf) =>
@@ -124,15 +241,126 @@ export class CodebaseSearcher {
124
241
  matchesGlob(sf.getFilePath(), options.include),
125
242
  );
126
243
 
244
+ const scanExtras = options.mode !== 'symbol' && options.rootDir !== undefined;
245
+ const templatesScanned = scanExtras && options.includeTemplates !== false;
246
+ const nonProgramFilesScanned =
247
+ scanExtras && options.includeNonProgramFiles !== false;
248
+
249
+ const extraFiles =
250
+ options.rootDir === undefined
251
+ ? []
252
+ : this.collectNonProgramFiles(options.rootDir, {
253
+ html: templatesScanned,
254
+ ts: nonProgramFilesScanned,
255
+ include: options.include,
256
+ });
257
+
127
258
  const results: SearchResult[] =
128
259
  options.mode === 'symbol'
129
- ? this.searchSymbols(query, sourceFiles)
130
- : this.searchText(query, options.mode, sourceFiles);
260
+ ? this.searchSymbols(query, programFiles)
261
+ : this.searchText(query, options.mode, [
262
+ ...programFiles.map((sf) => this.toSearchable(sf)),
263
+ ...extraFiles,
264
+ ]);
131
265
 
132
- if (options.maxResults !== undefined) {
133
- return results.slice(0, options.maxResults);
266
+ const limited =
267
+ options.maxResults === undefined
268
+ ? results
269
+ : results.slice(0, options.maxResults);
270
+
271
+ return {
272
+ results: limited,
273
+ coverage: {
274
+ programFileCount: programFiles.length,
275
+ extraFileCount: extraFiles.length,
276
+ templatesScanned,
277
+ nonProgramFilesScanned,
278
+ excludedDirectories: [...EXCLUDED_SEARCH_DIRECTORIES, '.*'],
279
+ },
280
+ };
281
+ }
282
+
283
+ private toSearchable(sf: SourceFile): SearchableFile {
284
+ return {
285
+ path: sf.getFilePath(),
286
+ text: () => sf.getFullText(),
287
+ source: 'program',
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Walks `rootDir` via the project's file system host (works for real and
293
+ * in-memory file systems) and returns files outside the program.
294
+ */
295
+ private collectNonProgramFiles(
296
+ rootDir: string,
297
+ filter: { html: boolean; ts: boolean; include: string | undefined },
298
+ ): SearchableFile[] {
299
+ if (!filter.html && !filter.ts) {
300
+ return [];
134
301
  }
135
- return results;
302
+
303
+ const fs = this.project.getFileSystem();
304
+ const programPaths = new Set(
305
+ this.project.getSourceFiles().map((sf) => normalizePath(sf.getFilePath())),
306
+ );
307
+ const files: SearchableFile[] = [];
308
+ const pending: string[] = [normalizePath(rootDir)];
309
+ const visited = new Set<string>();
310
+
311
+ const wanted = (path: string): boolean => {
312
+ if (filter.html && path.endsWith('.html')) {
313
+ return true;
314
+ }
315
+ return filter.ts && path.endsWith('.ts') && !programPaths.has(path);
316
+ };
317
+
318
+ while (pending.length > 0) {
319
+ const dir = pending.pop();
320
+ if (dir === undefined || visited.has(dir)) {
321
+ continue;
322
+ }
323
+ visited.add(dir);
324
+
325
+ let entries: ReturnType<typeof fs.readDirSync>;
326
+ try {
327
+ entries = fs.readDirSync(dir);
328
+ } catch {
329
+ continue;
330
+ }
331
+
332
+ for (const entry of entries) {
333
+ const fullPath = joinPath(dir, entry.name);
334
+ if (entry.isDirectory) {
335
+ if (!entry.isSymlink && !isExcludedDirectory(baseName(fullPath))) {
336
+ pending.push(fullPath);
337
+ }
338
+ continue;
339
+ }
340
+ if (!entry.isFile || !wanted(fullPath)) {
341
+ continue;
342
+ }
343
+ if (
344
+ filter.include !== undefined &&
345
+ !matchesGlob(fullPath, filter.include)
346
+ ) {
347
+ continue;
348
+ }
349
+ files.push({
350
+ path: fullPath,
351
+ text: () => {
352
+ try {
353
+ return fs.readFileSync(fullPath);
354
+ } catch {
355
+ return '';
356
+ }
357
+ },
358
+ source: 'non-program',
359
+ });
360
+ }
361
+ }
362
+
363
+ return files.sort((a, b) => a.path.localeCompare(b.path));
136
364
  }
137
365
 
138
366
  /**
@@ -141,14 +369,14 @@ export class CodebaseSearcher {
141
369
  private searchText(
142
370
  query: string,
143
371
  mode: 'text' | 'pattern',
144
- sourceFiles: ReturnType<Project['getSourceFiles']>,
372
+ files: SearchableFile[],
145
373
  ): SearchResult[] {
146
374
  const results: SearchResult[] = [];
147
375
  const regex = mode === 'pattern' ? new RegExp(query) : null;
148
376
 
149
- for (const sf of sourceFiles) {
150
- const filePath = sf.getFilePath();
151
- const lines = sf.getFullText().split('\n');
377
+ for (const file of files) {
378
+ const filePath = file.path;
379
+ const lines = file.text().split('\n');
152
380
 
153
381
  lines.forEach((lineText, index) => {
154
382
  const matched =
@@ -168,6 +396,7 @@ export class CodebaseSearcher {
168
396
  line: index + 1,
169
397
  col,
170
398
  snippet: lineText.trim(),
399
+ source: file.source,
171
400
  });
172
401
  });
173
402
  }
@@ -0,0 +1,389 @@
1
+ /**
2
+ * @angular-modernizer/api - Template Usage Finder
3
+ *
4
+ * Project-level companion to `scanTemplate`: resolves what a symbol means
5
+ * in Angular templates (component/directive selector, pipe name, class
6
+ * member, input/output binding) and scans every component template
7
+ * (external `templateUrl` files and inline `template:` literals).
8
+ *
9
+ * @remarks
10
+ * Member hits are `verified` when they read the member from the component
11
+ * context (`foo`, `this.foo`) inside the template of the declaring class or
12
+ * one of its subclasses. Same-name reads elsewhere (other components, or
13
+ * `x.foo`) are reported as `template-unverified`: they may or may not refer
14
+ * to the searched member.
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * const finder = new TemplateUsageFinder(project);
19
+ * const usages = finder.findTemplateUsages('UserCardComponent', declarations);
20
+ * // [{ file: '/src/app/list.component.html', usageType: 'template', templateKind: 'element', ... }]
21
+ * ```
22
+ */
23
+
24
+ import {
25
+ Node,
26
+ type ClassDeclaration,
27
+ type Decorator,
28
+ type Project,
29
+ type SourceFile,
30
+ } from 'ts-morph';
31
+ import { dirname, resolve } from 'node:path';
32
+ import {
33
+ scanTemplate,
34
+ type TemplateHit,
35
+ type TemplateQuery,
36
+ type TemplateUsageKind,
37
+ } from './template-usage-scanner.js';
38
+
39
+ /** Whether a template hit is known to refer to the searched symbol. */
40
+ export type TemplateMatch = 'verified' | 'template-unverified';
41
+
42
+ /** A template usage mapped back to a file position. */
43
+ export interface TemplateSymbolUsage {
44
+ /** Absolute path of the `.html` file, or of the `.ts` file for inline templates. */
45
+ file: string;
46
+
47
+ /** 1-based line. */
48
+ line: number;
49
+
50
+ /** 1-based column. */
51
+ col: number;
52
+
53
+ /** Trimmed source line. */
54
+ snippet: string;
55
+
56
+ /** Kind of template usage. */
57
+ templateKind: TemplateUsageKind;
58
+
59
+ /** Verification level (see class remarks). */
60
+ templateMatch: TemplateMatch;
61
+
62
+ /** Class name of the component that owns the template. */
63
+ component: string;
64
+
65
+ /** Explanation for unverified hits. */
66
+ note?: string;
67
+ }
68
+
69
+ const COMPONENT_DECORATORS = new Set(['Component', 'ExportComponent']);
70
+ const DIRECTIVE_DECORATORS = new Set([
71
+ 'Component',
72
+ 'ExportComponent',
73
+ 'Directive',
74
+ ]);
75
+ const SIGNAL_BINDING_FACTORIES = new Set(['input', 'output', 'model']);
76
+
77
+ interface ComponentTemplate {
78
+ owner: ClassDeclaration;
79
+ /** File the positions refer to. */
80
+ file: string;
81
+ content: string;
82
+ /** Maps an offset inside `content` to a 1-based line/column in `file`. */
83
+ locate: (offset: number) => { line: number; col: number; snippet: string };
84
+ }
85
+
86
+ interface MemberTarget {
87
+ owner: ClassDeclaration;
88
+ bindingNames: string[];
89
+ }
90
+
91
+ function findDecorator(
92
+ cls: ClassDeclaration,
93
+ names: Set<string>,
94
+ ): Decorator | undefined {
95
+ return cls.getDecorators().find((d) => names.has(d.getName()));
96
+ }
97
+
98
+ /** Reads a string-valued property (`selector`, `name`, `templateUrl`) from decorator metadata. */
99
+ function readDecoratorString(
100
+ decorator: Decorator,
101
+ property: string,
102
+ ): { value: string; node: Node } | undefined {
103
+ const arg = decorator.getArguments()[0];
104
+ if (arg === undefined || !Node.isObjectLiteralExpression(arg)) {
105
+ return undefined;
106
+ }
107
+ const prop = arg.getProperty(property);
108
+ if (prop === undefined || !Node.isPropertyAssignment(prop)) {
109
+ return undefined;
110
+ }
111
+ const init = prop.getInitializer();
112
+ if (
113
+ init !== undefined &&
114
+ (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
115
+ ) {
116
+ return { value: init.getLiteralText(), node: init };
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ function lineInfo(
122
+ text: string,
123
+ offset: number,
124
+ ): { line: number; col: number; snippet: string } {
125
+ const before = text.slice(0, offset);
126
+ const line = before.split('\n').length;
127
+ const lineStart = before.lastIndexOf('\n') + 1;
128
+ const lineEnd = text.indexOf('\n', offset);
129
+ const snippet = text.slice(lineStart, lineEnd === -1 ? undefined : lineEnd);
130
+ return { line, col: offset - lineStart + 1, snippet: snippet.trim() };
131
+ }
132
+
133
+ /** Input/output names under which a member can be bound from a parent template. */
134
+ function bindingNamesOf(member: Node): string[] {
135
+ const names: string[] = [];
136
+ const name = Node.hasName(member) ? member.getName() : undefined;
137
+
138
+ if (Node.isDecoratable(member)) {
139
+ for (const decorator of member.getDecorators()) {
140
+ const decoratorName = decorator.getName();
141
+ if (decoratorName !== 'Input' && decoratorName !== 'Output') {
142
+ continue;
143
+ }
144
+ const alias = decorator.getArguments()[0];
145
+ const aliasText =
146
+ alias !== undefined && Node.isStringLiteral(alias)
147
+ ? alias.getLiteralText()
148
+ : undefined;
149
+ names.push(aliasText ?? name ?? '');
150
+ }
151
+ }
152
+
153
+ if (Node.isPropertyDeclaration(member)) {
154
+ const init = member.getInitializer();
155
+ const callee =
156
+ init !== undefined && Node.isCallExpression(init)
157
+ ? init.getExpression().getText().split('.')[0]
158
+ : undefined;
159
+ if (callee !== undefined && SIGNAL_BINDING_FACTORIES.has(callee)) {
160
+ names.push(name ?? '');
161
+ if (callee === 'model' && name !== undefined) {
162
+ names.push(`${name}Change`);
163
+ }
164
+ }
165
+ }
166
+
167
+ return names.filter((n) => n !== '');
168
+ }
169
+
170
+ function inheritsFrom(cls: ClassDeclaration, owners: Set<ClassDeclaration>): boolean {
171
+ const seen = new Set<ClassDeclaration>();
172
+ let current: ClassDeclaration | undefined = cls;
173
+ while (current !== undefined && !seen.has(current)) {
174
+ if (owners.has(current)) {
175
+ return true;
176
+ }
177
+ seen.add(current);
178
+ current = current.getBaseClass();
179
+ }
180
+ return false;
181
+ }
182
+
183
+ /**
184
+ * Finds usages of a symbol inside Angular component templates.
185
+ */
186
+ export class TemplateUsageFinder {
187
+ constructor(private readonly project: Project) {}
188
+
189
+ /**
190
+ * @param symbol - Searched name (class, member, or pipe class name).
191
+ * @param declarations - Declaration nodes named `symbol` (as found by `UsageFinder`).
192
+ * @returns Template usages, unsorted.
193
+ */
194
+ findTemplateUsages(symbol: string, declarations: Node[]): TemplateSymbolUsage[] {
195
+ const selectorTargets: string[] = [];
196
+ const pipeNames: string[] = [];
197
+ const members: MemberTarget[] = [];
198
+
199
+ for (const decl of declarations) {
200
+ if (Node.isClassDeclaration(decl)) {
201
+ const directive = findDecorator(decl, DIRECTIVE_DECORATORS);
202
+ const selector =
203
+ directive === undefined
204
+ ? undefined
205
+ : readDecoratorString(directive, 'selector')?.value;
206
+ if (selector !== undefined) {
207
+ selectorTargets.push(selector);
208
+ }
209
+ const pipe = findDecorator(decl, new Set(['Pipe']));
210
+ const pipeName =
211
+ pipe === undefined ? undefined : readDecoratorString(pipe, 'name')?.value;
212
+ if (pipeName !== undefined) {
213
+ pipeNames.push(pipeName);
214
+ }
215
+ continue;
216
+ }
217
+
218
+ // Parameter properties live in the constructor; other members directly in the class.
219
+ const ownerClass = Node.isParameterDeclaration(decl)
220
+ ? decl.getParent().getParent()
221
+ : decl.getParent();
222
+ const isMember =
223
+ ownerClass !== undefined &&
224
+ Node.isClassDeclaration(ownerClass) &&
225
+ (Node.isMethodDeclaration(decl) ||
226
+ Node.isPropertyDeclaration(decl) ||
227
+ Node.isGetAccessorDeclaration(decl) ||
228
+ Node.isSetAccessorDeclaration(decl) ||
229
+ (Node.isParameterDeclaration(decl) && decl.isParameterProperty()));
230
+ if (isMember && Node.isClassDeclaration(ownerClass)) {
231
+ members.push({ owner: ownerClass, bindingNames: bindingNamesOf(decl) });
232
+ }
233
+ }
234
+
235
+ const hasClassTarget = selectorTargets.length > 0 || pipeNames.length > 0;
236
+ const scanMembers = members.length > 0 || declarations.length === 0;
237
+ if (!hasClassTarget && !scanMembers) {
238
+ return [];
239
+ }
240
+
241
+ const queries = this.buildQueries(symbol, selectorTargets, pipeNames, members, scanMembers);
242
+ const memberOwners = new Set(members.map((m) => m.owner));
243
+ const usages: TemplateSymbolUsage[] = [];
244
+
245
+ for (const template of this.collectTemplates()) {
246
+ for (const query of queries) {
247
+ for (const hit of scanTemplate(template.content, query)) {
248
+ usages.push(this.toUsage(template, hit, memberOwners));
249
+ }
250
+ }
251
+ }
252
+
253
+ return usages;
254
+ }
255
+
256
+ private buildQueries(
257
+ symbol: string,
258
+ selectors: string[],
259
+ pipeNames: string[],
260
+ members: MemberTarget[],
261
+ scanMembers: boolean,
262
+ ): TemplateQuery[] {
263
+ const queries: TemplateQuery[] = [];
264
+ for (const selector of selectors) {
265
+ queries.push({ selector });
266
+ }
267
+ for (const pipeName of pipeNames) {
268
+ queries.push({ pipeName });
269
+ }
270
+ if (scanMembers) {
271
+ queries.push({ memberName: symbol });
272
+ }
273
+ for (const member of members) {
274
+ const directive = findDecorator(member.owner, DIRECTIVE_DECORATORS);
275
+ const bindingSelector =
276
+ directive === undefined
277
+ ? undefined
278
+ : readDecoratorString(directive, 'selector')?.value;
279
+ if (bindingSelector === undefined) {
280
+ continue;
281
+ }
282
+ for (const bindingName of member.bindingNames) {
283
+ queries.push({ bindingName, bindingSelector });
284
+ }
285
+ }
286
+ return queries;
287
+ }
288
+
289
+ private toUsage(
290
+ template: ComponentTemplate,
291
+ hit: TemplateHit,
292
+ memberOwners: Set<ClassDeclaration>,
293
+ ): TemplateSymbolUsage {
294
+ const position = template.locate(hit.offset);
295
+ const isMemberHit = hit.implicitReceiver !== undefined;
296
+ const verified =
297
+ !isMemberHit ||
298
+ (hit.implicitReceiver === true && inheritsFrom(template.owner, memberOwners));
299
+ const component = template.owner.getName() ?? '<anonymous>';
300
+
301
+ return {
302
+ file: template.file,
303
+ line: position.line,
304
+ col: position.col,
305
+ snippet: position.snippet,
306
+ templateKind: hit.kind,
307
+ templateMatch: verified ? 'verified' : 'template-unverified',
308
+ component,
309
+ ...(verified
310
+ ? {}
311
+ : {
312
+ note:
313
+ hit.implicitReceiver === true
314
+ ? `Same-name member used in the template of ${component}, which does not declare or inherit the searched member.`
315
+ : `Same-name property accessed on another object ('x.${hit.name}') in the template of ${component}; receiver type not checked.`,
316
+ }),
317
+ };
318
+ }
319
+
320
+ /** All component templates in the project (inline and external). */
321
+ private collectTemplates(): ComponentTemplate[] {
322
+ const templates: ComponentTemplate[] = [];
323
+ for (const sf of this.project.getSourceFiles()) {
324
+ if (sf.isDeclarationFile() || sf.isInNodeModules()) {
325
+ continue;
326
+ }
327
+ for (const cls of sf.getClasses()) {
328
+ const component = findDecorator(cls, COMPONENT_DECORATORS);
329
+ if (component === undefined) {
330
+ continue;
331
+ }
332
+ const template = this.loadTemplate(sf, cls, component);
333
+ if (template !== undefined) {
334
+ templates.push(template);
335
+ }
336
+ }
337
+ }
338
+ return templates;
339
+ }
340
+
341
+ private loadTemplate(
342
+ sf: SourceFile,
343
+ owner: ClassDeclaration,
344
+ decorator: Decorator,
345
+ ): ComponentTemplate | undefined {
346
+ const inline = readDecoratorString(decorator, 'template');
347
+ if (inline !== undefined) {
348
+ // Raw text between the delimiters keeps offsets aligned with the .ts file.
349
+ const literalStart = inline.node.getStart() + 1;
350
+ const content = inline.node.getText().slice(1, -1);
351
+ return {
352
+ owner,
353
+ file: sf.getFilePath(),
354
+ content,
355
+ locate: (offset) => {
356
+ const pos = literalStart + offset;
357
+ const { line, column } = sf.getLineAndColumnAtPos(pos);
358
+ const snippet = sf.getFullText().split('\n')[line - 1] ?? '';
359
+ return { line, col: column, snippet: snippet.trim() };
360
+ },
361
+ };
362
+ }
363
+
364
+ const templateUrl = readDecoratorString(decorator, 'templateUrl');
365
+ if (templateUrl === undefined) {
366
+ return undefined;
367
+ }
368
+ const file = resolve(dirname(sf.getFilePath()), templateUrl.value).replaceAll(
369
+ '\\',
370
+ '/',
371
+ );
372
+ const fs = this.project.getFileSystem();
373
+ let content: string;
374
+ try {
375
+ if (!fs.fileExistsSync(file)) {
376
+ return undefined;
377
+ }
378
+ content = fs.readFileSync(file);
379
+ } catch {
380
+ return undefined;
381
+ }
382
+ return {
383
+ owner,
384
+ file,
385
+ content,
386
+ locate: (offset) => lineInfo(content, offset),
387
+ };
388
+ }
389
+ }