@grafana/create-plugin 7.16.0 → 7.17.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [7.17.0](https://github.com/grafana/plugin-tools/compare/@grafana/create-plugin@7.16.0...@grafana/create-plugin@7.17.0) (2026-10-28)
4
+
5
+
6
+ ### Features
7
+
8
+ * remove dependency on glob ([#2655](https://github.com/grafana/plugin-tools/issues/2655)) ([9db0649](https://github.com/grafana/plugin-tools/commit/9db0649fd4dee9b5745a248ba421998cd9b86764))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **templates:** Update dependency @grafana/plugin-e2e to v3.15.2 ([#3022](https://github.com/grafana/plugin-tools/issues/3022)) ([2115b27](https://github.com/grafana/plugin-tools/commit/2115b27767554a555640300c7b4dc9c9e1842909))
14
+
3
15
  ## [7.16.0](https://github.com/grafana/plugin-tools/compare/@grafana/create-plugin@7.15.1...@grafana/create-plugin@7.16.0) (2026-10-22)
4
16
 
5
17
 
@@ -108,11 +108,17 @@ var defaultMigrations = [
108
108
  version: "7.13.0",
109
109
  description: "Replace eslint-webpack-plugin with diagnostics-webpack-plugin in the webpack config. eslint-webpack-plugin depends on micromatch, which pulls in the braces dependency flagged by vulnerability scanners.",
110
110
  scriptPath: import.meta.resolve("./scripts/018-replace-eslint-webpack-plugin.js")
111
+ },
112
+ {
113
+ name: "019-remove-glob-dependency",
114
+ version: "7.17.0",
115
+ description: "Replace the glob package with the Node.js built-in fs.glob in bundler utils.",
116
+ scriptPath: import.meta.resolve("./scripts/019-remove-glob-dependency.js")
111
117
  }
112
118
  // Do not use LEGACY_UPDATE_CUTOFF_VERSION for new migrations. It is only used above to force migrations to run
113
119
  // for those written before the switch to updates as migrations.
114
120
  //
115
- // New migrations never pick a version: copy the unreleased version line from ../AGENTS.md exactly.
121
+ // New migrations should never specify a version: copy the unreleased version line from ../AGENTS.md exactly.
116
122
  // Do not paste it into a comment in this file, as release-please rewrites every line that carries it.
117
123
  ];
118
124
 
@@ -0,0 +1,93 @@
1
+ import { parseAsTypescript, findImports, findNamedImport, findFunctionDeclaration, callsFunction, insertImports, createImport, printAST } from '../../utils.ast.js';
2
+ import { removeDependenciesFromPackageJson, migrationsDebug } from '../../utils.js';
3
+
4
+ const OLD_PACKAGE_NAME = "glob";
5
+ const NEW_PACKAGE_NAME = "node:fs/promises";
6
+ const UTILS_PATHS = [".config/webpack/utils.ts", ".config/bundler/utils.ts"];
7
+ const GET_ENTRIES_SOURCE = `export async function getEntries() {
8
+ const plugins: string[][] = [];
9
+ for await (const pluginJson of glob('**/src/**/plugin.json')) {
10
+ const folder = path.dirname(pluginJson);
11
+ const modules: string[] = [];
12
+ for await (const module of glob(\`\${folder}/module.{ts,tsx,js,jsx}\`)) {
13
+ modules.push(path.resolve(module));
14
+ }
15
+ plugins.push(modules);
16
+ }
17
+
18
+ return plugins.reduce<Record<string, string>>((result, modules) => {
19
+ return modules.reduce((innerResult, module) => {
20
+ const pluginPath = path.dirname(module);
21
+ const pluginName = path.relative(process.cwd(), pluginPath).replace(/src\\/?/i, '');
22
+ const entryName = pluginName === '' ? 'module' : \`\${pluginName}/module\`;
23
+
24
+ innerResult[entryName] = module;
25
+ return innerResult;
26
+ }, result);
27
+ }, {});
28
+ }`;
29
+ function migrate(context) {
30
+ const updatedPaths = UTILS_PATHS.filter((filePath) => replaceGlobWithFsGlob(context, filePath));
31
+ if (updatedPaths.length === 0) {
32
+ return context;
33
+ }
34
+ const isStillUsed = UTILS_PATHS.some((filePath) => {
35
+ const source = context.getFile(filePath);
36
+ if (!source) {
37
+ return false;
38
+ }
39
+ const parsed = parseAsTypescript(source);
40
+ return !parsed.success || findImports(parsed.ast, OLD_PACKAGE_NAME).length > 0;
41
+ });
42
+ if (context.doesFileExist("package.json") && !isStillUsed) {
43
+ removeDependenciesFromPackageJson(context, [OLD_PACKAGE_NAME], [OLD_PACKAGE_NAME]);
44
+ }
45
+ return context;
46
+ }
47
+ function replaceGlobWithFsGlob(context, utilsPath) {
48
+ if (!context.doesFileExist(utilsPath)) {
49
+ return false;
50
+ }
51
+ const source = context.getFile(utilsPath);
52
+ if (!source || !source.includes(OLD_PACKAGE_NAME)) {
53
+ return false;
54
+ }
55
+ const parsed = parseAsTypescript(source);
56
+ if (!parsed.success) {
57
+ migrationsDebug(`Failed to parse ${utilsPath}. Error: ${parsed.error.message}`);
58
+ return false;
59
+ }
60
+ const globImport = findNamedImport(parsed.ast, OLD_PACKAGE_NAME, "glob");
61
+ if (!globImport) {
62
+ return false;
63
+ }
64
+ const getEntries = findFunctionDeclaration(parsed.ast, "getEntries");
65
+ if (!getEntries) {
66
+ return false;
67
+ }
68
+ if (!callsFunction(getEntries.body, "glob")) {
69
+ return false;
70
+ }
71
+ const { declaration, specifier } = globImport;
72
+ declaration.specifiers = (declaration.specifiers ?? []).filter((candidate) => candidate !== specifier);
73
+ if (declaration.specifiers.length === 0) {
74
+ parsed.ast.program.body.splice(parsed.ast.program.body.indexOf(declaration), 1);
75
+ }
76
+ insertImports(parsed.ast, [createImport({ named: [{ name: "glob" }] }, NEW_PACKAGE_NAME)]);
77
+ getEntries.body = getFunctionBody(GET_ENTRIES_SOURCE);
78
+ context.updateFile(utilsPath, printAST(parsed.ast));
79
+ return true;
80
+ }
81
+ function getFunctionBody(source) {
82
+ const parsed = parseAsTypescript(source);
83
+ if (!parsed.success) {
84
+ throw parsed.error;
85
+ }
86
+ const [statement] = parsed.ast.program.body;
87
+ if (statement?.type !== "ExportNamedDeclaration" || statement.declaration?.type !== "FunctionDeclaration") {
88
+ throw new Error("Failed to parse target function body");
89
+ }
90
+ return statement.declaration.body;
91
+ }
92
+
93
+ export { migrate as default };
@@ -34,6 +34,33 @@ function findVariableDeclaration(ast, variableName) {
34
34
  });
35
35
  return found;
36
36
  }
37
+ function findFunctionDeclaration(ast, functionName) {
38
+ let found = null;
39
+ recast.types.visit(ast, {
40
+ visitFunctionDeclaration(path) {
41
+ if (path.node.id?.name === functionName) {
42
+ found = path.node;
43
+ return false;
44
+ }
45
+ return this.traverse(path);
46
+ }
47
+ });
48
+ return found;
49
+ }
50
+ function callsFunction(ast, functionName) {
51
+ let isCalled = false;
52
+ recast.types.visit(ast, {
53
+ visitCallExpression(path) {
54
+ const { callee } = path.node;
55
+ if (callee.type === "Identifier" && callee.name === functionName) {
56
+ isCalled = true;
57
+ return false;
58
+ }
59
+ return this.traverse(path);
60
+ }
61
+ });
62
+ return isCalled;
63
+ }
37
64
  function isProperty(node) {
38
65
  return node.type === "Property" || node.type === "ObjectProperty";
39
66
  }
@@ -71,5 +98,21 @@ function insertImports(ast, imports) {
71
98
  ast.program.body.unshift(...imports);
72
99
  }
73
100
  }
101
+ function findImports(ast, packageName) {
102
+ return ast.program.body.filter(
103
+ (node) => node.type === "ImportDeclaration" && node.source.value === packageName
104
+ );
105
+ }
106
+ function findNamedImport(ast, packageName, importedName) {
107
+ for (const declaration of findImports(ast, packageName)) {
108
+ const specifier = declaration.specifiers?.find(
109
+ (candidate) => candidate.type === "ImportSpecifier" && candidate.imported.name === importedName
110
+ );
111
+ if (specifier) {
112
+ return { declaration, specifier };
113
+ }
114
+ }
115
+ return void 0;
116
+ }
74
117
 
75
- export { createImport, findObjectProperty, findVariableDeclaration, insertImports, isProperty, parseAsTypescript, printAST };
118
+ export { callsFunction, createImport, findFunctionDeclaration, findImports, findNamedImport, findObjectProperty, findVariableDeclaration, insertImports, isProperty, parseAsTypescript, printAST };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grafana/create-plugin",
3
- "version": "7.16.0",
3
+ "version": "7.17.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "directory": "packages/create-plugin",
@@ -127,9 +127,15 @@ export default [
127
127
  'Replace eslint-webpack-plugin with diagnostics-webpack-plugin in the webpack config. eslint-webpack-plugin depends on micromatch, which pulls in the braces dependency flagged by vulnerability scanners.',
128
128
  scriptPath: import.meta.resolve('./scripts/018-replace-eslint-webpack-plugin.js'),
129
129
  },
130
+ {
131
+ name: '019-remove-glob-dependency',
132
+ version: '7.17.0',
133
+ description: 'Replace the glob package with the Node.js built-in fs.glob in bundler utils.',
134
+ scriptPath: import.meta.resolve('./scripts/019-remove-glob-dependency.js'),
135
+ },
130
136
  // Do not use LEGACY_UPDATE_CUTOFF_VERSION for new migrations. It is only used above to force migrations to run
131
137
  // for those written before the switch to updates as migrations.
132
138
  //
133
- // New migrations never pick a version: copy the unreleased version line from ../AGENTS.md exactly.
139
+ // New migrations should never specify a version: copy the unreleased version line from ../AGENTS.md exactly.
134
140
  // Do not paste it into a comment in this file, as release-please rewrites every line that carries it.
135
141
  ] satisfies Migration[];
@@ -0,0 +1,146 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+ import migrate from './019-remove-glob-dependency.js';
4
+ import { createDefaultContext } from '../../test-utils.js';
5
+
6
+ const BUNDLER_UTILS_PATH = '.config/bundler/utils.ts';
7
+ const WEBPACK_UTILS_PATH = '.config/webpack/utils.ts';
8
+
9
+ const CURRENT_UTILS = readFileSync(
10
+ new URL('../../../../templates/common/.config/bundler/utils.ts', import.meta.url),
11
+ 'utf-8'
12
+ );
13
+
14
+ const NEW_GLOB_IMPORT = "import { glob } from 'node:fs/promises';";
15
+ const CONSTANTS_IMPORT = "import { SOURCE_DIR } from './constants.ts';";
16
+
17
+ // The migration appends the new import after the existing ones, so it lands below the constants import.
18
+ const MIGRATED_UTILS = CURRENT_UTILS.replace(`${NEW_GLOB_IMPORT}\n`, '').replace(
19
+ CONSTANTS_IMPORT,
20
+ `${CONSTANTS_IMPORT}\n${NEW_GLOB_IMPORT}`
21
+ );
22
+
23
+ // The template's utils.ts as scaffolded before the glob package was removed.
24
+ const SCAFFOLDED_UTILS = CURRENT_UTILS.replace(
25
+ "import { glob } from 'node:fs/promises';",
26
+ "import { glob } from 'glob';"
27
+ ).replace(
28
+ /export async function getEntries\(\) \{[\s\S]*?\n {2}return plugins\.reduce/,
29
+ `export async function getEntries() {
30
+ const pluginsJson = await glob('**/src/**/plugin.json', { absolute: true });
31
+
32
+ const plugins = await Promise.all(
33
+ pluginsJson.map((pluginJson) => {
34
+ const folder = path.dirname(pluginJson);
35
+ return glob(\`\${folder}/module.{ts,tsx,js,jsx}\`, { absolute: true });
36
+ })
37
+ );
38
+
39
+ return plugins.reduce`
40
+ );
41
+
42
+ function createContextWithGlob(utilsPath = BUNDLER_UTILS_PATH, utilsContent = SCAFFOLDED_UTILS) {
43
+ const context = createDefaultContext();
44
+ context.addFile(utilsPath, utilsContent);
45
+ context.updateFile(
46
+ 'package.json',
47
+ JSON.stringify({ devDependencies: { '@grafana/tsconfig': '^2.0.1', glob: '^11.1.0' } }, null, 2)
48
+ );
49
+
50
+ return context;
51
+ }
52
+
53
+ function getDevDependencies(context: ReturnType<typeof createDefaultContext>) {
54
+ return JSON.parse(context.getFile('package.json') ?? '{}').devDependencies;
55
+ }
56
+
57
+ describe('Migration - remove glob dependency', () => {
58
+ it('should be idempotent', async () => {
59
+ const context = createContextWithGlob();
60
+
61
+ await expect(migrate).toBeIdempotent(context);
62
+ });
63
+
64
+ it('should migrate the scaffolded utils to the current template', () => {
65
+ const context = migrate(createContextWithGlob());
66
+
67
+ expect(context.getFile(BUNDLER_UTILS_PATH)).toBe(MIGRATED_UTILS);
68
+ });
69
+
70
+ it('should migrate the legacy webpack utils location', () => {
71
+ const context = migrate(createContextWithGlob(WEBPACK_UTILS_PATH));
72
+
73
+ expect(context.getFile(WEBPACK_UTILS_PATH)).toBe(MIGRATED_UTILS);
74
+ });
75
+
76
+ it('should remove glob from package.json', () => {
77
+ const context = migrate(createContextWithGlob());
78
+
79
+ expect(getDevDependencies(context)).toEqual({ '@grafana/tsconfig': '^2.0.1' });
80
+ });
81
+
82
+ it('should migrate a customised getEntries that calls glob', () => {
83
+ const customisedUtils = SCAFFOLDED_UTILS.replace(
84
+ '{ absolute: true });\n\n const plugins',
85
+ "{ absolute: true, ignore: ['node_modules/**'] });\n\n const plugins"
86
+ );
87
+ const context = migrate(createContextWithGlob(BUNDLER_UTILS_PATH, customisedUtils));
88
+
89
+ expect(context.getFile(BUNDLER_UTILS_PATH)).toBe(MIGRATED_UTILS);
90
+ });
91
+
92
+ it('should skip a getEntries that does not call glob', () => {
93
+ const noGlobCallUtils = SCAFFOLDED_UTILS.replace(
94
+ "const pluginsJson = await glob('**/src/**/plugin.json', { absolute: true });",
95
+ "const pluginsJson = ['src/plugin.json'];"
96
+ ).replace('return glob(', 'return Promise.resolve(');
97
+ const context = createContextWithGlob(BUNDLER_UTILS_PATH, noGlobCallUtils);
98
+
99
+ migrate(context);
100
+
101
+ expect(context.getFile(BUNDLER_UTILS_PATH)).toBe(noGlobCallUtils);
102
+ expect(getDevDependencies(context).glob).toBe('^11.1.0');
103
+ });
104
+
105
+ it('should skip an aliased glob import', () => {
106
+ const aliasedUtils = SCAFFOLDED_UTILS.replace("import { glob } from 'glob';", "import { glob as g } from 'glob';")
107
+ .replaceAll('await glob(', 'await g(')
108
+ .replace('return glob(', 'return g(');
109
+ const context = createContextWithGlob(BUNDLER_UTILS_PATH, aliasedUtils);
110
+
111
+ migrate(context);
112
+
113
+ expect(context.getFile(BUNDLER_UTILS_PATH)).toBe(aliasedUtils);
114
+ expect(getDevDependencies(context).glob).toBe('^11.1.0');
115
+ });
116
+
117
+ it('should keep other glob specifiers and the dependency', () => {
118
+ const mixedUtils = SCAFFOLDED_UTILS.replace(
119
+ "import { glob } from 'glob';",
120
+ "import { glob, globSync } from 'glob';"
121
+ );
122
+ const context = migrate(createContextWithGlob(BUNDLER_UTILS_PATH, mixedUtils));
123
+ const migrated = context.getFile(BUNDLER_UTILS_PATH);
124
+
125
+ expect(migrated).toContain("import { globSync } from 'glob';");
126
+ expect(migrated).toContain("import { glob } from 'node:fs/promises';");
127
+ expect(migrated).toContain("for await (const pluginJson of glob('**/src/**/plugin.json'))");
128
+ expect(getDevDependencies(context).glob).toBe('^11.1.0');
129
+ });
130
+
131
+ it('should leave files without a glob import unchanged', () => {
132
+ const context = createContextWithGlob(BUNDLER_UTILS_PATH, CURRENT_UTILS);
133
+
134
+ migrate(context);
135
+
136
+ expect(context.getFile(BUNDLER_UTILS_PATH)).toBe(CURRENT_UTILS);
137
+ expect(getDevDependencies(context).glob).toBe('^11.1.0');
138
+ });
139
+
140
+ it('should not fail when the utils file cannot be parsed', () => {
141
+ const context = createContextWithGlob(BUNDLER_UTILS_PATH, "import { glob } from 'glob';\nexport function {");
142
+
143
+ expect(() => migrate(context)).not.toThrow();
144
+ expect(getDevDependencies(context).glob).toBe('^11.1.0');
145
+ });
146
+ });
@@ -0,0 +1,121 @@
1
+ import type { Context } from '../../context.js';
2
+ import * as recast from 'recast';
3
+ import {
4
+ callsFunction,
5
+ createImport,
6
+ findImports,
7
+ findFunctionDeclaration,
8
+ findNamedImport,
9
+ insertImports,
10
+ parseAsTypescript,
11
+ printAST,
12
+ } from '../../utils.ast.js';
13
+ import { migrationsDebug, removeDependenciesFromPackageJson } from '../../utils.js';
14
+
15
+ const OLD_PACKAGE_NAME = 'glob';
16
+ const NEW_PACKAGE_NAME = 'node:fs/promises';
17
+
18
+ const UTILS_PATHS = ['.config/webpack/utils.ts', '.config/bundler/utils.ts'];
19
+
20
+ const GET_ENTRIES_SOURCE = `export async function getEntries() {
21
+ const plugins: string[][] = [];
22
+ for await (const pluginJson of glob('**/src/**/plugin.json')) {
23
+ const folder = path.dirname(pluginJson);
24
+ const modules: string[] = [];
25
+ for await (const module of glob(\`\${folder}/module.{ts,tsx,js,jsx}\`)) {
26
+ modules.push(path.resolve(module));
27
+ }
28
+ plugins.push(modules);
29
+ }
30
+
31
+ return plugins.reduce<Record<string, string>>((result, modules) => {
32
+ return modules.reduce((innerResult, module) => {
33
+ const pluginPath = path.dirname(module);
34
+ const pluginName = path.relative(process.cwd(), pluginPath).replace(/src\\/?/i, '');
35
+ const entryName = pluginName === '' ? 'module' : \`\${pluginName}/module\`;
36
+
37
+ innerResult[entryName] = module;
38
+ return innerResult;
39
+ }, result);
40
+ }, {});
41
+ }`;
42
+
43
+ export default function migrate(context: Context) {
44
+ const updatedPaths = UTILS_PATHS.filter((filePath) => replaceGlobWithFsGlob(context, filePath));
45
+ if (updatedPaths.length === 0) {
46
+ return context;
47
+ }
48
+
49
+ const isStillUsed = UTILS_PATHS.some((filePath) => {
50
+ const source = context.getFile(filePath);
51
+ if (!source) {
52
+ return false;
53
+ }
54
+ const parsed = parseAsTypescript(source);
55
+ return !parsed.success || findImports(parsed.ast, OLD_PACKAGE_NAME).length > 0;
56
+ });
57
+
58
+ if (context.doesFileExist('package.json') && !isStillUsed) {
59
+ removeDependenciesFromPackageJson(context, [OLD_PACKAGE_NAME], [OLD_PACKAGE_NAME]);
60
+ }
61
+
62
+ return context;
63
+ }
64
+
65
+ function replaceGlobWithFsGlob(context: Context, utilsPath: string): boolean {
66
+ if (!context.doesFileExist(utilsPath)) {
67
+ return false;
68
+ }
69
+
70
+ const source = context.getFile(utilsPath);
71
+ if (!source || !source.includes(OLD_PACKAGE_NAME)) {
72
+ return false;
73
+ }
74
+
75
+ const parsed = parseAsTypescript(source);
76
+ if (!parsed.success) {
77
+ migrationsDebug(`Failed to parse ${utilsPath}. Error: ${parsed.error.message}`);
78
+ return false;
79
+ }
80
+
81
+ const globImport = findNamedImport(parsed.ast, OLD_PACKAGE_NAME, 'glob');
82
+ if (!globImport) {
83
+ return false;
84
+ }
85
+
86
+ const getEntries = findFunctionDeclaration(parsed.ast, 'getEntries');
87
+ if (!getEntries) {
88
+ return false;
89
+ }
90
+
91
+ // The new body calls glob directly, so an aliased import (and its calls) is left alone.
92
+ if (!callsFunction(getEntries.body, 'glob')) {
93
+ return false;
94
+ }
95
+
96
+ const { declaration, specifier } = globImport;
97
+ declaration.specifiers = (declaration.specifiers ?? []).filter((candidate) => candidate !== specifier);
98
+ if (declaration.specifiers.length === 0) {
99
+ parsed.ast.program.body.splice(parsed.ast.program.body.indexOf(declaration), 1);
100
+ }
101
+ insertImports(parsed.ast, [createImport({ named: [{ name: 'glob' }] }, NEW_PACKAGE_NAME)]);
102
+
103
+ getEntries.body = getFunctionBody(GET_ENTRIES_SOURCE);
104
+
105
+ context.updateFile(utilsPath, printAST(parsed.ast));
106
+ return true;
107
+ }
108
+
109
+ function getFunctionBody(source: string): recast.types.namedTypes.BlockStatement {
110
+ const parsed = parseAsTypescript(source);
111
+ if (!parsed.success) {
112
+ throw parsed.error;
113
+ }
114
+
115
+ const [statement] = parsed.ast.program.body;
116
+ if (statement?.type !== 'ExportNamedDeclaration' || statement.declaration?.type !== 'FunctionDeclaration') {
117
+ throw new Error('Failed to parse target function body');
118
+ }
119
+
120
+ return statement.declaration.body;
121
+ }
@@ -7,6 +7,10 @@ import {
7
7
  findObjectProperty,
8
8
  createImport,
9
9
  insertImports,
10
+ findImports,
11
+ findNamedImport,
12
+ findFunctionDeclaration,
13
+ callsFunction,
10
14
  } from './utils.ast.js';
11
15
 
12
16
  // Helper to create a simple test AST from source code
@@ -31,6 +35,58 @@ function findFirstNode<T extends recast.types.namedTypes.Node>(ast: recast.types
31
35
  }
32
36
 
33
37
  describe('utils.ast', () => {
38
+ describe('Declaration Utilities', () => {
39
+ describe('findFunctionDeclaration', () => {
40
+ it('should find a function declaration by name', () => {
41
+ const ast = createTestAST(`function foo() {}\nfunction bar() {}`);
42
+
43
+ const declaration = findFunctionDeclaration(ast, 'bar');
44
+
45
+ expect(declaration?.id?.name).toBe('bar');
46
+ });
47
+
48
+ it('should find an exported function declaration', () => {
49
+ const ast = createTestAST(`export async function getEntries() {}`);
50
+
51
+ const declaration = findFunctionDeclaration(ast, 'getEntries');
52
+
53
+ expect(declaration?.async).toBe(true);
54
+ });
55
+
56
+ it('should return null when the function is not declared', () => {
57
+ const ast = createTestAST(`const getEntries = () => {};`);
58
+
59
+ expect(findFunctionDeclaration(ast, 'getEntries')).toBeNull();
60
+ });
61
+ });
62
+
63
+ describe('callsFunction', () => {
64
+ it('should return true when the function is called', () => {
65
+ const ast = createTestAST(`async function run() { const files = await glob('**/*.ts'); }`);
66
+
67
+ expect(callsFunction(ast, 'glob')).toBe(true);
68
+ });
69
+
70
+ it('should return true for nested calls', () => {
71
+ const ast = createTestAST(`items.map((item) => wrap(glob(item)));`);
72
+
73
+ expect(callsFunction(ast, 'glob')).toBe(true);
74
+ });
75
+
76
+ it('should return false when the function is only referenced', () => {
77
+ const ast = createTestAST(`const fn = glob;`);
78
+
79
+ expect(callsFunction(ast, 'glob')).toBe(false);
80
+ });
81
+
82
+ it('should return false for a method with the same name', () => {
83
+ const ast = createTestAST(`fs.glob('**/*.ts');`);
84
+
85
+ expect(callsFunction(ast, 'glob')).toBe(false);
86
+ });
87
+ });
88
+ });
89
+
34
90
  describe('Property Utilities', () => {
35
91
  describe('isProperty', () => {
36
92
  it('should return true for Property nodes', () => {
@@ -191,5 +247,100 @@ describe('utils.ast', () => {
191
247
  expect(output).toBe(`import React from 'react';\nimport { map } from 'lodash';\nconst x = 1;`);
192
248
  });
193
249
  });
250
+
251
+ describe('findImports', () => {
252
+ it('should return an empty array when the package is not imported', () => {
253
+ const ast = createTestAST(`import React from 'react';\nconst x = 1;`);
254
+
255
+ expect(findImports(ast, 'glob')).toEqual([]);
256
+ });
257
+
258
+ it('should return the matching import declaration', () => {
259
+ const ast = createTestAST(`import React from 'react';\nimport { glob } from 'glob';`);
260
+
261
+ const imports = findImports(ast, 'glob');
262
+
263
+ expect(imports).toHaveLength(1);
264
+ expect(imports[0].source.value).toBe('glob');
265
+ });
266
+
267
+ it('should return every declaration importing the package', () => {
268
+ const ast = createTestAST(`import { glob } from 'glob';\nimport type { GlobOptions } from 'glob';`);
269
+
270
+ expect(findImports(ast, 'glob')).toHaveLength(2);
271
+ });
272
+
273
+ it('should not match packages with a similar name', () => {
274
+ const ast = createTestAST(`import globParent from 'glob-parent';\nimport { glob } from 'node:fs/promises';`);
275
+
276
+ expect(findImports(ast, 'glob')).toEqual([]);
277
+ });
278
+ });
279
+
280
+ describe('findNamedImport', () => {
281
+ it('should find a named import', () => {
282
+ const ast = createTestAST(`import { glob } from 'glob';`);
283
+
284
+ const match = findNamedImport(ast, 'glob', 'glob');
285
+
286
+ expect(match).toBeDefined();
287
+ expect(match?.declaration.source.value).toBe('glob');
288
+ expect(match?.specifier.imported.name).toBe('glob');
289
+ expect(match?.specifier.local?.name).toBe('glob');
290
+ });
291
+
292
+ it('should find an aliased named import by its imported name', () => {
293
+ const ast = createTestAST(`import { glob as g } from 'glob';`);
294
+
295
+ const match = findNamedImport(ast, 'glob', 'glob');
296
+
297
+ expect(match).toBeDefined();
298
+ expect(match?.specifier.local?.name).toBe('g');
299
+ });
300
+
301
+ it('should find the specifier among other specifiers', () => {
302
+ const ast = createTestAST(`import { globSync, glob } from 'glob';`);
303
+
304
+ const match = findNamedImport(ast, 'glob', 'glob');
305
+
306
+ expect(match).toBeDefined();
307
+ expect(match?.declaration.specifiers).toHaveLength(2);
308
+ expect(match?.specifier.imported.name).toBe('glob');
309
+ });
310
+
311
+ it('should find the specifier in a later declaration of the same package', () => {
312
+ const ast = createTestAST(`import type { GlobOptions } from 'glob';\nimport { glob } from 'glob';`);
313
+ const [, secondDeclaration] = findImports(ast, 'glob');
314
+
315
+ const match = findNamedImport(ast, 'glob', 'glob');
316
+
317
+ expect(match).toBeDefined();
318
+ expect(match?.declaration).toBe(secondDeclaration);
319
+ });
320
+
321
+ it('should return undefined when only other named imports exist', () => {
322
+ const ast = createTestAST(`import { globSync } from 'glob';`);
323
+
324
+ expect(findNamedImport(ast, 'glob', 'glob')).toBeUndefined();
325
+ });
326
+
327
+ it('should return undefined for a default import', () => {
328
+ const ast = createTestAST(`import glob from 'glob';`);
329
+
330
+ expect(findNamedImport(ast, 'glob', 'glob')).toBeUndefined();
331
+ });
332
+
333
+ it('should return undefined for a namespace import', () => {
334
+ const ast = createTestAST(`import * as glob from 'glob';`);
335
+
336
+ expect(findNamedImport(ast, 'glob', 'glob')).toBeUndefined();
337
+ });
338
+
339
+ it('should return undefined when the package is not imported', () => {
340
+ const ast = createTestAST(`import { glob } from 'node:fs/promises';`);
341
+
342
+ expect(findNamedImport(ast, 'glob', 'glob')).toBeUndefined();
343
+ });
344
+ });
194
345
  });
195
346
  });
@@ -45,6 +45,42 @@ export function findVariableDeclaration(
45
45
  return found;
46
46
  }
47
47
 
48
+ export function findFunctionDeclaration(
49
+ ast: recast.types.ASTNode,
50
+ functionName: string
51
+ ): recast.types.namedTypes.FunctionDeclaration | null {
52
+ let found = null;
53
+
54
+ recast.types.visit(ast, {
55
+ visitFunctionDeclaration(path) {
56
+ if (path.node.id?.name === functionName) {
57
+ found = path.node;
58
+ return false; // Stop traversal
59
+ }
60
+ return this.traverse(path);
61
+ },
62
+ });
63
+
64
+ return found;
65
+ }
66
+
67
+ export function callsFunction(ast: recast.types.ASTNode, functionName: string): boolean {
68
+ let isCalled = false;
69
+
70
+ recast.types.visit(ast, {
71
+ visitCallExpression(path) {
72
+ const { callee } = path.node;
73
+ if (callee.type === 'Identifier' && callee.name === functionName) {
74
+ isCalled = true;
75
+ return false; // Stop traversal
76
+ }
77
+ return this.traverse(path);
78
+ },
79
+ });
80
+
81
+ return isCalled;
82
+ }
83
+
48
84
  export function isProperty(
49
85
  node: recast.types.ASTNode
50
86
  ): node is recast.types.namedTypes.Property | recast.types.namedTypes.ObjectProperty {
@@ -108,3 +144,27 @@ export function insertImports(
108
144
  ast.program.body.unshift(...imports);
109
145
  }
110
146
  }
147
+
148
+ export function findImports(
149
+ ast: recast.types.namedTypes.File,
150
+ packageName: string
151
+ ): recast.types.namedTypes.ImportDeclaration[] {
152
+ return ast.program.body.filter(
153
+ (node): node is recast.types.namedTypes.ImportDeclaration =>
154
+ node.type === 'ImportDeclaration' && node.source.value === packageName
155
+ );
156
+ }
157
+
158
+ export function findNamedImport(ast: recast.types.namedTypes.File, packageName: string, importedName: string) {
159
+ for (const declaration of findImports(ast, packageName)) {
160
+ const specifier = declaration.specifiers?.find(
161
+ (candidate): candidate is recast.types.namedTypes.ImportSpecifier =>
162
+ candidate.type === 'ImportSpecifier' && candidate.imported.name === importedName
163
+ );
164
+ if (specifier) {
165
+ return { declaration, specifier };
166
+ }
167
+ }
168
+
169
+ return undefined;
170
+ }
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import process from 'process';
3
3
  import os from 'os';
4
4
  import path from 'path';
5
- import { glob } from 'glob';
5
+ import { glob } from 'node:fs/promises';
6
6
  import { SOURCE_DIR } from './constants.ts';
7
7
 
8
8
  export function isWSL() {
@@ -46,14 +46,15 @@ export function hasReadme() {
46
46
  // Support bundling nested plugins by finding all plugin.json files in src directory
47
47
  // then checking for a sibling module.[jt]sx? file.
48
48
  export async function getEntries() {
49
- const pluginsJson = await glob('**/src/**/plugin.json', { absolute: true });
50
-
51
- const plugins = await Promise.all(
52
- pluginsJson.map((pluginJson) => {
53
- const folder = path.dirname(pluginJson);
54
- return glob(`${folder}/module.{ts,tsx,js,jsx}`, { absolute: true });
55
- })
56
- );
49
+ const plugins: string[][] = [];
50
+ for await (const pluginJson of glob('**/src/**/plugin.json')) {
51
+ const folder = path.dirname(pluginJson);
52
+ const modules: string[] = [];
53
+ for await (const module of glob(`${folder}/module.{ts,tsx,js,jsx}`)) {
54
+ modules.push(path.resolve(module));
55
+ }
56
+ plugins.push(modules);
57
+ }
57
58
 
58
59
  return plugins.reduce<Record<string, string>>((result, modules) => {
59
60
  return modules.reduce((innerResult, module) => {
@@ -17,7 +17,7 @@
17
17
  "license": "Apache-2.0",
18
18
  "devDependencies": {
19
19
  "@grafana/eslint-config": "^10.0.0",
20
- "@grafana/plugin-e2e": "^3.15.1",
20
+ "@grafana/plugin-e2e": "^3.15.2",
21
21
  "@grafana/sign-plugin": "^3.3.3",
22
22
  "@grafana/tsconfig": "^2.2.0",
23
23
  "@playwright/test": "^1.62.1",{{#if useExperimentalRspack}}
@@ -46,7 +46,6 @@
46
46
  "eslint-plugin-react-hooks": "^7.1.0",{{#if useExperimentalRspack}}
47
47
  "eslint-webpack-plugin": "^5.0.0",{{/if}}{{#unless useExperimentalRspack}}
48
48
  "fork-ts-checker-webpack-plugin": "^9.1.0",{{/unless}}
49
- "glob": "^11.1.0",
50
49
  "identity-obj-proxy": "^3.0.0",
51
50
  "imports-loader": "^5.0.0",
52
51
  "intersection-observer": "^0.12.2",