impact-analysis 1.0.6 → 2.0.1
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/README.md +112 -15
- package/index.mjs +180 -184
- package/package.json +23 -15
- package/src/ai/explain.ts +29 -0
- package/src/cache/graphCache.ts +49 -0
- package/src/cli.ts +110 -0
- package/src/core/analyzer.ts +28 -0
- package/src/git/getChangedFiles.ts +15 -0
- package/src/graph/buildGraph.ts +151 -0
- package/src/graph/types.ts +5 -0
- package/src/parser/parseJS.ts +69 -0
- package/src/parser/parseVue.ts +23 -0
- package/src/parser/parseVueTemplate.ts +16 -0
- package/src/report/html-generator.ts +343 -0
- package/src/scanner/scanRepo.ts +25 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { DependencyGraph } from "../graph/types";
|
|
3
|
+
|
|
4
|
+
const CACHE_FILE = ".impact-analysis-cache.json";
|
|
5
|
+
|
|
6
|
+
export function loadCache(): DependencyGraph | null {
|
|
7
|
+
try {
|
|
8
|
+
if (!fs.existsSync(CACHE_FILE)) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const data = JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8"));
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
imports: new Map(
|
|
16
|
+
Object.entries(data.imports).map(([key, value]) => [key, new Set(value as string[])])
|
|
17
|
+
),
|
|
18
|
+
importedBy: new Map(
|
|
19
|
+
Object.entries(data.importedBy).map(([key, value]) => [key, new Set(value as string[])])
|
|
20
|
+
),
|
|
21
|
+
componentUsage: new Map(
|
|
22
|
+
Object.entries(data.componentUsage).map(([key, value]) => [key, new Set(value as string[])])
|
|
23
|
+
)
|
|
24
|
+
};
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.warn('Cache file corrupted, rebuilding...');
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function saveCache(data: DependencyGraph) {
|
|
32
|
+
try {
|
|
33
|
+
const serializable = {
|
|
34
|
+
imports: Object.fromEntries(
|
|
35
|
+
Array.from(data.imports.entries()).map(([key, value]) => [key, Array.from(value)])
|
|
36
|
+
),
|
|
37
|
+
importedBy: Object.fromEntries(
|
|
38
|
+
Array.from(data.importedBy.entries()).map(([key, value]) => [key, Array.from(value)])
|
|
39
|
+
),
|
|
40
|
+
componentUsage: Object.fromEntries(
|
|
41
|
+
Array.from(data.componentUsage.entries()).map(([key, value]) => [key, Array.from(value)])
|
|
42
|
+
)
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
fs.writeFileSync(CACHE_FILE, JSON.stringify(serializable, null, 2));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.warn('Failed to save cache:', error instanceof Error ? error.message : String(error));
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import 'dotenv/config';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { exec } from 'child_process';
|
|
5
|
+
import { promisify } from 'util';
|
|
6
|
+
import { getChangedFiles } from './git/getChangedFiles';
|
|
7
|
+
import { scanRepo } from './scanner/scanRepo';
|
|
8
|
+
import { buildGraph } from './graph/buildGraph';
|
|
9
|
+
import { analyzeImpact } from './core/analyzer';
|
|
10
|
+
import { generateHtmlReport } from './report/html-generator';
|
|
11
|
+
import { explainImpact } from './ai/explain';
|
|
12
|
+
|
|
13
|
+
const execAsync = promisify(exec);
|
|
14
|
+
|
|
15
|
+
async function main() {
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
const showHtml = args.includes('--html');
|
|
18
|
+
const useAI = args.includes('--ai');
|
|
19
|
+
const clearCache = args.includes('--clear-cache');
|
|
20
|
+
const baseBranch = args.find(arg => !arg.startsWith('--')) || 'main';
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
if (clearCache) {
|
|
24
|
+
const fs = await import('fs');
|
|
25
|
+
if (fs.existsSync('.impact-analysis-cache.json')) {
|
|
26
|
+
fs.unlinkSync('.impact-analysis-cache.json');
|
|
27
|
+
console.log('Cache cleared.');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.log('Scanning repository...');
|
|
32
|
+
const allFiles = await scanRepo();
|
|
33
|
+
|
|
34
|
+
console.log(`Building dependency graph from ${allFiles.length} files...`);
|
|
35
|
+
const graph = await buildGraph(allFiles);
|
|
36
|
+
|
|
37
|
+
console.log(`Getting changed files against ${baseBranch}...`);
|
|
38
|
+
const changedFiles = await getChangedFiles(baseBranch);
|
|
39
|
+
|
|
40
|
+
if (changedFiles.length === 0) {
|
|
41
|
+
console.log('No changed files found.');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log(`Found ${changedFiles.length} changed files. Analyzing impact...`);
|
|
46
|
+
|
|
47
|
+
const dependencyMap: Record<string, string[]> = {};
|
|
48
|
+
graph.importedBy.forEach((importers, file) => {
|
|
49
|
+
dependencyMap[file] = Array.from(importers);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const results = analyzeImpact(changedFiles, dependencyMap);
|
|
53
|
+
|
|
54
|
+
if (useAI) {
|
|
55
|
+
try {
|
|
56
|
+
console.log('Getting AI explanation...');
|
|
57
|
+
const explanation = await explainImpact(results);
|
|
58
|
+
if (explanation) {
|
|
59
|
+
(results as any).aiExplanation = explanation;
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.warn('AI explanation failed:', error instanceof Error ? error.message : String(error));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log('\n--- Impact Analysis Results ---\n');
|
|
67
|
+
results.forEach((result, index) => {
|
|
68
|
+
console.log(`${index + 1}. ${path.basename(result.changedFile)}`);
|
|
69
|
+
console.log(` Risk: ${result.risk}`);
|
|
70
|
+
console.log(` Impacted files: ${result.impactedFiles.length}`);
|
|
71
|
+
if (result.impactedFiles.length > 0) {
|
|
72
|
+
result.impactedFiles.slice(0, 5).forEach(file => {
|
|
73
|
+
console.log(` - ${path.basename(file)}`);
|
|
74
|
+
});
|
|
75
|
+
if (result.impactedFiles.length > 5) {
|
|
76
|
+
console.log(` ... and ${result.impactedFiles.length - 5} more`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
console.log('');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Generate HTML by default
|
|
83
|
+
console.log('Generating HTML report...');
|
|
84
|
+
const htmlPath = generateHtmlReport(results);
|
|
85
|
+
console.log(`Report saved: ${htmlPath}`);
|
|
86
|
+
|
|
87
|
+
if (showHtml) {
|
|
88
|
+
try {
|
|
89
|
+
// Use platform-specific command to open the file
|
|
90
|
+
const command = process.platform === 'win32'
|
|
91
|
+
? `start "" "${htmlPath}"`
|
|
92
|
+
: process.platform === 'darwin'
|
|
93
|
+
? `open "${htmlPath}"`
|
|
94
|
+
: `xdg-open "${htmlPath}"`;
|
|
95
|
+
|
|
96
|
+
await execAsync(command);
|
|
97
|
+
console.log(`Report opened in browser`);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.log(`Could not auto-open browser. Please open: ${htmlPath}`);
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
console.log(`\nTo view the graphical report, run: impact-analysis --html`);
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error('Error:', error instanceof Error ? error.message : String(error));
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
main();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
export interface ImpactResult {
|
|
4
|
+
changedFile: string;
|
|
5
|
+
impactedFiles: string[];
|
|
6
|
+
risk: 'LOW' | 'MEDIUM' | 'HIGH';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function analyzeImpact(
|
|
10
|
+
changedFiles: string[],
|
|
11
|
+
dependencyMap: Record<string, string[]>
|
|
12
|
+
): ImpactResult[] {
|
|
13
|
+
return changedFiles.map(changed => {
|
|
14
|
+
const impacted = Object.entries(dependencyMap)
|
|
15
|
+
.filter(([, deps]) => deps.includes(changed))
|
|
16
|
+
.map(([file]) => file);
|
|
17
|
+
|
|
18
|
+
let risk: ImpactResult['risk'] = 'LOW';
|
|
19
|
+
if (impacted.length > 5) risk = 'HIGH';
|
|
20
|
+
else if (impacted.length > 2) risk = 'MEDIUM';
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
changedFile: changed,
|
|
24
|
+
impactedFiles: impacted,
|
|
25
|
+
risk
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import simpleGit from "simple-git";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
export async function getChangedFiles(base: string): Promise<string[]> {
|
|
5
|
+
try {
|
|
6
|
+
const git = simpleGit();
|
|
7
|
+
const diff = await git.diff(["--name-only", base]);
|
|
8
|
+
return diff
|
|
9
|
+
.split("\n")
|
|
10
|
+
.filter(f => /\.(js|jsx|ts|tsx|vue)$/.test(f))
|
|
11
|
+
.map(f => path.resolve(process.cwd(), f));
|
|
12
|
+
} catch (error) {
|
|
13
|
+
throw new Error(`Failed to get changed files. Make sure Git is installed and you're in a Git repository. Base branch: ${base}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { extractImports } from "../parser/parseJS";
|
|
4
|
+
import { parseVue } from "../parser/parseVue";
|
|
5
|
+
import { DependencyGraph } from "./types";
|
|
6
|
+
import { loadCache, saveCache } from "../cache/graphCache";
|
|
7
|
+
|
|
8
|
+
function resolveImportPath(importPath: string, fromFile: string, allFiles: string[]): string | null {
|
|
9
|
+
if (
|
|
10
|
+
(!importPath.startsWith('.') && !importPath.startsWith('/') &&
|
|
11
|
+
!importPath.startsWith('~') && !importPath.startsWith('@') &&
|
|
12
|
+
!importPath.startsWith('#')) ||
|
|
13
|
+
importPath.startsWith('virtual:') ||
|
|
14
|
+
importPath.startsWith('~icons/') ||
|
|
15
|
+
importPath.startsWith('unplugin-') ||
|
|
16
|
+
/\.(css|scss|sass|less|styl|stylus|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)$/.test(importPath)
|
|
17
|
+
) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const baseDir = path.dirname(fromFile);
|
|
22
|
+
const projectRoot = process.cwd();
|
|
23
|
+
const extensions = ['.vue', '.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', ''];
|
|
24
|
+
const indexFiles = ['/index.vue', '/index.js', '/index.ts', '/index.jsx', '/index.tsx', '/index.mjs'];
|
|
25
|
+
|
|
26
|
+
let resolvedBase: string | null = null;
|
|
27
|
+
|
|
28
|
+
// Handle various path alias patterns
|
|
29
|
+
if (importPath.startsWith('~/') || importPath.startsWith('@/')) {
|
|
30
|
+
// Common aliases: ~/ or @/ → project root
|
|
31
|
+
resolvedBase = path.join(projectRoot, importPath.substring(2));
|
|
32
|
+
} else if (importPath.startsWith('#')) {
|
|
33
|
+
// Nuxt 3 aliases: #app, #imports, #components
|
|
34
|
+
// Map to common locations
|
|
35
|
+
if (importPath.startsWith('#app') || importPath.startsWith('#imports')) {
|
|
36
|
+
return null; // Skip Nuxt internals
|
|
37
|
+
}
|
|
38
|
+
if (importPath.startsWith('#components/')) {
|
|
39
|
+
resolvedBase = path.join(projectRoot, 'components', importPath.substring(12));
|
|
40
|
+
} else {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
} else if (importPath.startsWith('/')) {
|
|
44
|
+
// Absolute path from root
|
|
45
|
+
resolvedBase = path.join(projectRoot, importPath.substring(1));
|
|
46
|
+
} else if (importPath.startsWith('.')) {
|
|
47
|
+
// Relative path
|
|
48
|
+
resolvedBase = path.join(baseDir, importPath);
|
|
49
|
+
} else {
|
|
50
|
+
// Could be a tsconfig path alias (e.g., @components/...)
|
|
51
|
+
// Try common patterns
|
|
52
|
+
const aliasPatterns = [
|
|
53
|
+
['@components/', 'components/'],
|
|
54
|
+
['@utils/', 'utils/'],
|
|
55
|
+
['@lib/', 'lib/'],
|
|
56
|
+
['@src/', 'src/'],
|
|
57
|
+
['@/', ''],
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
for (const [alias, replacement] of aliasPatterns) {
|
|
61
|
+
if (importPath.startsWith(alias)) {
|
|
62
|
+
resolvedBase = path.join(projectRoot, importPath.replace(alias, replacement));
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!resolvedBase) {
|
|
68
|
+
return null; // Likely an external package
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Normalize path separators
|
|
73
|
+
resolvedBase = path.normalize(resolvedBase);
|
|
74
|
+
|
|
75
|
+
const hasExtension = extensions.some(ext => ext && importPath.endsWith(ext));
|
|
76
|
+
|
|
77
|
+
for (const ext of extensions) {
|
|
78
|
+
const withExt = hasExtension ? resolvedBase : resolvedBase + ext;
|
|
79
|
+
|
|
80
|
+
if (allFiles.includes(withExt)) {
|
|
81
|
+
return withExt;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const indexFile of indexFiles) {
|
|
86
|
+
const resolved = resolvedBase + indexFile;
|
|
87
|
+
if (allFiles.includes(resolved)) {
|
|
88
|
+
return resolved;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function buildGraph(files: string[]): Promise<DependencyGraph> {
|
|
96
|
+
const cached = loadCache();
|
|
97
|
+
if (cached) {
|
|
98
|
+
console.log('Using cached dependency graph...');
|
|
99
|
+
return cached;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const graph: DependencyGraph = {
|
|
103
|
+
imports: new Map(),
|
|
104
|
+
importedBy: new Map(),
|
|
105
|
+
componentUsage: new Map()
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
let filesProcessed = 0;
|
|
109
|
+
|
|
110
|
+
for (const file of files) {
|
|
111
|
+
const code = fs.readFileSync(file, "utf-8");
|
|
112
|
+
|
|
113
|
+
if (file.endsWith(".vue")) {
|
|
114
|
+
const { imports, components } = parseVue(code);
|
|
115
|
+
|
|
116
|
+
imports.forEach(imp => {
|
|
117
|
+
const resolved = resolveImportPath(imp, file, files);
|
|
118
|
+
if (resolved) {
|
|
119
|
+
graph.imports.set(file, (graph.imports.get(file) || new Set()).add(resolved));
|
|
120
|
+
graph.importedBy.set(resolved, (graph.importedBy.get(resolved) || new Set()).add(file));
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
components.forEach(component => {
|
|
125
|
+
graph.componentUsage.set(
|
|
126
|
+
component,
|
|
127
|
+
(graph.componentUsage.get(component) || new Set()).add(file)
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
} else {
|
|
131
|
+
const imports = extractImports(code);
|
|
132
|
+
|
|
133
|
+
imports.forEach(imp => {
|
|
134
|
+
const resolved = resolveImportPath(imp, file, files);
|
|
135
|
+
if (resolved) {
|
|
136
|
+
graph.imports.set(file, (graph.imports.get(file) || new Set()).add(resolved));
|
|
137
|
+
graph.importedBy.set(resolved, (graph.importedBy.get(resolved) || new Set()).add(file));
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
filesProcessed++;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log(`Dependency graph built: ${graph.importedBy.size} files with dependencies`);
|
|
146
|
+
|
|
147
|
+
saveCache(graph);
|
|
148
|
+
|
|
149
|
+
return graph;
|
|
150
|
+
}
|
|
151
|
+
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { parse } from "@babel/parser";
|
|
2
|
+
import traverse from "@babel/traverse";
|
|
3
|
+
|
|
4
|
+
export function extractImports(code: string): string[] {
|
|
5
|
+
const imports: string[] = [];
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const ast = parse(code, {
|
|
9
|
+
sourceType: "unambiguous",
|
|
10
|
+
plugins: [
|
|
11
|
+
"typescript",
|
|
12
|
+
"jsx",
|
|
13
|
+
"decorators-legacy",
|
|
14
|
+
"classProperties",
|
|
15
|
+
"dynamicImport",
|
|
16
|
+
"importMeta",
|
|
17
|
+
"topLevelAwait",
|
|
18
|
+
"classStaticBlock",
|
|
19
|
+
"optionalChaining",
|
|
20
|
+
"nullishCoalescingOperator"
|
|
21
|
+
],
|
|
22
|
+
errorRecovery: true
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
traverse(ast as any, {
|
|
26
|
+
// Static imports: import x from 'module'
|
|
27
|
+
ImportDeclaration(path) {
|
|
28
|
+
imports.push(path.node.source.value);
|
|
29
|
+
},
|
|
30
|
+
// Dynamic imports: import('module')
|
|
31
|
+
Import(path) {
|
|
32
|
+
const parent = path.parent;
|
|
33
|
+
if (parent.type === 'CallExpression' && parent.arguments[0]) {
|
|
34
|
+
const arg = parent.arguments[0];
|
|
35
|
+
if (arg.type === 'StringLiteral') {
|
|
36
|
+
imports.push(arg.value);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
// Export from: export { x } from 'module'
|
|
41
|
+
ExportNamedDeclaration(path) {
|
|
42
|
+
if (path.node.source) {
|
|
43
|
+
imports.push(path.node.source.value);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
// Export all: export * from 'module'
|
|
47
|
+
ExportAllDeclaration(path) {
|
|
48
|
+
imports.push(path.node.source.value);
|
|
49
|
+
},
|
|
50
|
+
// CommonJS require: const x = require('module')
|
|
51
|
+
CallExpression(path) {
|
|
52
|
+
if (
|
|
53
|
+
path.node.callee.type === 'Identifier' &&
|
|
54
|
+
path.node.callee.name === 'require' &&
|
|
55
|
+
path.node.arguments[0] &&
|
|
56
|
+
path.node.arguments[0].type === 'StringLiteral'
|
|
57
|
+
) {
|
|
58
|
+
imports.push(path.node.arguments[0].value);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
} catch (error) {
|
|
63
|
+
// Silently skip files with parse errors
|
|
64
|
+
// Most errors are from node_modules or generated files
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Remove duplicates
|
|
68
|
+
return [...new Set(imports)];
|
|
69
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { parse as parseSFC } from "@vue/compiler-sfc";
|
|
2
|
+
import { extractImports } from "./parseJS";
|
|
3
|
+
import { extractTemplateComponents } from "./parseVueTemplate";
|
|
4
|
+
|
|
5
|
+
export function parseVue(code: string) {
|
|
6
|
+
try {
|
|
7
|
+
const { descriptor } = parseSFC(code);
|
|
8
|
+
|
|
9
|
+
const scriptContent = descriptor.script?.content || '';
|
|
10
|
+
const scriptSetupContent = descriptor.scriptSetup?.content || '';
|
|
11
|
+
const allScript = scriptContent + '\n' + scriptSetupContent;
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
imports: allScript.trim() ? extractImports(allScript) : [],
|
|
15
|
+
components: extractTemplateComponents(code)
|
|
16
|
+
};
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return {
|
|
19
|
+
imports: [],
|
|
20
|
+
components: []
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { parse as parseSFC } from "@vue/compiler-sfc";
|
|
2
|
+
|
|
3
|
+
export function extractTemplateComponents(code: string): string[] {
|
|
4
|
+
const { descriptor } = parseSFC(code);
|
|
5
|
+
const template = descriptor.template?.content || "";
|
|
6
|
+
|
|
7
|
+
const componentRegex = /<([A-Z][\w-]*)/g;
|
|
8
|
+
const components = new Set<string>();
|
|
9
|
+
|
|
10
|
+
let match;
|
|
11
|
+
while ((match = componentRegex.exec(template))) {
|
|
12
|
+
components.add(match[1]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return Array.from(components);
|
|
16
|
+
}
|