@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.
Files changed (49) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +23 -0
  3. package/dist/analyzer.js +37 -0
  4. package/dist/bin/run.js +12 -0
  5. package/dist/commands/detect19.js +41 -0
  6. package/dist/file-scanner.js +20 -0
  7. package/dist/libs/output/src/index.js +132 -0
  8. package/dist/parser.js +23 -0
  9. package/dist/patterns/definitions.js +119 -0
  10. package/dist/patterns/matcher.js +146 -0
  11. package/dist/reporters/console.js +132 -0
  12. package/dist/reporters/json.js +11 -0
  13. package/dist/results.js +128 -0
  14. package/dist/source-extractor.js +80 -0
  15. package/dist/utils/analyzer.js +88 -0
  16. package/dist/utils/ast.js +20 -0
  17. package/dist/utils/dependencies.js +97 -0
  18. package/dist/utils/output.js +5 -0
  19. package/dist/utils/plugin.js +36 -0
  20. package/package.json +42 -0
  21. package/src/analyzer.test.ts +14 -0
  22. package/src/analyzer.ts +42 -0
  23. package/src/bin/run.ts +17 -0
  24. package/src/commands/detect19.ts +53 -0
  25. package/src/file-scanner.ts +19 -0
  26. package/src/parser.ts +22 -0
  27. package/src/patterns/definitions.ts +125 -0
  28. package/src/patterns/matcher.test.ts +221 -0
  29. package/src/patterns/matcher.ts +268 -0
  30. package/src/reporters/console.ts +139 -0
  31. package/src/reporters/json.ts +13 -0
  32. package/src/results.ts +170 -0
  33. package/src/source-extractor.ts +101 -0
  34. package/src/types/patterns.ts +14 -0
  35. package/src/types/plugins.ts +6 -0
  36. package/src/types/processors.ts +40 -0
  37. package/src/types/reporters.ts +53 -0
  38. package/src/utils/analyzer.test.ts +190 -0
  39. package/src/utils/analyzer.ts +120 -0
  40. package/src/utils/ast.ts +19 -0
  41. package/src/utils/dependencies.test.ts +123 -0
  42. package/src/utils/dependencies.ts +141 -0
  43. package/src/utils/output.ts +3 -0
  44. package/src/utils/plugin.ts +72 -0
  45. package/test/fixtures/dependencies/package-lock.json +49 -0
  46. package/test/fixtures/dependencies/package.json +16 -0
  47. package/test/fixtures/patterns/module.js.map +1 -0
  48. package/tsconfig.json +9 -0
  49. package/vitest.config.ts +12 -0
@@ -0,0 +1,97 @@
1
+ import { join } from 'node:path';
2
+ import { parsePnpmProject, parseNpmLockV2Project, parseYarnLockV2Project } from 'snyk-nodejs-lockfile-parser';
3
+ import { readFileSync, existsSync } from 'node:fs';
4
+ import { readJsonFile } from './plugin.js';
5
+
6
+ var __defProp = Object.defineProperty;
7
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
8
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
9
+ class DependencyContext {
10
+ constructor() {
11
+ __publicField(this, "dependencies", /* @__PURE__ */ new Map());
12
+ __publicField(this, "devDependencies", /* @__PURE__ */ new Map());
13
+ __publicField(this, "depGraph", null);
14
+ }
15
+ async loadDependencies(pluginRoot = process.cwd()) {
16
+ const lockfile = this.findLockfile(pluginRoot);
17
+ const packageJsonPath = join(pluginRoot, "package.json");
18
+ if (lockfile) {
19
+ const pkgJsonContent = readJsonFile(packageJsonPath);
20
+ const lockfileContent = readFileSync(join(pluginRoot, lockfile), "utf8");
21
+ const pkgJsonContentString = JSON.stringify(pkgJsonContent);
22
+ if (lockfile === "pnpm-lock.yaml") {
23
+ this.depGraph = await parsePnpmProject(pkgJsonContentString, lockfileContent, {
24
+ includeDevDeps: true,
25
+ includeOptionalDeps: true,
26
+ strictOutOfSync: false,
27
+ pruneWithinTopLevelDeps: false
28
+ });
29
+ } else if (lockfile === "package-lock.json") {
30
+ this.depGraph = await parseNpmLockV2Project(pkgJsonContentString, lockfileContent, {
31
+ includeDevDeps: true,
32
+ includeOptionalDeps: true,
33
+ strictOutOfSync: false,
34
+ pruneCycles: false
35
+ });
36
+ } else if (lockfile === "yarn.lock") {
37
+ this.depGraph = await parseYarnLockV2Project(pkgJsonContentString, lockfileContent, {
38
+ includeDevDeps: true,
39
+ includeOptionalDeps: true,
40
+ strictOutOfSync: false,
41
+ pruneWithinTopLevelDeps: true
42
+ });
43
+ }
44
+ if (pkgJsonContent.dependencies) {
45
+ Object.entries(pkgJsonContent.dependencies).forEach(([name, version]) => {
46
+ this.dependencies.set(name, version);
47
+ });
48
+ }
49
+ if (pkgJsonContent.devDependencies) {
50
+ Object.entries(pkgJsonContent.devDependencies).forEach(([name, version]) => {
51
+ this.devDependencies.set(name, version);
52
+ });
53
+ }
54
+ } else {
55
+ throw new Error(`No lockfile found in ${pluginRoot}`);
56
+ }
57
+ }
58
+ findLockfile(pluginRoot) {
59
+ const lockfiles = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
60
+ for (const lockfile of lockfiles) {
61
+ if (existsSync(join(pluginRoot, lockfile))) {
62
+ return lockfile;
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+ findRootDependency(packageName) {
68
+ if (this.isDirect(packageName)) {
69
+ return packageName;
70
+ }
71
+ if (this.depGraph) {
72
+ try {
73
+ const pkgs = this.depGraph.getPkgs().filter((p) => p.name === packageName);
74
+ if (pkgs.length > 0) {
75
+ const paths = this.depGraph.pkgPathsToRoot(pkgs[0]);
76
+ if (paths.length > 0 && paths[0].length > 1) {
77
+ return paths[0][paths[0].length - 2].name;
78
+ }
79
+ }
80
+ } catch (error) {
81
+ console.error(error);
82
+ }
83
+ }
84
+ return packageName;
85
+ }
86
+ isDirect(packageName) {
87
+ return this.dependencies.has(packageName) || this.devDependencies.has(packageName);
88
+ }
89
+ getVersion(packageName) {
90
+ return this.dependencies.get(packageName) || this.devDependencies.get(packageName);
91
+ }
92
+ getAllDependencies() {
93
+ return new Map([...this.dependencies, ...this.devDependencies]);
94
+ }
95
+ }
96
+
97
+ export { DependencyContext };
@@ -0,0 +1,5 @@
1
+ import { Output } from '../libs/output/src/index.js';
2
+
3
+ const output = new Output("@grafana/react-detect");
4
+
5
+ export { output };
@@ -0,0 +1,36 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+
4
+ let cachedPluginJson = null;
5
+ function getPluginJson(dir) {
6
+ if (cachedPluginJson) {
7
+ return cachedPluginJson;
8
+ }
9
+ const srcPath = dir ? path.join(dir, "dist") : path.join(process.cwd(), "dist");
10
+ const pluginJsonPath = path.join(srcPath, "plugin.json");
11
+ cachedPluginJson = readJsonFile(pluginJsonPath);
12
+ return cachedPluginJson;
13
+ }
14
+ function isFile(path2) {
15
+ try {
16
+ return fs.lstatSync(path2).isFile();
17
+ } catch (e) {
18
+ return false;
19
+ }
20
+ }
21
+ function readJsonFile(filename) {
22
+ if (!isFile(filename)) {
23
+ throw new Error(
24
+ `There is no "${path.basename(
25
+ filename
26
+ )}" file found at "${filename}". Make sure you run this command from a plugins root directory.`
27
+ );
28
+ }
29
+ try {
30
+ return JSON.parse(fs.readFileSync(filename).toString());
31
+ } catch (error) {
32
+ throw new Error(`Failed to load json file ${filename}: ${error instanceof Error ? error.message : String(error)}`);
33
+ }
34
+ }
35
+
36
+ export { getPluginJson, readJsonFile };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@grafana/react-detect",
3
+ "description": "Run various checks to detect if a Grafana plugin is compatible with React.",
4
+ "version": "0.1.0",
5
+ "repository": {
6
+ "directory": "packages/react-detect",
7
+ "url": "https://github.com/grafana/plugin-tools"
8
+ },
9
+ "author": "Grafana",
10
+ "license": "Apache-2.0",
11
+ "bin": "./dist/bin/run.js",
12
+ "type": "module",
13
+ "publishConfig": {
14
+ "registry": "https://registry.npmjs.org/",
15
+ "access": "public"
16
+ },
17
+ "scripts": {
18
+ "clean": "rm -rf ./dist",
19
+ "build": "rollup -c ../../rollup.config.ts --configPlugin esbuild",
20
+ "dev": "npm run build -- -w",
21
+ "lint": "eslint --cache ./src",
22
+ "lint:fix": "npm run lint -- --fix",
23
+ "lint:package": "publint",
24
+ "test": "vitest",
25
+ "typecheck": "tsc --noEmit"
26
+ },
27
+ "dependencies": {
28
+ "@typescript-eslint/parser": "^8.46.2",
29
+ "@typescript-eslint/typescript-estree": "^8.46.2",
30
+ "fast-glob": "^3.3.2",
31
+ "minimist": "^1.2.8",
32
+ "snyk-nodejs-lockfile-parser": "^2.4.4",
33
+ "source-map": "^0.7.4"
34
+ },
35
+ "devDependencies": {
36
+ "@libs/output": "1.0.3",
37
+ "@types/minimist": "^1.2.5"
38
+ },
39
+ "engines": {
40
+ "node": ">=20"
41
+ }
42
+ }
@@ -0,0 +1,14 @@
1
+ import { join } from 'node:path';
2
+ import { analyzeSourceFiles } from './analyzer.js';
3
+ import { extractAllSources } from './source-extractor.js';
4
+
5
+ describe('analyzeSourceFiles', () => {
6
+ it('should analyze a source match with React code', async () => {
7
+ const fixturePath = join(__dirname, '..', 'test', 'fixtures', 'patterns', 'module.js.map');
8
+ const sourceFile = await extractAllSources([fixturePath]);
9
+ const result = await analyzeSourceFiles(sourceFile);
10
+
11
+ expect(result[0].confidence).toBe('high');
12
+ expect(result[0].componentType).toBe('function');
13
+ });
14
+ });
@@ -0,0 +1,42 @@
1
+ import { AnalyzedMatch, SourceFile } from './types/processors.js';
2
+ import { parseFile } from './parser.js';
3
+ import { analyzeConfidence, analyzeComponentType } from './utils/analyzer.js';
4
+ import { findPatternMatches } from './patterns/matcher.js';
5
+
6
+ export async function analyzeSourceFiles(sourceFiles: SourceFile[]): Promise<AnalyzedMatch[]> {
7
+ const matches: AnalyzedMatch[] = [];
8
+
9
+ for (const source of sourceFiles) {
10
+ if (source.type === 'external') {
11
+ continue;
12
+ }
13
+
14
+ try {
15
+ const ast = parseFile(source.content, source.path);
16
+ const patternMatches = findPatternMatches(ast, source.content);
17
+
18
+ for (const match of patternMatches) {
19
+ const analyzed = {
20
+ pattern: match.pattern,
21
+ matched: match.matched,
22
+ context: match.context,
23
+ sourceFile: source.path,
24
+ sourceLine: match.line,
25
+ sourceColumn: match.column,
26
+ type: source.type,
27
+ packageName: source.packageName,
28
+ bundledFilePath: source.bundledFilePath,
29
+
30
+ confidence: analyzeConfidence(ast),
31
+ componentType: analyzeComponentType(ast),
32
+ };
33
+
34
+ matches.push(analyzed);
35
+ }
36
+ } catch (error) {
37
+ console.error(`Failed to analyze ${source.path}:`, error);
38
+ }
39
+ }
40
+
41
+ return matches;
42
+ }
package/src/bin/run.ts ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+
3
+ import minimist from 'minimist';
4
+ import { detect19 } from '../commands/detect19.js';
5
+
6
+ const args = process.argv.slice(2);
7
+ const argv = minimist(args);
8
+
9
+ const commands: Record<string, (argv: minimist.ParsedArgs) => Promise<void>> = {
10
+ detect19,
11
+ };
12
+
13
+ // Default to detect19 if no command specified
14
+ const commandName = argv._[0] || 'detect19';
15
+ const command = commands[commandName];
16
+
17
+ command(argv);
@@ -0,0 +1,53 @@
1
+ import minimist from 'minimist';
2
+ import { findSourceMapFiles } from '../file-scanner.js';
3
+ import { generateAnalysisResults } from '../results.js';
4
+ import { DependencyContext } from '../utils/dependencies.js';
5
+ import { jsonReporter } from '../reporters/json.js';
6
+ import { consoleReporter } from '../reporters/console.js';
7
+ import { extractAllSources } from '../source-extractor.js';
8
+ import { analyzeSourceFiles } from '../analyzer.js';
9
+ /**
10
+ * Main detect command for finding React 19 breaking changes
11
+ */
12
+ export async function detect19(argv: minimist.ParsedArgs) {
13
+ const pluginRoot = argv.pluginRoot || process.cwd();
14
+
15
+ const allMatches = await getAllMatches(pluginRoot);
16
+ const depContext = new DependencyContext();
17
+ await depContext.loadDependencies(pluginRoot);
18
+
19
+ const matchesWithRootDependency = allMatches.map((match) => {
20
+ if (match.type === 'dependency' && match.packageName) {
21
+ return { ...match, rootDependency: depContext.findRootDependency(match.packageName) };
22
+ }
23
+ return match;
24
+ });
25
+
26
+ const results = generateAnalysisResults(matchesWithRootDependency, pluginRoot, depContext);
27
+
28
+ if (argv.json) {
29
+ jsonReporter(results);
30
+ } else {
31
+ consoleReporter(results);
32
+ }
33
+
34
+ process.exit(results.summary.totalIssues > 0 ? 1 : 0);
35
+ }
36
+
37
+ async function getAllMatches(pluginRoot: string) {
38
+ const sourcemapPaths = await findSourceMapFiles(pluginRoot);
39
+
40
+ if (sourcemapPaths.length === 0) {
41
+ throw new Error('No source map files found in dist directory. Make sure to build your plugin first.');
42
+ }
43
+
44
+ const sources = await extractAllSources(sourcemapPaths);
45
+
46
+ if (sources.length === 0) {
47
+ throw new Error('No sources found in source maps.');
48
+ }
49
+
50
+ const matches = await analyzeSourceFiles(sources);
51
+
52
+ return matches;
53
+ }
@@ -0,0 +1,19 @@
1
+ import fg from 'fast-glob';
2
+ import { join } from 'node:path';
3
+
4
+ export async function findSourceMapFiles(directory: string): Promise<string[]> {
5
+ const distDirectory = join(directory, 'dist');
6
+
7
+ try {
8
+ const files = await fg('**/*.js.map', {
9
+ cwd: distDirectory,
10
+ absolute: true,
11
+ ignore: ['**/node_modules/**'],
12
+ });
13
+ return files;
14
+ } catch (error) {
15
+ throw new Error(
16
+ `Error finding source map files in ${distDirectory}: ${error instanceof Error ? error.message : 'Unknown error'}`
17
+ );
18
+ }
19
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { parse, TSESTree } from '@typescript-eslint/typescript-estree';
2
+
3
+ const PARSER_OPTIONS = {
4
+ jsx: true,
5
+ loc: true,
6
+ range: true,
7
+ comment: false,
8
+ tokens: false,
9
+ ecmaVersion: 'latest' as const,
10
+ sourceType: 'module' as const,
11
+ };
12
+
13
+ export function parseFile(code: string, filePath: string): TSESTree.Program {
14
+ try {
15
+ return parse(code, {
16
+ ...PARSER_OPTIONS,
17
+ filePath,
18
+ });
19
+ } catch (error) {
20
+ throw new Error(`Failed to parse ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`);
21
+ }
22
+ }
@@ -0,0 +1,125 @@
1
+ import { PatternDefinition } from '../types/patterns.js';
2
+
3
+ export const PATTERN_DEFINITIONS: Record<string, PatternDefinition> = {
4
+ __SECRET_INTERNALS: {
5
+ severity: 'renamed',
6
+ impactLevel: 'critical',
7
+ description: 'React internals __SECRET_INTERNALS renamed to _DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE',
8
+ fix: {
9
+ description:
10
+ 'Check the list of libraries depending on React internals. Alternatively externalise react/jsx-runtime in webpack config. Your plugin will only be compatible with Grafana >=12.3.0',
11
+ },
12
+ link: 'https://react.dev/blog/2024/04/25/react-19-upgrade-guide#libraries-depending-on-react-internals-may-block-upgrades',
13
+ },
14
+ jsxRuntimeImport: {
15
+ severity: 'renamed',
16
+ impactLevel: 'critical',
17
+ description:
18
+ 'Dependency bundles react/jsx-runtime which will break with React 19 due to `__SECRET_INTERNALS` being renamed',
19
+ fix: {
20
+ description:
21
+ 'Externalize react/jsx-runtime in webpack config. Your plugin will only be compatible with Grafana >=12.3.0',
22
+ },
23
+ link: 'https://grafana.com/developers/plugin-tools/how-to-guides/extend-configurations#extend-the-webpack-config',
24
+ },
25
+ defaultProps: {
26
+ severity: 'removed',
27
+ impactLevel: 'critical',
28
+ description: 'Default props removed in favour of function components',
29
+ fix: {
30
+ description: 'Use ES6 default parameters',
31
+ before: 'MyComponent.defaultProps = { value: "test" }',
32
+ after: 'function MyComponent({ value = "test" }) { ... }',
33
+ },
34
+ link: 'https://react.dev/blog/2024/04/25/react-19-upgrade-guide#removed-deprecated-react-apis',
35
+ functionComponentOnly: true,
36
+ },
37
+ propTypes: {
38
+ severity: 'removed',
39
+ impactLevel: 'warning',
40
+ description: 'Prop types removed in favour of typescript or other type-checking solution',
41
+ link: 'https://react.dev/blog/2024/04/25/react-19-upgrade-guide#removed-deprecated-react-apis',
42
+ fix: {
43
+ description: 'Run the codemod to migrate to Typescript',
44
+ before: 'npx codemod@latest react/prop-types-typescript',
45
+ },
46
+ },
47
+ contextTypes: {
48
+ severity: 'removed',
49
+ impactLevel: 'critical',
50
+ description: 'Legacy Context contextTypes removed in React 19',
51
+ fix: {
52
+ description: 'Migrate to the new contextType API.',
53
+ },
54
+ link: 'https://react.dev/blog/2024/04/25/react-19-upgrade-guide#removed-removing-legacy-context',
55
+ },
56
+ getChildContext: {
57
+ severity: 'removed',
58
+ impactLevel: 'critical',
59
+ description: 'Legacy Context getChildContext removed in React 19',
60
+ fix: {
61
+ description: 'Migrate to the new contextType API.',
62
+ },
63
+ link: 'https://react.dev/blog/2024/04/25/react-19-upgrade-guide#removed-removing-legacy-context',
64
+ },
65
+ stringRefs: {
66
+ severity: 'removed',
67
+ impactLevel: 'critical',
68
+ description: 'String refs removed in React 19',
69
+ fix: {
70
+ description: 'Run the codemod to migrate to ref callbacks.',
71
+ before: 'npx codemod@latest react/19/replace-string-ref',
72
+ },
73
+ link: 'https://react.dev/blog/2024/04/25/react-19-upgrade-guide#removed-string-refs',
74
+ },
75
+ findDOMNode: {
76
+ severity: 'removed',
77
+ impactLevel: 'critical',
78
+ description: 'findDOMNode removed from React and ReactDOM in React 19',
79
+ fix: {
80
+ description: 'Replace ReactDOM.findDOMNode with DOM refs',
81
+ before: 'const node = findDOMNode(this);',
82
+ after: 'const node = useRef(null);',
83
+ },
84
+ link: 'https://react.dev/blog/2024/04/25/react-19-upgrade-guide#removed-reactdom-finddomnode',
85
+ },
86
+ createFactory: {
87
+ severity: 'removed',
88
+ impactLevel: 'critical',
89
+ description: 'React.createFactory removed in React 19',
90
+ fix: {
91
+ description: 'Use JSX instead',
92
+ before: "const button = createFactory('button');",
93
+ after: 'const button = <button />;',
94
+ },
95
+ link: 'https://react.dev/blog/2024/04/25/react-19-upgrade-guide#removed-createfactory',
96
+ },
97
+
98
+ 'ReactDOM.render': {
99
+ severity: 'removed',
100
+ impactLevel: 'critical',
101
+ description: 'ReactDOM.render removed in React 19 (use createRoot)',
102
+ fix: {
103
+ description: 'Use createRoot instead.',
104
+ before: 'ReactDOM.render(<App />, document.getElementById("root"));',
105
+ after: 'const root = createRoot(document.getElementById("root")); root.render(<App />);',
106
+ },
107
+ link: 'https://react.dev/blog/2024/04/25/react-19-upgrade-guide#removed-reactdom-render',
108
+ },
109
+
110
+ 'ReactDOM.unmountComponentAtNode': {
111
+ severity: 'removed',
112
+ impactLevel: 'critical',
113
+ description: 'ReactDOM.unmountComponentAtNode removed in React 19',
114
+ fix: {
115
+ description: 'Use createRoot instead.',
116
+ before: 'ReactDOM.unmountComponentAtNode(container);',
117
+ after: 'const root = createRoot(container); root.unmount();',
118
+ },
119
+ link: 'https://react.dev/blog/2024/04/25/react-19-upgrade-guide#removed-reactdom-render',
120
+ },
121
+ };
122
+
123
+ export function getPattern(name: string): PatternDefinition | undefined {
124
+ return PATTERN_DEFINITIONS[name];
125
+ }