@grafana/react-detect 0.1.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/LICENSE +201 -0
- package/README.md +23 -0
- package/dist/analyzer.js +37 -0
- package/dist/bin/run.js +12 -0
- package/dist/commands/detect19.js +41 -0
- package/dist/file-scanner.js +20 -0
- package/dist/libs/output/src/index.js +132 -0
- package/dist/parser.js +23 -0
- package/dist/patterns/definitions.js +119 -0
- package/dist/patterns/matcher.js +146 -0
- package/dist/reporters/console.js +132 -0
- package/dist/reporters/json.js +11 -0
- package/dist/results.js +128 -0
- package/dist/source-extractor.js +80 -0
- package/dist/utils/analyzer.js +88 -0
- package/dist/utils/ast.js +20 -0
- package/dist/utils/dependencies.js +97 -0
- package/dist/utils/output.js +5 -0
- package/dist/utils/plugin.js +36 -0
- package/package.json +42 -0
- package/src/analyzer.test.ts +14 -0
- package/src/analyzer.ts +42 -0
- package/src/bin/run.ts +17 -0
- package/src/commands/detect19.ts +53 -0
- package/src/file-scanner.ts +19 -0
- package/src/parser.ts +22 -0
- package/src/patterns/definitions.ts +125 -0
- package/src/patterns/matcher.test.ts +221 -0
- package/src/patterns/matcher.ts +268 -0
- package/src/reporters/console.ts +139 -0
- package/src/reporters/json.ts +13 -0
- package/src/results.ts +170 -0
- package/src/source-extractor.ts +101 -0
- package/src/types/patterns.ts +14 -0
- package/src/types/plugins.ts +6 -0
- package/src/types/processors.ts +40 -0
- package/src/types/reporters.ts +53 -0
- package/src/utils/analyzer.test.ts +190 -0
- package/src/utils/analyzer.ts +120 -0
- package/src/utils/ast.ts +19 -0
- package/src/utils/dependencies.test.ts +123 -0
- package/src/utils/dependencies.ts +141 -0
- package/src/utils/output.ts +3 -0
- package/src/utils/plugin.ts +72 -0
- package/test/fixtures/dependencies/package-lock.json +49 -0
- package/test/fixtures/dependencies/package.json +16 -0
- package/test/fixtures/patterns/module.js.map +1 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +12 -0
package/src/results.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { AnalyzedMatch } from './types/processors.js';
|
|
2
|
+
import { PluginAnalysisResults, AnalysisResult, DependencyIssue } from './types/reporters.js';
|
|
3
|
+
import { getPattern } from './patterns/definitions.js';
|
|
4
|
+
import { getPluginJson } from './utils/plugin.js';
|
|
5
|
+
import { DependencyContext } from './utils/dependencies.js';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
export function generateAnalysisResults(
|
|
9
|
+
matches: AnalyzedMatch[],
|
|
10
|
+
pluginRoot: string,
|
|
11
|
+
depContext: DependencyContext
|
|
12
|
+
): PluginAnalysisResults {
|
|
13
|
+
const filtered = filterMatches(matches);
|
|
14
|
+
const pluginJson = getPluginJson(pluginRoot);
|
|
15
|
+
const sourceMatches = filtered.filter((m) => m.type === 'source');
|
|
16
|
+
const dependencyMatches = filtered.filter((m) => m.type === 'dependency');
|
|
17
|
+
|
|
18
|
+
const criticalMatches = filtered.filter((m) => {
|
|
19
|
+
const pattern = getPattern(m.pattern);
|
|
20
|
+
return pattern?.impactLevel === 'critical';
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const warningMatches = filtered.filter((m) => {
|
|
24
|
+
const pattern = getPattern(m.pattern);
|
|
25
|
+
return pattern?.impactLevel === 'warning';
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const critical = criticalMatches.map((m) => generateResult(m));
|
|
29
|
+
const warnings = warningMatches.map((m) => generateResult(m));
|
|
30
|
+
const dependencies = buildDependencyIssues(dependencyMatches, depContext);
|
|
31
|
+
|
|
32
|
+
const totalIssues = filtered.length;
|
|
33
|
+
const affectedDeps = new Set(
|
|
34
|
+
dependencyMatches.map((m) => m.packageName).filter((name): name is string => name !== undefined)
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
plugin: {
|
|
39
|
+
id: pluginJson?.id || '',
|
|
40
|
+
name: pluginJson?.name || '',
|
|
41
|
+
version: pluginJson?.info.version || '',
|
|
42
|
+
type: pluginJson?.type || '',
|
|
43
|
+
},
|
|
44
|
+
summary: {
|
|
45
|
+
totalIssues,
|
|
46
|
+
critical: critical.length,
|
|
47
|
+
warnings: warnings.length,
|
|
48
|
+
sourceIssuesCount: sourceMatches.length,
|
|
49
|
+
dependencyIssuesCount: dependencyMatches.length,
|
|
50
|
+
status: totalIssues > 0 ? 'action_required' : 'no_action_required',
|
|
51
|
+
affectedDependencies: Array.from(affectedDeps),
|
|
52
|
+
},
|
|
53
|
+
issues: {
|
|
54
|
+
critical,
|
|
55
|
+
warnings,
|
|
56
|
+
dependencies,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function filterMatches(matches: AnalyzedMatch[]): AnalyzedMatch[] {
|
|
62
|
+
const filtered = matches.filter((match) => {
|
|
63
|
+
// TODO: add mode for strict / loose filtering
|
|
64
|
+
if (match.type === 'source' && (match.confidence === 'none' || match.confidence === 'unknown')) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (match.type === 'source') {
|
|
69
|
+
const pattern = getPattern(match.pattern);
|
|
70
|
+
// defaultProps are allowed on class components in React 19.
|
|
71
|
+
if (pattern?.functionComponentOnly && match.componentType !== 'function') {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Don't report react internals from React's JSX transform. We only care about other dependencies
|
|
77
|
+
// relying on React internals.
|
|
78
|
+
if (
|
|
79
|
+
match.type === 'dependency' &&
|
|
80
|
+
match.pattern === '__SECRET_INTERNALS' &&
|
|
81
|
+
match.packageName === 'react' &&
|
|
82
|
+
(match.sourceFile.includes('jsx-runtime') || match.sourceFile.includes('jsx-dev-runtime'))
|
|
83
|
+
) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return filtered;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function generateResult(match: AnalyzedMatch): AnalysisResult {
|
|
94
|
+
const pattern = getPattern(match.pattern);
|
|
95
|
+
|
|
96
|
+
if (!pattern) {
|
|
97
|
+
throw new Error(`Pattern not found: ${match.pattern}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const cleanFilePath = cleanSourceFilePath(match.sourceFile);
|
|
101
|
+
|
|
102
|
+
const result: AnalysisResult = {
|
|
103
|
+
pattern: match.pattern,
|
|
104
|
+
severity: pattern.severity,
|
|
105
|
+
impactLevel: pattern.impactLevel,
|
|
106
|
+
location: {
|
|
107
|
+
type: match.type,
|
|
108
|
+
file: path.join(process.cwd(), cleanFilePath),
|
|
109
|
+
line: match.sourceLine,
|
|
110
|
+
column: match.sourceColumn,
|
|
111
|
+
},
|
|
112
|
+
problem: pattern.description,
|
|
113
|
+
fix: {
|
|
114
|
+
description: pattern.fix?.description || '',
|
|
115
|
+
before: pattern.fix?.before || '',
|
|
116
|
+
after: pattern.fix?.after || '',
|
|
117
|
+
},
|
|
118
|
+
link: pattern.link || '',
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Add dependency-specific fields
|
|
122
|
+
if (match.type === 'dependency') {
|
|
123
|
+
result.packageName = match.packageName;
|
|
124
|
+
result.rootDependency = match.rootDependency;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build DependencyIssue objects grouped by package
|
|
132
|
+
*/
|
|
133
|
+
function buildDependencyIssues(dependencyMatches: AnalyzedMatch[], depContext: DependencyContext): DependencyIssue[] {
|
|
134
|
+
// Group by package
|
|
135
|
+
const byPackage = new Map<string, AnalyzedMatch[]>();
|
|
136
|
+
for (const match of dependencyMatches) {
|
|
137
|
+
if (match.type === 'dependency' && match.packageName) {
|
|
138
|
+
const existing = byPackage.get(match.packageName) || [];
|
|
139
|
+
existing.push(match);
|
|
140
|
+
byPackage.set(match.packageName, existing);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Build DependencyIssue for each package
|
|
145
|
+
const issues: DependencyIssue[] = [];
|
|
146
|
+
for (const [packageName, matches] of byPackage) {
|
|
147
|
+
const rootDep = matches[0].rootDependency || packageName;
|
|
148
|
+
const version = depContext.getVersion(packageName) || null;
|
|
149
|
+
|
|
150
|
+
issues.push({
|
|
151
|
+
packageName,
|
|
152
|
+
version: version || 'unknown',
|
|
153
|
+
rootDependency: rootDep,
|
|
154
|
+
issues: matches.map((m) => generateResult(m)),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return issues;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function cleanSourceFilePath(filePath: string): string {
|
|
162
|
+
let cleanPath = filePath;
|
|
163
|
+
if (filePath.includes('node_modules')) {
|
|
164
|
+
cleanPath = filePath.replace(/webpack:\/\/[^/]+\/node_modules\//, './node_modules/');
|
|
165
|
+
} else {
|
|
166
|
+
cleanPath = filePath.replace(/webpack:\/\/[^/]+/, './src/');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return cleanPath;
|
|
170
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { SourceMapConsumer } from 'source-map';
|
|
2
|
+
import { SourceFile, SourceFileType } from './types/processors.js';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
export async function extractSourcesFromMap(sourcemapFilePath: string): Promise<SourceFile[]> {
|
|
6
|
+
const mapContent = await readFile(sourcemapFilePath, 'utf8');
|
|
7
|
+
const sourceMap = JSON.parse(mapContent);
|
|
8
|
+
const consumer = await new SourceMapConsumer(sourceMap);
|
|
9
|
+
|
|
10
|
+
const sourceFiles: SourceFile[] = [];
|
|
11
|
+
const bundledFilePath = sourcemapFilePath.replace('.map', '');
|
|
12
|
+
|
|
13
|
+
for (const source of consumer.sources) {
|
|
14
|
+
if (shouldSkipSource(source)) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const content = consumer.sourceContentFor(source, true);
|
|
19
|
+
if (!content) {
|
|
20
|
+
console.warn(`No sourceContent for ${source} in ${sourcemapFilePath}`);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const classified = classifySource(source);
|
|
25
|
+
sourceFiles.push({
|
|
26
|
+
path: source,
|
|
27
|
+
content,
|
|
28
|
+
type: classified.type,
|
|
29
|
+
packageName: classified.packageName,
|
|
30
|
+
bundledFilePath,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
consumer.destroy();
|
|
34
|
+
return sourceFiles;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function extractAllSources(sourcemapFilePaths: string[]): Promise<SourceFile[]> {
|
|
38
|
+
const allSources: SourceFile[] = [];
|
|
39
|
+
// we need to dedupe paths
|
|
40
|
+
const seenPaths = new Set<string>();
|
|
41
|
+
|
|
42
|
+
for (const mapFilePath of sourcemapFilePaths) {
|
|
43
|
+
try {
|
|
44
|
+
const sources = await extractSourcesFromMap(mapFilePath);
|
|
45
|
+
for (const source of sources) {
|
|
46
|
+
if (!seenPaths.has(source.path)) {
|
|
47
|
+
seenPaths.add(source.path);
|
|
48
|
+
allSources.push(source);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error(`Failed to extract sources from ${mapFilePath}:`, error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return allSources;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function classifySource(sourcePath: string): { type: SourceFileType; packageName?: string } {
|
|
60
|
+
if (sourcePath.includes('node_modules')) {
|
|
61
|
+
const packageName = getPackageName(sourcePath);
|
|
62
|
+
return { type: 'dependency', packageName };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { type: 'source' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getPackageName(sourcePath: string) {
|
|
69
|
+
// PNPM has its own ideas of how to resolve dependencies, so we need to handle it differently.
|
|
70
|
+
const pnpmMatch = sourcePath.match(/\.pnpm\/[^/]+\/node_modules\/((?:@[^/]+\/)?[^/]+)/);
|
|
71
|
+
if (pnpmMatch) {
|
|
72
|
+
return pnpmMatch[1];
|
|
73
|
+
}
|
|
74
|
+
// for everything else, we can use the standard node_modules structure.
|
|
75
|
+
const match = sourcePath.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)/);
|
|
76
|
+
return match ? match[1] : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function shouldSkipSource(sourcePath: string): boolean {
|
|
80
|
+
// skip webpack runtime source
|
|
81
|
+
if (sourcePath.includes('webpack/bootstrap') || sourcePath.includes('webpack/runtime')) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Skip webpack entry points without actual paths
|
|
86
|
+
if (sourcePath.match(/^webpack:\/\/[^/]+\/?$/)) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Skip packages externalised by webpack
|
|
91
|
+
if (sourcePath.includes('/external%20amd')) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Skip virtual publicPath
|
|
96
|
+
if (sourcePath.includes('node_modules/grafana-public-path.js')) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type Severity = 'removed' | 'renamed' | 'deprecated';
|
|
2
|
+
|
|
3
|
+
export interface PatternDefinition {
|
|
4
|
+
severity: Severity;
|
|
5
|
+
impactLevel: 'critical' | 'warning';
|
|
6
|
+
description: string;
|
|
7
|
+
fix: {
|
|
8
|
+
description: string;
|
|
9
|
+
before?: string;
|
|
10
|
+
after?: string;
|
|
11
|
+
};
|
|
12
|
+
link: string;
|
|
13
|
+
functionComponentOnly?: boolean;
|
|
14
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type Confidence = 'high' | 'medium' | 'low' | 'none' | 'unknown';
|
|
2
|
+
export type ComponentType = 'class' | 'function' | 'unknown';
|
|
3
|
+
|
|
4
|
+
export type PatternMatch = {
|
|
5
|
+
pattern: string;
|
|
6
|
+
line: number;
|
|
7
|
+
column: number;
|
|
8
|
+
matched: string;
|
|
9
|
+
context: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type SourceFileType = 'source' | 'dependency' | 'external';
|
|
13
|
+
export interface SourceFile {
|
|
14
|
+
path: string;
|
|
15
|
+
content: string;
|
|
16
|
+
type: SourceFileType;
|
|
17
|
+
packageName?: string;
|
|
18
|
+
bundledFilePath: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SourceMatch = {
|
|
22
|
+
pattern: string;
|
|
23
|
+
matched: string;
|
|
24
|
+
context: string;
|
|
25
|
+
|
|
26
|
+
sourceFile: string;
|
|
27
|
+
sourceLine: number;
|
|
28
|
+
sourceColumn: number;
|
|
29
|
+
|
|
30
|
+
type: 'source' | 'dependency';
|
|
31
|
+
packageName?: string;
|
|
32
|
+
|
|
33
|
+
bundledFilePath: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type AnalyzedMatch = SourceMatch & {
|
|
37
|
+
confidence: Confidence;
|
|
38
|
+
componentType: ComponentType;
|
|
39
|
+
rootDependency?: string;
|
|
40
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Severity } from './patterns.js';
|
|
2
|
+
import { PluginMetadata } from './plugins.js';
|
|
3
|
+
|
|
4
|
+
export interface AnalysisResult {
|
|
5
|
+
pattern: string;
|
|
6
|
+
severity: Severity;
|
|
7
|
+
impactLevel: 'critical' | 'warning';
|
|
8
|
+
location: {
|
|
9
|
+
type: 'source' | 'dependency';
|
|
10
|
+
file: string;
|
|
11
|
+
line: number;
|
|
12
|
+
column: number;
|
|
13
|
+
};
|
|
14
|
+
problem: string;
|
|
15
|
+
fix: {
|
|
16
|
+
description: string;
|
|
17
|
+
before: string;
|
|
18
|
+
after: string;
|
|
19
|
+
};
|
|
20
|
+
link: string;
|
|
21
|
+
packageName?: string;
|
|
22
|
+
rootDependency?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PluginAnalysisResults {
|
|
26
|
+
plugin: PluginMetadata;
|
|
27
|
+
summary: {
|
|
28
|
+
totalIssues: number;
|
|
29
|
+
critical: number;
|
|
30
|
+
warnings: number;
|
|
31
|
+
sourceIssuesCount: number;
|
|
32
|
+
dependencyIssuesCount: number;
|
|
33
|
+
status: 'action_required' | 'no_action_required';
|
|
34
|
+
affectedDependencies: string[];
|
|
35
|
+
};
|
|
36
|
+
issues: {
|
|
37
|
+
critical: AnalysisResult[];
|
|
38
|
+
warnings: AnalysisResult[];
|
|
39
|
+
dependencies: DependencyIssue[];
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DependencyIssue {
|
|
44
|
+
packageName: string;
|
|
45
|
+
version: string;
|
|
46
|
+
rootDependency: string;
|
|
47
|
+
issues: AnalysisResult[];
|
|
48
|
+
recommendation?: {
|
|
49
|
+
action: 'update' | 'replace' | 'remove';
|
|
50
|
+
targetVersion?: string;
|
|
51
|
+
reason: string;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseFile } from '../parser.js';
|
|
3
|
+
import { analyzeConfidence, analyzeComponentType, hasReactComponent, hasFunctionComponentPattern } from './analyzer.js';
|
|
4
|
+
|
|
5
|
+
describe('analyzeConfidence', () => {
|
|
6
|
+
it('should return high for React code with imports and JSX', () => {
|
|
7
|
+
const code = `
|
|
8
|
+
import React from 'react';
|
|
9
|
+
function MyComponent() {
|
|
10
|
+
return <div>Hello</div>;
|
|
11
|
+
}
|
|
12
|
+
`;
|
|
13
|
+
const ast = parseFile(code, 'test.tsx');
|
|
14
|
+
expect(analyzeConfidence(ast)).toBe('high');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should return high for class component', () => {
|
|
18
|
+
const code = `
|
|
19
|
+
import React from 'react';
|
|
20
|
+
class MyComponent extends React.Component {
|
|
21
|
+
render() { return <div>Hello</div>; }
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
24
|
+
const ast = parseFile(code, 'test.tsx');
|
|
25
|
+
expect(analyzeConfidence(ast)).toBe('high');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should return low for hooks without imports', () => {
|
|
29
|
+
const code = `
|
|
30
|
+
function useCustomHook() {
|
|
31
|
+
const [state, setState] = useState(0);
|
|
32
|
+
return state;
|
|
33
|
+
}
|
|
34
|
+
`;
|
|
35
|
+
const ast = parseFile(code, 'test.tsx');
|
|
36
|
+
expect(analyzeConfidence(ast)).toBe('low');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should return medium for JSX without imports', () => {
|
|
40
|
+
const code = `
|
|
41
|
+
function Component() {
|
|
42
|
+
return <div>Hello</div>;
|
|
43
|
+
}
|
|
44
|
+
`;
|
|
45
|
+
const ast = parseFile(code, 'test.tsx');
|
|
46
|
+
expect(analyzeConfidence(ast)).toBe('medium');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should return medium for only React import', () => {
|
|
50
|
+
const code = `
|
|
51
|
+
import React from 'react';
|
|
52
|
+
const value = 42;
|
|
53
|
+
`;
|
|
54
|
+
const ast = parseFile(code, 'test.tsx');
|
|
55
|
+
expect(analyzeConfidence(ast)).toBe('medium');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should return none for non-React code', () => {
|
|
59
|
+
const code = `
|
|
60
|
+
function regularFunction() {
|
|
61
|
+
return 42;
|
|
62
|
+
}
|
|
63
|
+
`;
|
|
64
|
+
const ast = parseFile(code, 'test.js');
|
|
65
|
+
expect(analyzeConfidence(ast)).toBe('none');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('analyzeComponentType', () => {
|
|
70
|
+
it('should detect class components extending React.Component', () => {
|
|
71
|
+
const code = `
|
|
72
|
+
import React from 'react';
|
|
73
|
+
class MyComponent extends React.Component {
|
|
74
|
+
render() { return <div>Hello</div>; }
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
77
|
+
const ast = parseFile(code, 'test.tsx');
|
|
78
|
+
expect(analyzeComponentType(ast)).toBe('class');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should detect class components extending React.PureComponent', () => {
|
|
82
|
+
const code = `
|
|
83
|
+
import React from 'react';
|
|
84
|
+
class MyComponent extends React.PureComponent {
|
|
85
|
+
render() { return <div>Hello</div>; }
|
|
86
|
+
}
|
|
87
|
+
`;
|
|
88
|
+
const ast = parseFile(code, 'test.tsx');
|
|
89
|
+
expect(analyzeComponentType(ast)).toBe('class');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should detect function components with JSX', () => {
|
|
93
|
+
const code = `
|
|
94
|
+
function MyComponent() {
|
|
95
|
+
return <div>Hello</div>;
|
|
96
|
+
}
|
|
97
|
+
`;
|
|
98
|
+
const ast = parseFile(code, 'test.tsx');
|
|
99
|
+
expect(analyzeComponentType(ast)).toBe('function');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should detect function components with hooks', () => {
|
|
103
|
+
const code = `
|
|
104
|
+
function MyComponent() {
|
|
105
|
+
const [state] = useState(0);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
`;
|
|
109
|
+
const ast = parseFile(code, 'test.tsx');
|
|
110
|
+
expect(analyzeComponentType(ast)).toBe('function');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should detect arrow function components', () => {
|
|
114
|
+
const code = `
|
|
115
|
+
const MyComponent = () => {
|
|
116
|
+
return <div>Hello</div>;
|
|
117
|
+
};
|
|
118
|
+
`;
|
|
119
|
+
const ast = parseFile(code, 'test.tsx');
|
|
120
|
+
expect(analyzeComponentType(ast)).toBe('function');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should return unknown for non-component code', () => {
|
|
124
|
+
const code = `
|
|
125
|
+
function regularFunction() {
|
|
126
|
+
return 42;
|
|
127
|
+
}
|
|
128
|
+
`;
|
|
129
|
+
const ast = parseFile(code, 'test.js');
|
|
130
|
+
expect(analyzeComponentType(ast)).toBe('unknown');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('hasReactComponent', () => {
|
|
135
|
+
it('should detect React.Component', () => {
|
|
136
|
+
const code = `
|
|
137
|
+
class MyComponent extends React.Component {}
|
|
138
|
+
`;
|
|
139
|
+
const ast = parseFile(code, 'test.tsx');
|
|
140
|
+
expect(hasReactComponent(ast)).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should detect React.PureComponent', () => {
|
|
144
|
+
const code = `
|
|
145
|
+
class MyComponent extends React.PureComponent {}
|
|
146
|
+
`;
|
|
147
|
+
const ast = parseFile(code, 'test.tsx');
|
|
148
|
+
expect(hasReactComponent(ast)).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should return false for non-React classes', () => {
|
|
152
|
+
const code = `
|
|
153
|
+
class MyClass extends BaseClass {}
|
|
154
|
+
`;
|
|
155
|
+
const ast = parseFile(code, 'test.tsx');
|
|
156
|
+
expect(hasReactComponent(ast)).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('hasFunctionComponentPattern', () => {
|
|
161
|
+
it('should detect functions with JSX', () => {
|
|
162
|
+
const code = `
|
|
163
|
+
function MyComponent() {
|
|
164
|
+
return <div>Hello</div>;
|
|
165
|
+
}
|
|
166
|
+
`;
|
|
167
|
+
const ast = parseFile(code, 'test.tsx');
|
|
168
|
+
expect(hasFunctionComponentPattern(ast)).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should detect functions with hooks', () => {
|
|
172
|
+
const code = `
|
|
173
|
+
function MyComponent() {
|
|
174
|
+
const [state] = useState(0);
|
|
175
|
+
}
|
|
176
|
+
`;
|
|
177
|
+
const ast = parseFile(code, 'test.tsx');
|
|
178
|
+
expect(hasFunctionComponentPattern(ast)).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should return false for functions without React patterns', () => {
|
|
182
|
+
const code = `
|
|
183
|
+
function regularFunction() {
|
|
184
|
+
return 42;
|
|
185
|
+
}
|
|
186
|
+
`;
|
|
187
|
+
const ast = parseFile(code, 'test.js');
|
|
188
|
+
expect(hasFunctionComponentPattern(ast)).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { ComponentType, Confidence } from '../types/processors.js';
|
|
2
|
+
import { walk } from './ast.js';
|
|
3
|
+
import { TSESTree } from '@typescript-eslint/typescript-estree';
|
|
4
|
+
|
|
5
|
+
export function analyzeConfidence(ast: TSESTree.Program): Confidence {
|
|
6
|
+
let score = 0;
|
|
7
|
+
if (hasReactImport(ast)) {
|
|
8
|
+
score += 3;
|
|
9
|
+
}
|
|
10
|
+
if (hasJSX(ast)) {
|
|
11
|
+
score += 3;
|
|
12
|
+
}
|
|
13
|
+
if (hasHooks(ast)) {
|
|
14
|
+
score += 2;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (hasReactComponent(ast)) {
|
|
18
|
+
score += 3;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (score >= 6) {
|
|
22
|
+
return 'high';
|
|
23
|
+
}
|
|
24
|
+
if (score >= 3) {
|
|
25
|
+
return 'medium';
|
|
26
|
+
}
|
|
27
|
+
if (score >= 1) {
|
|
28
|
+
return 'low';
|
|
29
|
+
}
|
|
30
|
+
return 'none';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function analyzeComponentType(ast: TSESTree.Program): ComponentType {
|
|
34
|
+
if (hasReactComponent(ast)) {
|
|
35
|
+
return 'class';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (hasFunctionComponentPattern(ast)) {
|
|
39
|
+
return 'function';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return 'unknown';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function hasReactComponent(ast: TSESTree.Program): boolean {
|
|
46
|
+
let found = false;
|
|
47
|
+
|
|
48
|
+
walk(ast, (node) => {
|
|
49
|
+
if (
|
|
50
|
+
node.type === 'ClassDeclaration' &&
|
|
51
|
+
node.superClass?.type === 'MemberExpression' &&
|
|
52
|
+
node.superClass.object.type === 'Identifier' &&
|
|
53
|
+
node.superClass.object.name === 'React' &&
|
|
54
|
+
node.superClass.property.type === 'Identifier' &&
|
|
55
|
+
(node.superClass.property.name === 'Component' || node.superClass.property.name === 'PureComponent')
|
|
56
|
+
) {
|
|
57
|
+
found = true;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return found;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function hasFunctionComponentPattern(ast: TSESTree.Program): boolean {
|
|
65
|
+
const hasJSXInFile = hasJSX(ast);
|
|
66
|
+
const hasHooksInFile = hasHooks(ast);
|
|
67
|
+
|
|
68
|
+
if (!hasJSXInFile && !hasHooksInFile) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let found = false;
|
|
73
|
+
walk(ast, (node) => {
|
|
74
|
+
if (node.type === 'FunctionDeclaration' || node.type === 'ArrowFunctionExpression') {
|
|
75
|
+
found = true;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasReactImport(ast: TSESTree.Program): boolean {
|
|
83
|
+
let found = false;
|
|
84
|
+
|
|
85
|
+
walk(ast, (node) => {
|
|
86
|
+
if (
|
|
87
|
+
node.type === 'ImportDeclaration' &&
|
|
88
|
+
node.source.type === 'Literal' &&
|
|
89
|
+
(node.source.value === 'react' || node.source.value === 'react-dom')
|
|
90
|
+
) {
|
|
91
|
+
found = true;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return found;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hasJSX(ast: TSESTree.Program): boolean {
|
|
99
|
+
let found = false;
|
|
100
|
+
|
|
101
|
+
walk(ast, (node) => {
|
|
102
|
+
if (node.type === 'JSXElement' || node.type === 'JSXFragment') {
|
|
103
|
+
found = true;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return found;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function hasHooks(ast: TSESTree.Program): boolean {
|
|
111
|
+
let found = false;
|
|
112
|
+
|
|
113
|
+
walk(ast, (node) => {
|
|
114
|
+
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name.startsWith('use')) {
|
|
115
|
+
found = true;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return found;
|
|
120
|
+
}
|