@grafana/create-plugin 7.12.3 → 7.14.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 (93) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/codemods/additions/additions.js +20 -7
  3. package/dist/codemods/additions/prompts/docs-content.md +119 -0
  4. package/dist/codemods/additions/prompts/example-prompt.md +56 -0
  5. package/dist/codemods/additions/scripts/docs-content.js +30 -0
  6. package/dist/codemods/additions/scripts/docs.js +250 -0
  7. package/dist/codemods/additions/scripts/experimental-app-sdk.js +39 -33
  8. package/dist/codemods/agentic/definitions.js +31 -0
  9. package/dist/codemods/agentic/detect.js +57 -0
  10. package/dist/codemods/agentic/handoff.js +35 -0
  11. package/dist/codemods/agentic/index.js +143 -0
  12. package/dist/codemods/agentic/prompts.js +115 -0
  13. package/dist/codemods/agentic/resolve.js +120 -0
  14. package/dist/codemods/agentic/runner.js +79 -0
  15. package/dist/codemods/agentic/verify.js +55 -0
  16. package/dist/codemods/migrations/migrations.js +12 -0
  17. package/dist/codemods/migrations/scripts/017-jest-30.js +32 -0
  18. package/dist/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.js +115 -0
  19. package/dist/codemods/utils.agents.js +64 -0
  20. package/dist/codemods/utils.js +51 -8
  21. package/dist/codemods/utils.workflows.js +94 -0
  22. package/dist/commands/add.command.js +74 -12
  23. package/dist/utils/utils.cli.js +7 -2
  24. package/dist/utils/utils.console.js +10 -1
  25. package/dist/utils/utils.git.js +10 -1
  26. package/dist/utils/utils.handlebars.js +9 -2
  27. package/dist/utils/utils.packageManager.js +2 -2
  28. package/dist/utils/utils.templates.js +38 -24
  29. package/package.json +1 -1
  30. package/src/codemods/AGENTS.md +25 -0
  31. package/src/codemods/additions/additions.test.ts +69 -4
  32. package/src/codemods/additions/additions.ts +50 -8
  33. package/src/codemods/additions/prompts/docs-content.md +119 -0
  34. package/src/codemods/additions/prompts/example-prompt.md +56 -0
  35. package/src/codemods/additions/scripts/docs-content.test.ts +94 -0
  36. package/src/codemods/additions/scripts/docs-content.ts +32 -0
  37. package/src/codemods/additions/scripts/docs.test.ts +1037 -0
  38. package/src/codemods/additions/scripts/docs.ts +353 -0
  39. package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +40 -80
  40. package/src/codemods/additions/scripts/experimental-app-sdk.ts +42 -34
  41. package/src/codemods/agentic/definitions.test.ts +67 -0
  42. package/src/codemods/agentic/definitions.ts +33 -0
  43. package/src/codemods/agentic/detect.test.ts +136 -0
  44. package/src/codemods/agentic/detect.ts +75 -0
  45. package/src/codemods/agentic/handoff.test.ts +102 -0
  46. package/src/codemods/agentic/handoff.ts +45 -0
  47. package/src/codemods/agentic/index.test.ts +342 -0
  48. package/src/codemods/agentic/index.ts +221 -0
  49. package/src/codemods/agentic/prompts.test.ts +233 -0
  50. package/src/codemods/agentic/prompts.ts +166 -0
  51. package/src/codemods/agentic/resolve.test.ts +220 -0
  52. package/src/codemods/agentic/resolve.ts +165 -0
  53. package/src/codemods/agentic/runner.test.ts +146 -0
  54. package/src/codemods/agentic/runner.ts +103 -0
  55. package/src/codemods/agentic/types.ts +50 -0
  56. package/src/codemods/agentic/verify.test.ts +106 -0
  57. package/src/codemods/agentic/verify.ts +93 -0
  58. package/src/codemods/migrations/migrations.ts +14 -0
  59. package/src/codemods/migrations/scripts/004-eslint9-flat-config.ts +1 -3
  60. package/src/codemods/migrations/scripts/017-jest-30.test.ts +72 -0
  61. package/src/codemods/migrations/scripts/017-jest-30.ts +39 -0
  62. package/src/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.test.ts +174 -0
  63. package/src/codemods/migrations/scripts/018-replace-eslint-webpack-plugin.ts +141 -0
  64. package/src/codemods/utils.agents.test.ts +140 -0
  65. package/src/codemods/utils.agents.ts +81 -0
  66. package/src/codemods/utils.test.ts +131 -1
  67. package/src/codemods/utils.ts +79 -8
  68. package/src/codemods/utils.workflows.test.ts +153 -0
  69. package/src/codemods/utils.workflows.ts +140 -0
  70. package/src/commands/add.command.ts +92 -9
  71. package/src/utils/utils.cli.ts +5 -0
  72. package/src/utils/utils.git.test.ts +57 -0
  73. package/src/utils/utils.git.ts +25 -0
  74. package/src/utils/utils.handlebars.ts +9 -0
  75. package/src/utils/utils.packageManager.test.ts +23 -0
  76. package/src/utils/utils.packageManager.ts +3 -2
  77. package/src/utils/utils.templates.ts +50 -25
  78. package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +1 -1
  79. package/templates/common/.config/webpack/webpack.config.ts +4 -5
  80. package/templates/common/_package.json +9 -8
  81. package/templates/docs/common/workflows/validate-docs.yml +52 -0
  82. package/templates/docs/panel/docs/AGENTS.md +107 -0
  83. package/templates/docs/panel/docs/CLAUDE.md +1 -0
  84. package/templates/docs/panel/docs/README.md +20 -0
  85. package/templates/docs/panel/docs/data-formats.md +27 -0
  86. package/templates/docs/panel/docs/examples.md +35 -0
  87. package/templates/docs/panel/docs/index.md +26 -0
  88. package/templates/docs/panel/docs/options.md +53 -0
  89. package/templates/docs/panel/docs/troubleshooting.md +35 -0
  90. package/templates/github/workflows/bundle-stats.yml +1 -1
  91. package/templates/github/workflows/ci.yml +7 -7
  92. package/templates/github/workflows/is-compatible.yml +3 -3
  93. package/templates/github/workflows/release.yml +1 -1
@@ -159,9 +159,7 @@ function addConfigsToMigration(imports: Imports, config: Linter.ConfigOverride)
159
159
  function generateAST(
160
160
  value: Object
161
161
  ):
162
- | recast.types.namedTypes.ObjectExpression
163
- | recast.types.namedTypes.ArrayExpression
164
- | recast.types.namedTypes.Literal {
162
+ recast.types.namedTypes.ObjectExpression | recast.types.namedTypes.ArrayExpression | recast.types.namedTypes.Literal {
165
163
  if (Array.isArray(value)) {
166
164
  return builders.arrayExpression(value.map((i) => generateAST(i)));
167
165
  }
@@ -0,0 +1,72 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import migrate from './017-jest-30.js';
3
+ import { Context } from '../../context.js';
4
+ import { output } from '../../../utils/utils.console.js';
5
+
6
+ function setupContext(devDependencies: Record<string, string>) {
7
+ const context = new Context('/virtual');
8
+ context.addFile('package.json', JSON.stringify({ devDependencies }));
9
+ return context;
10
+ }
11
+
12
+ describe('017-jest-30', () => {
13
+ beforeEach(() => {
14
+ vi.spyOn(output, 'warning').mockImplementation(() => {});
15
+ });
16
+
17
+ afterEach(() => {
18
+ vi.restoreAllMocks();
19
+ });
20
+
21
+ it('should bump jest, jest-environment-jsdom and @types/jest to v30', () => {
22
+ const result = migrate(
23
+ setupContext({ jest: '^29.7.0', 'jest-environment-jsdom': '^29.7.0', '@types/jest': '^29.5.0' })
24
+ );
25
+ const packageJson = JSON.parse(result.getFile('package.json') || '{}');
26
+
27
+ expect(packageJson.devDependencies).toEqual({
28
+ '@types/jest': '^30.0.0',
29
+ jest: '^30.5.0',
30
+ 'jest-environment-jsdom': '^30.5.0',
31
+ });
32
+ });
33
+
34
+ it('should not add jest packages the plugin does not use', () => {
35
+ const result = migrate(setupContext({ jest: '^29.7.0' }));
36
+ const packageJson = JSON.parse(result.getFile('package.json') || '{}');
37
+
38
+ expect(packageJson.devDependencies).toEqual({ jest: '^30.5.0' });
39
+ });
40
+
41
+ it('should not downgrade newer versions', () => {
42
+ const result = migrate(setupContext({ jest: '^31.0.0' }));
43
+ const packageJson = JSON.parse(result.getFile('package.json') || '{}');
44
+
45
+ expect(packageJson.devDependencies).toEqual({ jest: '^31.0.0' });
46
+ });
47
+
48
+ it('should not modify anything when jest is not installed', () => {
49
+ const result = migrate(setupContext({ vitest: '^4.0.0' }));
50
+ const packageJson = JSON.parse(result.getFile('package.json') || '{}');
51
+
52
+ expect(packageJson.devDependencies).toEqual({ vitest: '^4.0.0' });
53
+ });
54
+
55
+ it('should warn about breaking changes when jest is bumped', () => {
56
+ migrate(setupContext({ jest: '^29.7.0' }));
57
+
58
+ expect(output.warning).toHaveBeenCalledWith(expect.objectContaining({ title: 'Jest was updated to v30' }));
59
+ });
60
+
61
+ it('should not warn when jest is already on v30', () => {
62
+ migrate(setupContext({ jest: '^30.5.0' }));
63
+
64
+ expect(output.warning).not.toHaveBeenCalled();
65
+ });
66
+
67
+ it('should be idempotent', async () => {
68
+ await expect(migrate).toBeIdempotent(
69
+ setupContext({ jest: '^29.7.0', 'jest-environment-jsdom': '^29.7.0', '@types/jest': '^29.5.0' })
70
+ );
71
+ });
72
+ });
@@ -0,0 +1,39 @@
1
+ import type { Context } from '../../context.js';
2
+ import { addDependenciesToPackageJson, readJsonFile } from '../../utils.js';
3
+ import { output } from '../../../utils/utils.console.js';
4
+
5
+ const JEST_30_DEPENDENCIES: Record<string, string> = {
6
+ jest: '^30.5.0',
7
+ 'jest-environment-jsdom': '^30.5.0',
8
+ '@types/jest': '^30.0.0',
9
+ };
10
+
11
+ // Jest 29 depends on micromatch, which pulls in braces. Jest 30 uses picomatch instead.
12
+ export default function migrate(context: Context) {
13
+ if (!context.doesFileExist('package.json')) {
14
+ return context;
15
+ }
16
+
17
+ const packageJson = readJsonFile(context, 'package.json');
18
+ const installed = { ...packageJson.dependencies, ...packageJson.devDependencies };
19
+
20
+ // Only bump the jest packages the plugin already has.
21
+ const devDependencies = Object.fromEntries(
22
+ Object.entries(JEST_30_DEPENDENCIES).filter(([name]) => Boolean(installed[name]))
23
+ );
24
+
25
+ const before = context.getFile('package.json');
26
+ addDependenciesToPackageJson(context, {}, devDependencies);
27
+
28
+ if (context.getFile('package.json') !== before) {
29
+ output.warning({
30
+ title: 'Jest was updated to v30',
31
+ body: [
32
+ 'Jest 30 is a major release, so existing tests may need small fixes (jsdom 26, removed matcher aliases like `toBeCalled`).',
33
+ 'See https://jestjs.io/docs/upgrading-to-jest30 if `npm run test:ci` fails after the update.',
34
+ ],
35
+ });
36
+ }
37
+
38
+ return context;
39
+ }
@@ -0,0 +1,174 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import migrate from './018-replace-eslint-webpack-plugin.js';
3
+ import { Context } from '../../context.js';
4
+ import { output } from '../../../utils/utils.console.js';
5
+
6
+ const WEBPACK_CONFIG_PATH = '.config/webpack/webpack.config.ts';
7
+ const RSPACK_CONFIG_PATH = '.config/rspack/rspack.config.ts';
8
+
9
+ const WEBPACK_CONFIG = `import ESLintPlugin from 'eslint-webpack-plugin';
10
+ import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
11
+ import { type Configuration } from 'webpack';
12
+ import LiveReloadPlugin from 'webpack-livereload-plugin';
13
+
14
+ const config = async (env: Record<string, unknown>): Promise<Configuration> => ({
15
+ plugins: [
16
+ ...(env.development ? [
17
+ new LiveReloadPlugin(),
18
+ new ForkTsCheckerWebpackPlugin({
19
+ async: Boolean(env.development),
20
+ }),
21
+ new ESLintPlugin({
22
+ extensions: ['.ts', '.tsx'],
23
+ lintDirtyModulesOnly: Boolean(env.development), // don't lint on start, only lint changed files
24
+ failOnError: Boolean(env.production),
25
+ }),
26
+ ] : []),
27
+ ],
28
+ });
29
+
30
+ export default config;
31
+ `;
32
+
33
+ const RSPACK_CONFIG = `import rspack, { type Configuration } from '@rspack/core';
34
+ import ESLintPlugin from 'eslint-webpack-plugin';
35
+ import { TsCheckerRspackPlugin } from 'ts-checker-rspack-plugin';
36
+
37
+ const config = async (env: Record<string, unknown>): Promise<Configuration> => ({
38
+ plugins: [
39
+ ...(env.development
40
+ ? [
41
+ new TsCheckerRspackPlugin({
42
+ async: Boolean(env.development),
43
+ }),
44
+ new ESLintPlugin({
45
+ extensions: ['.ts', '.tsx'],
46
+ lintDirtyModulesOnly: Boolean(env.development), // don't lint on start, only lint changed files
47
+ }),
48
+ ]
49
+ : []),
50
+ ],
51
+ });
52
+
53
+ export default config;
54
+ `;
55
+
56
+ function setupContext(files: Record<string, string> = { [WEBPACK_CONFIG_PATH]: WEBPACK_CONFIG }) {
57
+ const context = new Context('/virtual');
58
+ context.addFile(
59
+ 'package.json',
60
+ JSON.stringify({ devDependencies: { 'eslint-webpack-plugin': '^5.0.0', webpack: '^5.101.0' } })
61
+ );
62
+ for (const [path, content] of Object.entries(files)) {
63
+ context.addFile(path, content);
64
+ }
65
+ return context;
66
+ }
67
+
68
+ describe('018-replace-eslint-webpack-plugin', () => {
69
+ beforeEach(() => {
70
+ vi.spyOn(output, 'warning').mockImplementation(() => {});
71
+ });
72
+
73
+ afterEach(() => {
74
+ vi.restoreAllMocks();
75
+ });
76
+
77
+ it('should replace the plugin in the webpack config', () => {
78
+ const result = migrate(setupContext());
79
+ const expected = WEBPACK_CONFIG.replace(
80
+ "import ESLintPlugin from 'eslint-webpack-plugin';",
81
+ "import DiagnosticsPlugin from 'diagnostics-webpack-plugin';"
82
+ ).replace(
83
+ /new ESLintPlugin\(\{\n(?:.*\n){3} {6}\}\)/,
84
+ `new DiagnosticsPlugin({
85
+ lintOnStart: false, // don't lint on start, only lint changed files
86
+ checks: [{ use: 'eslint', extensions: ['ts', 'tsx'] }],
87
+ })`
88
+ );
89
+
90
+ expect(result.getFile(WEBPACK_CONFIG_PATH)).toBe(expected);
91
+ });
92
+
93
+ it('should swap the dependency and bump webpack in package.json', () => {
94
+ const result = migrate(setupContext());
95
+ const packageJson = JSON.parse(result.getFile('package.json') || '{}');
96
+
97
+ expect(packageJson.devDependencies).toEqual({ 'diagnostics-webpack-plugin': '^1.0.1', webpack: '^5.106.0' });
98
+ });
99
+
100
+ it('should replace the plugin when it shares a line with other plugins', () => {
101
+ const result = migrate(
102
+ setupContext({
103
+ [WEBPACK_CONFIG_PATH]: `import ESLintPlugin from 'eslint-webpack-plugin';\nimport LiveReloadPlugin from 'webpack-livereload-plugin';\n\nexport default { plugins: [new LiveReloadPlugin(), new ESLintPlugin({ extensions: ['.ts', '.tsx'], lintDirtyModulesOnly: Boolean(env.development) })] };\n`,
104
+ })
105
+ );
106
+ const config = result.getFile(WEBPACK_CONFIG_PATH) || '';
107
+
108
+ expect(config).not.toContain('ESLintPlugin');
109
+ expect(config).toContain('[new LiveReloadPlugin(), new DiagnosticsPlugin({');
110
+ });
111
+
112
+ it('should replace the plugin when it has the older scaffolded options', () => {
113
+ const result = migrate(setupContext({ [WEBPACK_CONFIG_PATH]: WEBPACK_CONFIG.replace(/\n.*failOnError.*/, '') }));
114
+
115
+ expect(result.getFile(WEBPACK_CONFIG_PATH)).not.toContain('ESLintPlugin');
116
+ expect(output.warning).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it('should skip the replacement and warn when the options were customised', () => {
120
+ const customConfig = WEBPACK_CONFIG.replace("extensions: ['.ts', '.tsx'],", "extensions: ['.ts', '.tsx', '.js'],");
121
+ const result = migrate(setupContext({ [WEBPACK_CONFIG_PATH]: customConfig }));
122
+ const packageJson = JSON.parse(result.getFile('package.json') || '{}');
123
+
124
+ expect(result.getFile(WEBPACK_CONFIG_PATH)).toBe(customConfig);
125
+ expect(packageJson.devDependencies).toEqual({ 'eslint-webpack-plugin': '^5.0.0', webpack: '^5.101.0' });
126
+ expect(output.warning).toHaveBeenCalledWith(
127
+ expect.objectContaining({ title: expect.stringContaining('Skipping the eslint-webpack-plugin replacement') })
128
+ );
129
+ });
130
+
131
+ it('should skip the replacement when the plugin is called without options', () => {
132
+ const config = `import ESLintPlugin from 'eslint-webpack-plugin';\n\nexport default { plugins: [new ESLintPlugin()] };\n`;
133
+ const result = migrate(setupContext({ [WEBPACK_CONFIG_PATH]: config }));
134
+
135
+ expect(result.getFile(WEBPACK_CONFIG_PATH)).toBe(config);
136
+ });
137
+
138
+ it('should leave the rspack config alone', () => {
139
+ const result = migrate(setupContext({ [RSPACK_CONFIG_PATH]: RSPACK_CONFIG }));
140
+ const packageJson = JSON.parse(result.getFile('package.json') || '{}');
141
+
142
+ expect(result.getFile(RSPACK_CONFIG_PATH)).toBe(RSPACK_CONFIG);
143
+ expect(packageJson.devDependencies).toEqual({ 'eslint-webpack-plugin': '^5.0.0', webpack: '^5.101.0' });
144
+ });
145
+
146
+ it('should keep the old dependency when an extended config still imports it', () => {
147
+ const result = migrate(
148
+ setupContext({
149
+ [WEBPACK_CONFIG_PATH]: WEBPACK_CONFIG,
150
+ 'webpack.config.ts': `import ESLintPlugin from 'eslint-webpack-plugin';\n`,
151
+ })
152
+ );
153
+ const packageJson = JSON.parse(result.getFile('package.json') || '{}');
154
+
155
+ expect(result.getFile(WEBPACK_CONFIG_PATH)).not.toContain('ESLintPlugin');
156
+ expect(packageJson.devDependencies['eslint-webpack-plugin']).toBe('^5.0.0');
157
+ expect(packageJson.devDependencies['diagnostics-webpack-plugin']).toBe('^1.0.1');
158
+ });
159
+
160
+ it('should not modify configs that do not use the plugin', () => {
161
+ const context = new Context('/virtual');
162
+ context.addFile(WEBPACK_CONFIG_PATH, `export default {};\n`);
163
+
164
+ const result = migrate(context);
165
+
166
+ expect(result.getFile(WEBPACK_CONFIG_PATH)).toBe(`export default {};\n`);
167
+ });
168
+
169
+ it('should be idempotent', async () => {
170
+ await expect(migrate).toBeIdempotent(
171
+ setupContext({ [WEBPACK_CONFIG_PATH]: WEBPACK_CONFIG, [RSPACK_CONFIG_PATH]: RSPACK_CONFIG })
172
+ );
173
+ });
174
+ });
@@ -0,0 +1,141 @@
1
+ import { join } from 'node:path';
2
+ import * as recast from 'recast';
3
+ import type { Context } from '../../context.js';
4
+ import { addDependenciesToPackageJson, migrationsDebug, removeDependenciesFromPackageJson } from '../../utils.js';
5
+ import { parseAsTypescript } from '../../utils.ast.js';
6
+ import { output } from '../../../utils/utils.console.js';
7
+
8
+ const OLD_PACKAGE_NAME = 'eslint-webpack-plugin';
9
+ const NEW_PACKAGE_NAME = 'diagnostics-webpack-plugin';
10
+
11
+ const WEBPACK_CONFIG_PATH = join('.config', 'webpack', 'webpack.config.ts');
12
+
13
+ // The rspack config keeps eslint-webpack-plugin, diagnostics-webpack-plugin relies on webpack-only compiler hooks.
14
+ const CONFIG_PATHS = [
15
+ WEBPACK_CONFIG_PATH,
16
+ join('.config', 'rspack', 'rspack.config.ts'),
17
+ 'webpack.config.ts',
18
+ 'rspack.config.ts',
19
+ ];
20
+
21
+ // Options the template has scaffolded over time, with whitespace, comments and trailing commas stripped.
22
+ const SCAFFOLDED_OPTIONS = [
23
+ "{extensions:['.ts','.tsx'],lintDirtyModulesOnly:Boolean(env.development)}",
24
+ "{extensions:['.ts','.tsx'],lintDirtyModulesOnly:Boolean(env.development),failOnError:Boolean(env.production)}",
25
+ ];
26
+
27
+ const NEW_IMPORT = `import DiagnosticsPlugin from '${NEW_PACKAGE_NAME}';`;
28
+ const NEW_PLUGIN = `new DiagnosticsPlugin({
29
+ lintOnStart: false, // don't lint on start, only lint changed files
30
+ checks: [{ use: 'eslint', extensions: ['ts', 'tsx'] }],
31
+ })`;
32
+
33
+ // eslint-webpack-plugin depends on micromatch, which pulls in braces. diagnostics-webpack-plugin uses picomatch
34
+ // instead and needs webpack 5.106 or newer.
35
+ export default function migrate(context: Context) {
36
+ if (!replaceESLintPlugin(context)) {
37
+ return context;
38
+ }
39
+
40
+ if (context.doesFileExist('package.json')) {
41
+ addDependenciesToPackageJson(context, {}, { [NEW_PACKAGE_NAME]: '^1.0.1', webpack: '^5.106.0' });
42
+
43
+ const isStillUsed = CONFIG_PATHS.some(
44
+ (path) => context.doesFileExist(path) && (context.getFile(path) ?? '').includes(OLD_PACKAGE_NAME)
45
+ );
46
+ if (!isStillUsed) {
47
+ removeDependenciesFromPackageJson(context, [OLD_PACKAGE_NAME], [OLD_PACKAGE_NAME]);
48
+ }
49
+ }
50
+
51
+ return context;
52
+ }
53
+
54
+ function replaceESLintPlugin(context: Context): boolean {
55
+ if (!context.doesFileExist(WEBPACK_CONFIG_PATH)) {
56
+ return false;
57
+ }
58
+
59
+ const source = context.getFile(WEBPACK_CONFIG_PATH);
60
+ if (!source || !source.includes(OLD_PACKAGE_NAME)) {
61
+ return false;
62
+ }
63
+
64
+ const parsed = parseAsTypescript(source);
65
+ if (!parsed.success) {
66
+ migrationsDebug(`Failed to parse ${WEBPACK_CONFIG_PATH}. Error: ${parsed.error.message}`);
67
+ return false;
68
+ }
69
+
70
+ // Splice the source text rather than reprinting the AST, which would reformat the surrounding plugins array.
71
+ const lineOffsets = [0];
72
+ for (const line of source.split('\n')) {
73
+ lineOffsets.push(lineOffsets[lineOffsets.length - 1] + line.length + 1);
74
+ }
75
+ const toOffset = ({ line, column }: { line: number; column: number }) => lineOffsets[line - 1] + column;
76
+
77
+ const localNames = new Set<string>();
78
+ const replacements: Array<{ node: recast.types.namedTypes.Node; text: string }> = [];
79
+ let hasCustomOptions = false;
80
+
81
+ recast.visit(parsed.ast, {
82
+ visitImportDeclaration(path) {
83
+ if (path.node.source.value === OLD_PACKAGE_NAME) {
84
+ for (const specifier of path.node.specifiers ?? []) {
85
+ if (specifier.local?.name) {
86
+ localNames.add(String(specifier.local.name));
87
+ }
88
+ }
89
+ replacements.push({ node: path.node, text: NEW_IMPORT });
90
+ return false;
91
+ }
92
+ return this.traverse(path);
93
+ },
94
+ });
95
+
96
+ recast.visit(parsed.ast, {
97
+ visitNewExpression(path) {
98
+ const { node } = path;
99
+ if (node.callee.type === 'Identifier' && localNames.has(node.callee.name)) {
100
+ const [options] = node.arguments;
101
+ const optionsText = options?.loc ? source.slice(toOffset(options.loc.start), toOffset(options.loc.end)) : '';
102
+ const normalized = optionsText
103
+ .replace(/\/\/.*$/gm, '')
104
+ .replace(/\s+/g, '')
105
+ .replace(/,}/g, '}');
106
+ if (node.arguments.length !== 1 || !SCAFFOLDED_OPTIONS.includes(normalized)) {
107
+ hasCustomOptions = true;
108
+ }
109
+ replacements.push({ node, text: NEW_PLUGIN });
110
+ return false;
111
+ }
112
+ return this.traverse(path);
113
+ },
114
+ });
115
+
116
+ if (hasCustomOptions) {
117
+ output.warning({
118
+ title: `Skipping the ${OLD_PACKAGE_NAME} replacement in ${WEBPACK_CONFIG_PATH}`,
119
+ body: [
120
+ `The ${OLD_PACKAGE_NAME} options differ from the scaffolded defaults, so the config was left as is.`,
121
+ `${OLD_PACKAGE_NAME} depends on micromatch, which pulls in braces. To drop it, switch to ${NEW_PACKAGE_NAME} by hand.`,
122
+ ],
123
+ });
124
+ return false;
125
+ }
126
+
127
+ let updated = source;
128
+ const sorted = replacements
129
+ .filter(({ node }) => node.loc)
130
+ .sort(
131
+ (a, b) => b.node.loc!.start.line - a.node.loc!.start.line || b.node.loc!.start.column - a.node.loc!.start.column
132
+ );
133
+ for (const { node, text } of sorted) {
134
+ const { start, end } = node.loc!;
135
+ const indent = ' '.repeat(start.column);
136
+ updated = updated.slice(0, toOffset(start)) + text.replaceAll('\n', `\n${indent}`) + updated.slice(toOffset(end));
137
+ }
138
+
139
+ context.updateFile(WEBPACK_CONFIG_PATH, updated);
140
+ return true;
141
+ }
@@ -0,0 +1,140 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { Context } from './context.js';
4
+ import {
5
+ AGENT_INSTRUCTIONS_PATH,
6
+ INSTRUCTIONS_POINTER,
7
+ appendToAgentInstructions,
8
+ ensureConfigAgentFiles,
9
+ ensureRootAgentFiles,
10
+ } from './utils.agents.js';
11
+
12
+ describe('utils.agents', () => {
13
+ describe('ensureRootAgentFiles', () => {
14
+ const templateDir = join(__dirname, '..', '..', 'templates', 'common');
15
+
16
+ it('creates the missing root agent files', () => {
17
+ const context = new Context('/virtual');
18
+ expect(ensureRootAgentFiles(context, templateDir)).toEqual([]);
19
+ for (const file of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
20
+ expect(context.getFile(file), file).toContain(AGENT_INSTRUCTIONS_PATH);
21
+ }
22
+ });
23
+
24
+ it('appends a pointer to an existing file that never reaches instructions.md', () => {
25
+ const context = new Context('/virtual');
26
+ context.addFile('CLAUDE.md', '# Mine\n');
27
+ expect(ensureRootAgentFiles(context, templateDir)).toEqual(['CLAUDE.md']);
28
+ expect(context.getFile('CLAUDE.md')).toContain(AGENT_INSTRUCTIONS_PATH);
29
+ expect(context.getFile('CLAUDE.md')).toContain('# Mine');
30
+ });
31
+
32
+ it('treats a file that imports a linked AGENTS.md as linked', () => {
33
+ const context = new Context('/virtual');
34
+ context.addFile('AGENTS.md', `Read @./${AGENT_INSTRUCTIONS_PATH}\n`);
35
+ context.addFile('CLAUDE.md', '@AGENTS.md\n');
36
+ expect(ensureRootAgentFiles(context, templateDir)).toEqual([]);
37
+ expect(context.getFile('CLAUDE.md')).toBe('@AGENTS.md\n');
38
+ });
39
+
40
+ it.each(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'])('uses the same pointer as the %s template', (file) => {
41
+ expect(readFileSync(join(templateDir, file), 'utf-8')).toContain(INSTRUCTIONS_POINTER);
42
+ });
43
+
44
+ it.each([
45
+ ['a plain mention of AGENTS.md', 'Codex reads AGENTS.md, this file is for Claude.\n'],
46
+ ['a markdown link to AGENTS.md', 'See [the guide](AGENTS.md).\n'],
47
+ ['a plain mention of the instructions', `Don't edit ${AGENT_INSTRUCTIONS_PATH} by hand.\n`],
48
+ ])('links a CLAUDE.md with only %s', (_, content) => {
49
+ const context = new Context('/virtual');
50
+ context.addFile('AGENTS.md', `Read @./${AGENT_INSTRUCTIONS_PATH}\n`);
51
+ context.addFile('CLAUDE.md', content);
52
+ expect(ensureRootAgentFiles(context, templateDir)).toEqual(['CLAUDE.md']);
53
+ expect(context.getFile('CLAUDE.md')).toBe(`${content}\n${INSTRUCTIONS_POINTER}`);
54
+ });
55
+
56
+ it('treats a plain mention of the instructions in AGENTS.md as linked', () => {
57
+ const context = new Context('/virtual');
58
+ context.addFile('AGENTS.md', `Read ${AGENT_INSTRUCTIONS_PATH} first.\n`);
59
+ expect(ensureRootAgentFiles(context, templateDir)).toEqual([]);
60
+ });
61
+
62
+ it('links existing files that never reach instructions.md', () => {
63
+ const context = new Context('/virtual');
64
+ context.addFile('AGENTS.md', '# Mine\n');
65
+ context.addFile('CLAUDE.md', '@AGENTS.md\n');
66
+ context.addFile('GEMINI.md', 'See docs/AGENTS.md\n');
67
+ // AGENTS.md gets the pointer first, so CLAUDE.md's @AGENTS.md then resolves without its own edit
68
+ expect(ensureRootAgentFiles(context, templateDir)).toEqual(['AGENTS.md', 'GEMINI.md']);
69
+ expect(context.getFile('AGENTS.md')).toContain(AGENT_INSTRUCTIONS_PATH);
70
+ expect(context.getFile('CLAUDE.md')).toBe('@AGENTS.md\n');
71
+ expect(context.getFile('GEMINI.md')).toContain(AGENT_INSTRUCTIONS_PATH);
72
+ });
73
+ });
74
+
75
+ describe('ensureConfigAgentFiles', () => {
76
+ const templateDir = join(__dirname, '..', '..', 'templates', 'common');
77
+
78
+ it('creates only the given files from the template dir and returns them', () => {
79
+ const context = new Context('/virtual');
80
+ const added = ensureConfigAgentFiles(context, templateDir, ['instructions.md', 'e2e-testing.md'], {
81
+ packageManagerName: 'npm',
82
+ });
83
+
84
+ expect(added.sort()).toEqual(['.config/AGENTS/e2e-testing.md', AGENT_INSTRUCTIONS_PATH]);
85
+ });
86
+
87
+ it('does nothing when instructions.md already exists', () => {
88
+ const context = new Context('/virtual');
89
+ context.addFile(AGENT_INSTRUCTIONS_PATH, '# Mine\n');
90
+ expect(
91
+ ensureConfigAgentFiles(context, templateDir, ['instructions.md', 'e2e-testing.md'], {
92
+ packageManagerName: 'npm',
93
+ })
94
+ ).toEqual([]);
95
+
96
+ expect(context.getFile(AGENT_INSTRUCTIONS_PATH)).toBe('# Mine\n');
97
+ expect(context.doesFileExist('.config/AGENTS/e2e-testing.md')).toBe(false);
98
+ });
99
+ });
100
+
101
+ describe('appendToAgentInstructions', () => {
102
+ it('writes just the text into an empty instructions file', () => {
103
+ const context = new Context('/virtual');
104
+ context.addFile(AGENT_INSTRUCTIONS_PATH, '\n');
105
+ appendToAgentInstructions(context, 'new.md', '## New\n');
106
+ expect(context.getFile(AGENT_INSTRUCTIONS_PATH)).toBe('## New\n');
107
+ });
108
+
109
+ it('appends after a blank line', () => {
110
+ const context = new Context('/virtual');
111
+ context.addFile(AGENT_INSTRUCTIONS_PATH, '# Mine\n');
112
+ appendToAgentInstructions(context, 'new.md', '## New\n');
113
+
114
+ expect(context.getFile(AGENT_INSTRUCTIONS_PATH)).toBe('# Mine\n\n## New\n');
115
+ });
116
+
117
+ it('uses the given separator', () => {
118
+ const context = new Context('/virtual');
119
+ context.addFile(AGENT_INSTRUCTIONS_PATH, '- one\n');
120
+ appendToAgentInstructions(context, 'new.md', '- two\n', '\n');
121
+
122
+ expect(context.getFile(AGENT_INSTRUCTIONS_PATH)).toBe('- one\n- two\n');
123
+ });
124
+
125
+ it('skips when the marker is already present', () => {
126
+ const context = new Context('/virtual');
127
+ context.addFile(AGENT_INSTRUCTIONS_PATH, 'See new.md\n');
128
+ appendToAgentInstructions(context, 'new.md', '## New\n');
129
+
130
+ expect(context.getFile(AGENT_INSTRUCTIONS_PATH)).toBe('See new.md\n');
131
+ });
132
+
133
+ it('skips when there is no instructions file', () => {
134
+ const context = new Context('/virtual');
135
+ appendToAgentInstructions(context, 'new.md', '## New\n');
136
+
137
+ expect(context.doesFileExist(AGENT_INSTRUCTIONS_PATH)).toBe(false);
138
+ });
139
+ });
140
+ });
@@ -0,0 +1,81 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { Context } from './context.js';
4
+ import { addTemplateFolder, codemodsDebug } from './utils.js';
5
+
6
+ export const AGENT_INSTRUCTIONS_PATH = '.config/AGENTS/instructions.md';
7
+
8
+ const ROOT_AGENT_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
9
+ // matches the pointer line in templates/common/{AGENTS,CLAUDE,GEMINI}.md
10
+ export const INSTRUCTIONS_POINTER = `## Project knowledge\nThis repository contains a **Grafana plugin**. You must Read @./${AGENT_INSTRUCTIONS_PATH} before doing changes.\n`;
11
+ const IMPORTS_INSTRUCTIONS = /@(\.\/)?\.config\/AGENTS\/instructions\.md/;
12
+ const IMPORTS_AGENTS_MD = /(^|\s)@(\.\/)?AGENTS\.md\b/m;
13
+
14
+ /**
15
+ * Creates the missing root agent files. Appends a pointer to the instructions to any existing one that
16
+ * doesn't reach them, and returns those files.
17
+ */
18
+ export function ensureRootAgentFiles(context: Context, templateDir: string): string[] {
19
+ const existing = ROOT_AGENT_FILES.filter((file) => context.doesFileExist(file));
20
+ for (const file of ROOT_AGENT_FILES) {
21
+ if (!existing.includes(file)) {
22
+ context.addFile(file, readFileSync(join(templateDir, file), 'utf-8'));
23
+ }
24
+ }
25
+
26
+ // Claude and Gemini only follow `@` imports, while Codex reads AGENTS.md as text, so a mention is enough there
27
+ const reachesInstructions = (file: string): boolean => {
28
+ const content = context.getFile(file) ?? '';
29
+ if (file === 'AGENTS.md') {
30
+ return content.includes(AGENT_INSTRUCTIONS_PATH);
31
+ }
32
+ return IMPORTS_INSTRUCTIONS.test(content) || (IMPORTS_AGENTS_MD.test(content) && reachesInstructions('AGENTS.md'));
33
+ };
34
+
35
+ const linked: string[] = [];
36
+ for (const file of existing) {
37
+ if (reachesInstructions(file)) {
38
+ continue;
39
+ }
40
+ const content = context.getFile(file) ?? '';
41
+ context.updateFile(file, `${content.trimEnd()}\n\n${INSTRUCTIONS_POINTER}`);
42
+ linked.push(file);
43
+ }
44
+ return linked;
45
+ }
46
+
47
+ /**
48
+ * Creates the given `.config/AGENTS/` files, only when `instructions.md` is missing. They import each
49
+ * other, so a plugin with its own instructions keeps what it has. Returns the paths it added.
50
+ */
51
+ export function ensureConfigAgentFiles(
52
+ context: Context,
53
+ templateDir: string,
54
+ files: string[],
55
+ data: Record<string, unknown>
56
+ ): string[] {
57
+ if (context.doesFileExist(AGENT_INSTRUCTIONS_PATH)) {
58
+ return [];
59
+ }
60
+ return addTemplateFolder(context, {
61
+ folderPath: join(templateDir, '.config', 'AGENTS'),
62
+ targetPath: '.config/AGENTS',
63
+ data,
64
+ include: (file) => files.includes(file),
65
+ });
66
+ }
67
+
68
+ /** Appends `text` to the agent instructions, unless they're missing or already contain `marker`. */
69
+ export function appendToAgentInstructions(context: Context, marker: string, text: string, separator = '\n\n') {
70
+ const existing = context.getFile(AGENT_INSTRUCTIONS_PATH);
71
+ if (existing === undefined) {
72
+ codemodsDebug(`Could not find ${AGENT_INSTRUCTIONS_PATH}, skipping`);
73
+ return;
74
+ }
75
+ if (existing.includes(marker)) {
76
+ codemodsDebug(`${AGENT_INSTRUCTIONS_PATH} already contains ${marker}, skipping`);
77
+ return;
78
+ }
79
+ const kept = existing.trimEnd();
80
+ context.updateFile(AGENT_INSTRUCTIONS_PATH, kept ? `${kept}${separator}${text}` : text);
81
+ }