@grafana/create-plugin 7.16.0 → 7.18.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 (50) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/codemods/additions/additions.js +7 -0
  3. package/dist/codemods/additions/prompts/rspack.md +118 -0
  4. package/dist/codemods/additions/scripts/docs.js +2 -2
  5. package/dist/codemods/additions/scripts/rspack.js +322 -0
  6. package/dist/codemods/agentic/prompts.js +1 -1
  7. package/dist/codemods/migrations/migrations.js +13 -1
  8. package/dist/codemods/migrations/scripts/019-remove-glob-dependency.js +93 -0
  9. package/dist/codemods/migrations/scripts/020-rename-rspack-feature-flag.js +8 -0
  10. package/dist/codemods/utils.ast.js +44 -1
  11. package/dist/codemods/utils.rspack.js +38 -0
  12. package/dist/commands/generate.command.js +1 -9
  13. package/dist/constants.js +0 -1
  14. package/dist/utils/utils.templates.js +4 -4
  15. package/package.json +1 -1
  16. package/src/codemods/additions/additions.test.ts +9 -0
  17. package/src/codemods/additions/additions.ts +7 -0
  18. package/src/codemods/additions/prompts/rspack.md +118 -0
  19. package/src/codemods/additions/scripts/docs.test.ts +4 -4
  20. package/src/codemods/additions/scripts/docs.ts +2 -2
  21. package/src/codemods/additions/scripts/rspack.test.ts +768 -0
  22. package/src/codemods/additions/scripts/rspack.ts +428 -0
  23. package/src/codemods/agentic/prompts.test.ts +11 -2
  24. package/src/codemods/agentic/prompts.ts +1 -1
  25. package/src/codemods/migrations/migrations.ts +14 -1
  26. package/src/codemods/migrations/scripts/019-remove-glob-dependency.test.ts +146 -0
  27. package/src/codemods/migrations/scripts/019-remove-glob-dependency.ts +121 -0
  28. package/src/codemods/migrations/scripts/020-rename-rspack-feature-flag.test.ts +94 -0
  29. package/src/codemods/migrations/scripts/020-rename-rspack-feature-flag.ts +8 -0
  30. package/src/codemods/utils.ast.test.ts +151 -0
  31. package/src/codemods/utils.ast.ts +60 -0
  32. package/src/codemods/utils.rspack.ts +58 -0
  33. package/src/codemods/utils.test.ts +5 -2
  34. package/src/commands/generate.command.ts +2 -10
  35. package/src/constants.ts +0 -1
  36. package/src/types.ts +1 -1
  37. package/src/utils/tests/utils.config.test.ts +10 -0
  38. package/src/utils/utils.config.ts +2 -1
  39. package/src/utils/utils.templates.ts +4 -4
  40. package/templates/common/.config/AGENTS/instructions.md +2 -2
  41. package/templates/common/.config/README.md +56 -0
  42. package/templates/common/.config/bundler/externals.ts +2 -2
  43. package/templates/common/.config/bundler/utils.ts +10 -9
  44. package/templates/common/.config/rspack/DiagnosticsRspackPlugin.ts +29 -0
  45. package/templates/common/.config/rspack/LicenseRspackPlugin.ts +43 -0
  46. package/templates/common/.config/rspack/LiveReloadRspackPlugin.ts +95 -0
  47. package/templates/common/.config/rspack/rspack.config.ts +41 -32
  48. package/templates/common/.cprc.json +1 -2
  49. package/templates/common/_package.json +19 -20
  50. package/templates/common/.config/rspack/liveReloadPlugin.ts +0 -133
@@ -0,0 +1,428 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { lt, major, minVersion, validRange } from 'semver';
4
+ import type { Context } from '../../context.js';
5
+ import { replaceExperimentalRspackFlag } from '../../utils.rspack.js';
6
+ import { renderHandlebarsTemplate } from '../../../utils/utils.handlebars.js';
7
+ import { getTemplateData } from '../../../utils/utils.templates.js';
8
+ import {
9
+ additionsDebug,
10
+ addDependenciesToPackageJson,
11
+ readJsonFile,
12
+ removeDependenciesFromPackageJson,
13
+ renderTemplate,
14
+ } from '../../utils.js';
15
+
16
+ const RSPACK_TEMPLATE_DATA_OVERRIDES = {
17
+ useRspack: true,
18
+ frontendBundler: 'rspack',
19
+ };
20
+
21
+ const RSPACK_CONFIG_FILES = [
22
+ '.config/rspack/rspack.config.ts',
23
+ '.config/rspack/BuildModeRspackPlugin.ts',
24
+ '.config/rspack/LiveReloadRspackPlugin.ts',
25
+ '.config/rspack/LicenseRspackPlugin.ts',
26
+ '.config/rspack/DiagnosticsRspackPlugin.ts',
27
+ ];
28
+
29
+ // files the rspack template used to ship under another name. removed so they do not linger
30
+ const RETIRED_RSPACK_FILES = ['.config/rspack/liveReloadPlugin.ts'];
31
+
32
+ // markdown in .config that describes the bundler, for agents and for developers
33
+ const DOC_FILES = ['.config/AGENTS/instructions.md', '.config/README.md'];
34
+
35
+ const BUNDLER_FILES = [
36
+ '.config/bundler/constants.ts',
37
+ '.config/bundler/copyFiles.ts',
38
+ '.config/bundler/externals.ts',
39
+ '.config/bundler/utils.ts',
40
+ ];
41
+
42
+ const WEBPACK_CONFIG_PATH = '.config/webpack/webpack.config.ts';
43
+ const RSPACK_CONFIG_PATH = '.config/rspack/rspack.config.ts';
44
+
45
+ // every file create-plugin has scaffolded into .config/webpack over time. anything else in there
46
+ // was added by the plugin author, so it is kept and reported instead of deleted
47
+ const WEBPACK_TEMPLATE_FILES = [
48
+ '.config/webpack/BuildModeWebpackPlugin.ts',
49
+ '.config/webpack/PluginSchemaWebpackPlugin.ts',
50
+ '.config/webpack/constants.ts',
51
+ '.config/webpack/generateCode.ts',
52
+ '.config/webpack/publicPath.ts',
53
+ '.config/webpack/tsconfig.webpack.json',
54
+ '.config/webpack/utils.ts',
55
+ '.config/webpack/watchPluginJson.ts',
56
+ '.config/webpack/webpack.config.ts',
57
+ '.config/webpack/webpack.parts.ts',
58
+ ];
59
+
60
+ export default function rspack(context: Context): Context {
61
+ const hasWebpackSetup = context.doesFileExist(WEBPACK_CONFIG_PATH);
62
+ const hasRspackSetup = context.doesFileExist(RSPACK_CONFIG_PATH);
63
+
64
+ if (!hasWebpackSetup && !hasRspackSetup) {
65
+ context.setMessage({
66
+ level: 'warning',
67
+ title: 'No create-plugin bundler configuration found, so nothing was changed.',
68
+ body: [`The rspack addition expects ${WEBPACK_CONFIG_PATH} or ${RSPACK_CONFIG_PATH} to exist.`],
69
+ });
70
+ return context;
71
+ }
72
+
73
+ const followUps: string[] = [];
74
+
75
+ deleteRetiredRspackFiles(context);
76
+ renderTemplateFiles(context, RSPACK_CONFIG_FILES);
77
+ renderTemplateFiles(context, BUNDLER_FILES);
78
+ renderTemplateFiles(context, DOC_FILES, false);
79
+ replaceExperimentalRspackFlag(context, { enable: true });
80
+
81
+ followUps.push(...addRootRspackConfigStub(context));
82
+ followUps.push(...updatePackageJson(context));
83
+ followUps.push(...deleteWebpackTemplateFiles(context));
84
+ reportFollowUps(context, followUps);
85
+
86
+ return context;
87
+ }
88
+
89
+ function deleteRetiredRspackFiles(context: Context): void {
90
+ for (const filePath of RETIRED_RSPACK_FILES) {
91
+ if (context.doesFileExist(filePath)) {
92
+ context.deleteFile(filePath);
93
+ }
94
+ }
95
+ }
96
+
97
+ function reportFollowUps(context: Context, followUps: string[]): void {
98
+ if (followUps.length === 0) {
99
+ return;
100
+ }
101
+
102
+ context.setMessage({
103
+ level: 'warning',
104
+ title: 'The rspack addition left some things for you to review.',
105
+ body: followUps,
106
+ });
107
+ }
108
+
109
+ const resolveTemplatePath = (relativePath: string) =>
110
+ fileURLToPath(new URL(`../../../../templates/common/${relativePath}`, import.meta.url));
111
+
112
+ // updateFile is a no-op for identical content, so re-rendering on every run keeps the addition idempotent
113
+ function renderTemplateFiles(context: Context, filePaths: string[], includeWarning = true): void {
114
+ const templateData = { ...getTemplateData(), ...RSPACK_TEMPLATE_DATA_OVERRIDES };
115
+ for (const filePath of filePaths) {
116
+ const rendered = renderTemplate(resolveTemplatePath(filePath), includeWarning, templateData);
117
+ if (context.doesFileExist(filePath)) {
118
+ context.updateFile(filePath, rendered);
119
+ } else {
120
+ context.addFile(filePath, rendered);
121
+ }
122
+ }
123
+ }
124
+
125
+ const ROOT_CONFIG_EXTENSIONS = ['ts', 'js', 'cjs', 'mjs', 'cts', 'mts'];
126
+
127
+ // a root webpack config means the plugin extends the build. the stub makes the build fail loudly
128
+ // until that config is ported, so a plugin cannot ship without the build features it relies on
129
+ function addRootRspackConfigStub(context: Context): string[] {
130
+ const rootWebpackConfig = ROOT_CONFIG_EXTENSIONS.map((extension) => `webpack.config.${extension}`).find((filePath) =>
131
+ context.doesFileExist(filePath)
132
+ );
133
+ if (!rootWebpackConfig) {
134
+ return [];
135
+ }
136
+
137
+ const existingRootRspackConfig = ROOT_CONFIG_EXTENSIONS.map((extension) => `rspack.config.${extension}`).find(
138
+ (filePath) => context.doesFileExist(filePath)
139
+ );
140
+ if (existingRootRspackConfig) {
141
+ return [
142
+ `${existingRootRspackConfig} already exists, so it was left as is. Check that it includes everything from ${rootWebpackConfig}.`,
143
+ ];
144
+ }
145
+
146
+ additionsDebug(`Custom ${rootWebpackConfig} detected. Adding a rspack.config.ts stub that fails until it is ported.`);
147
+ context.addFile('rspack.config.ts', getRootRspackConfigStub(rootWebpackConfig));
148
+
149
+ return [
150
+ `rspack.config.ts was added and fails the build until the customisations in ${rootWebpackConfig} are ported to it.`,
151
+ ];
152
+ }
153
+
154
+ interface PackageJson {
155
+ dependencies?: Record<string, string>;
156
+ devDependencies?: Record<string, string>;
157
+ scripts?: Record<string, string>;
158
+ engines?: Record<string, string>;
159
+ [key: string]: unknown;
160
+ }
161
+
162
+ function updatePackageJson(context: Context): string[] {
163
+ if (!context.doesFileExist('package.json')) {
164
+ additionsDebug('No package.json found. Skipping dependency and script updates.');
165
+ return [];
166
+ }
167
+
168
+ const followUps: string[] = [];
169
+ const templateVersions = getTemplateDevDependencyVersions();
170
+ const userBuildFileSources = readUserBuildFileSources(context);
171
+ const stillUsedDevDependencies = WEBPACK_ONLY_DEV_DEPENDENCIES.filter((name) =>
172
+ userBuildFileSources.some((source) => referencesPackage(source, name))
173
+ );
174
+ const unusedDevDependencies = WEBPACK_ONLY_DEV_DEPENDENCIES.filter(
175
+ (name) => !stillUsedDevDependencies.includes(name)
176
+ );
177
+
178
+ addDependenciesToPackageJson(
179
+ context,
180
+ {},
181
+ {
182
+ ...pickVersions(getMissingPackages(context, BASE_CONFIG_DEV_DEPENDENCIES), templateVersions),
183
+ ...pickVersions(RSPACK_DEV_DEPENDENCIES, templateVersions),
184
+ }
185
+ );
186
+ removeDependenciesFromPackageJson(context, [], unusedDevDependencies);
187
+
188
+ if (stillUsedDevDependencies.length > 0) {
189
+ followUps.push(
190
+ 'These webpack packages were kept because your root build config still uses them. Remove them once the config is ported to rspack:',
191
+ ...stillUsedDevDependencies.map((name) => ` ${name}`)
192
+ );
193
+ }
194
+
195
+ const packageJson = readJsonFile<PackageJson>(context, 'package.json');
196
+ const { scripts, unparsedScripts } = rewriteWebpackScripts(packageJson.scripts ?? {});
197
+
198
+ if (packageJson.scripts) {
199
+ context.updateFile('package.json', JSON.stringify({ ...packageJson, scripts }, null, 2));
200
+ }
201
+
202
+ raiseNodeEngine(context);
203
+ dropLockedRspackOnePackages(context);
204
+
205
+ if (unparsedScripts.length > 0) {
206
+ followUps.push(
207
+ 'These scripts still call webpack in a way the addition could not rewrite. Change them to call rspack with ./rspack.config.ts or ./.config/rspack/rspack.config.ts:',
208
+ ...unparsedScripts.map(([name, command]) => ` ${name}: ${command}`)
209
+ );
210
+ }
211
+
212
+ return followUps;
213
+ }
214
+
215
+ // rspack 2 needs Node 22.12. 22.23 also has type stripping on by default, so the rspack CLI can
216
+ // always load TypeScript configs natively. matches the engines range create-plugin scaffolds
217
+ const MIN_NODE_VERSION = '22.23.0';
218
+
219
+ function raiseNodeEngine(context: Context): void {
220
+ const packageJson = readJsonFile<PackageJson>(context, 'package.json');
221
+ const nodeRange = packageJson.engines?.node;
222
+ if (!nodeRange || !validRange(nodeRange)) {
223
+ return;
224
+ }
225
+
226
+ const lowestAllowedVersion = minVersion(nodeRange);
227
+ if (!lowestAllowedVersion || !lt(lowestAllowedVersion, MIN_NODE_VERSION)) {
228
+ return;
229
+ }
230
+
231
+ const engines = { ...packageJson.engines, node: '>=22.23' };
232
+ context.updateFile('package.json', JSON.stringify({ ...packageJson, engines }, null, 2));
233
+ }
234
+
235
+ // npm refuses to move @rspack/cli and @rspack/core from 1 to 2 in one install while the lockfile
236
+ // still pins the old versions (ERESOLVE). dropping the locked @rspack packages lets npm resolve
237
+ // them again from package.json. pnpm and yarn upgrade them without help
238
+ const LOCKED_RSPACK_PACKAGE_PATTERN = /(^|\/)node_modules\/@rspack\//;
239
+
240
+ function dropLockedRspackOnePackages(context: Context): void {
241
+ if (!context.doesFileExist('package-lock.json')) {
242
+ return;
243
+ }
244
+
245
+ const lockfile = readJsonFile<{ packages?: Record<string, { version?: string }> }>(context, 'package-lock.json');
246
+ const lockedCoreVersion = lockfile.packages?.['node_modules/@rspack/core']?.version;
247
+ if (!lockfile.packages || !lockedCoreVersion || major(lockedCoreVersion) >= 2) {
248
+ return;
249
+ }
250
+
251
+ const packages = Object.fromEntries(
252
+ Object.entries(lockfile.packages).filter(([path]) => !LOCKED_RSPACK_PACKAGE_PATTERN.test(path))
253
+ );
254
+ context.updateFile('package-lock.json', JSON.stringify({ ...lockfile, packages }, null, 2) + '\n');
255
+ }
256
+
257
+ // a webpack or webpack-cli command at the start of a script, after a shell separator, after env
258
+ // assignments or after npx. the lookbehind keeps the separator and prefixes out of the match
259
+ const WEBPACK_COMMAND_PATTERN = /(?<=(?:^|&&|\|\||;|\|)\s*(?:\w+=\S*\s+)*(?:npx\s+)?)webpack(?:-cli)?(?=\s|$)/g;
260
+ const ROOT_WEBPACK_CONFIG_ARG_PATTERN =
261
+ /((?:^|\s)(?:-c|--config)(?:\s+|=))(?:\.\/)?webpack\.config\.[cm]?[jt]s(?=\s|$)/g;
262
+ const TEMPLATE_WEBPACK_CONFIG_ARG_PATTERN =
263
+ /((?:^|\s)(?:-c|--config)(?:\s+|=))(?:\.\/)?\.config\/webpack\/webpack\.config\.ts(?=\s|$)/g;
264
+ // anything that still runs webpack after the rewrite, e.g. node_modules/webpack-cli/bin/cli.js
265
+ const REMAINING_WEBPACK_PATTERN = /(?:^|[\s/])webpack(?:-cli)?(?:[\s/]|$)|\.config\/webpack\//;
266
+
267
+ function rewriteWebpackScripts(scripts: Record<string, string>) {
268
+ const unparsedScripts: Array<[string, string]> = [];
269
+ const rewrittenScripts = Object.fromEntries(
270
+ Object.entries(scripts).map(([name, command]) => {
271
+ const rewritten = command
272
+ .replace(WEBPACK_COMMAND_PATTERN, 'rspack')
273
+ .replace(ROOT_WEBPACK_CONFIG_ARG_PATTERN, '$1./rspack.config.ts')
274
+ .replace(TEMPLATE_WEBPACK_CONFIG_ARG_PATTERN, '$1./.config/rspack/rspack.config.ts');
275
+
276
+ if (REMAINING_WEBPACK_PATTERN.test(rewritten)) {
277
+ unparsedScripts.push([name, command]);
278
+ return [name, command];
279
+ }
280
+
281
+ return [name, rewritten];
282
+ })
283
+ );
284
+
285
+ return { scripts: rewrittenScripts, unparsedScripts };
286
+ }
287
+
288
+ // root level webpack.* and rspack.* files, e.g. webpack.config.ts and helpers like webpack.config.utils.ts
289
+ const USER_BUILD_FILE_PATTERN = /^(webpack|rspack)\.[\w.-]*\.[cm]?[jt]s$/;
290
+
291
+ function readUserBuildFileSources(context: Context): string[] {
292
+ return context
293
+ .readDir('.')
294
+ .filter((filePath) => USER_BUILD_FILE_PATTERN.test(filePath))
295
+ .map((filePath) => context.getFile(filePath) ?? '');
296
+ }
297
+
298
+ // matches the package as a module specifier or loader string: 'name', "name", `name` or 'name/subpath'
299
+ function referencesPackage(source: string, packageName: string): boolean {
300
+ const escapedName = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
301
+ return new RegExp(`['"\`]${escapedName}(/[^'"\`]*)?['"\`]`).test(source);
302
+ }
303
+
304
+ // packages the rspack base config and its helpers need that webpack plugins do not
305
+ export const RSPACK_DEV_DEPENDENCIES = [
306
+ '@rspack/cli',
307
+ '@rspack/core',
308
+ '@types/ws',
309
+ 'diagnostics-webpack-plugin',
310
+ 'rspack-merge',
311
+ 'rspack-plugin-virtual-module',
312
+ 'ts-checker-rspack-plugin',
313
+ // style-loader and imports-loader still need webpack, and diagnostics-webpack-plugin peers on webpack ^5.106
314
+ 'webpack',
315
+ 'ws',
316
+ ];
317
+
318
+ // packages only the webpack base config uses. removed unless a root build file still imports them
319
+ export const WEBPACK_ONLY_DEV_DEPENDENCIES = [
320
+ 'copy-webpack-plugin',
321
+ 'eslint-webpack-plugin',
322
+ 'fork-ts-checker-webpack-plugin',
323
+ 'swc-loader',
324
+ 'terser-webpack-plugin',
325
+ 'webpack-cli',
326
+ 'webpack-livereload-plugin',
327
+ 'webpack-subresource-integrity',
328
+ 'webpack-virtual-modules',
329
+ ];
330
+
331
+ // packages the rendered .config/rspack and .config/bundler files load at build time that every
332
+ // scaffold gets, so older plugins may have removed some of them. added only when missing.
333
+ // @swc/helpers is needed because builtin:swc-loader runs with externalHelpers, which webpack did not
334
+ export const BASE_CONFIG_DEV_DEPENDENCIES = [
335
+ '@swc/helpers',
336
+ 'css-loader',
337
+ 'imports-loader',
338
+ 'replace-in-file-webpack-plugin',
339
+ 'sass',
340
+ 'sass-loader',
341
+ 'style-loader',
342
+ ];
343
+
344
+ // the lists above say which packages the addition manages. their versions come from the package.json
345
+ // template, so they always match the .config files rendered from that same template. the template is
346
+ // rendered with fixed data, since only devDependencies are read and they do not depend on the plugin
347
+ const PACKAGE_JSON_TEMPLATE_DATA = {
348
+ ...RSPACK_TEMPLATE_DATA_OVERRIDES,
349
+ pluginName: 'plugin',
350
+ orgName: 'org',
351
+ pluginType: 'panel',
352
+ packageManagerName: 'npm',
353
+ packageManagerVersion: '10.0.0',
354
+ scenesVersion: '*',
355
+ isAppType: false,
356
+ isNPM: true,
357
+ };
358
+
359
+ export function getTemplateDevDependencyVersions(): Record<string, string> {
360
+ const template = readFileSync(resolveTemplatePath('_package.json'), 'utf-8');
361
+ const rendered: { devDependencies?: Record<string, string> } = JSON.parse(
362
+ renderHandlebarsTemplate(template, PACKAGE_JSON_TEMPLATE_DATA)
363
+ );
364
+ return rendered.devDependencies ?? {};
365
+ }
366
+
367
+ function pickVersions(names: string[], templateVersions: Record<string, string>): Record<string, string> {
368
+ return Object.fromEntries(
369
+ names.map((name) => {
370
+ const version = templateVersions[name];
371
+ if (!version) {
372
+ throw new Error(`The package.json template has no version for ${name}, which the rspack addition installs.`);
373
+ }
374
+ return [name, version];
375
+ })
376
+ );
377
+ }
378
+
379
+ // a plugin that already has one of these keeps its version
380
+ function getMissingPackages(context: Context, names: string[]): string[] {
381
+ const packageJson = readJsonFile<PackageJson>(context, 'package.json');
382
+ const installed = { ...packageJson.dependencies, ...packageJson.devDependencies };
383
+ return names.filter((name) => !(name in installed));
384
+ }
385
+
386
+ function deleteWebpackTemplateFiles(context: Context): string[] {
387
+ for (const filePath of WEBPACK_TEMPLATE_FILES) {
388
+ if (context.doesFileExist(filePath)) {
389
+ context.deleteFile(filePath);
390
+ }
391
+ }
392
+
393
+ const leftoverFiles = context.readDir('.config/webpack');
394
+ if (leftoverFiles.length === 0) {
395
+ return [];
396
+ }
397
+
398
+ return [
399
+ 'These files in .config/webpack were not created by create-plugin, so they were kept. Move anything you still need out of .config and port it to rspack:',
400
+ ...leftoverFiles.map((filePath) => ` ${filePath}`),
401
+ ];
402
+ }
403
+
404
+ function getRootRspackConfigStub(rootWebpackConfig: string): string {
405
+ return `import type { Configuration } from '@rspack/core';
406
+ import { merge } from 'rspack-merge';
407
+
408
+ import grafanaConfig, { type Env } from './.config/rspack/rspack.config.ts';
409
+
410
+ // TODO(rspack): port the customisations in ./${rootWebpackConfig} to this file, then delete it.
411
+ // Run \`npx @grafana/create-plugin@latest add rspack --agent\` to have an AI agent do the port,
412
+ // or follow https://grafana.com/developers/plugin-tools/how-to-guides/extend-configurations
413
+ // This file is loaded as native ESM, so CommonJS globals are not available. Use import.meta.dirname,
414
+ // createRequire(import.meta.url) and .ts extensions on relative imports.
415
+ throw new Error(
416
+ '[rspack] ${rootWebpackConfig} has not been ported to rspack.config.ts yet. ' +
417
+ 'Run \`npx @grafana/create-plugin@latest add rspack --agent\` or port it by hand, then remove this error.'
418
+ );
419
+
420
+ const config = async (env: Env): Promise<Configuration> => {
421
+ const baseConfig = await grafanaConfig(env);
422
+
423
+ return merge(baseConfig, {});
424
+ };
425
+
426
+ export default config;
427
+ `;
428
+ }
@@ -89,8 +89,17 @@ describe('buildSystemPrompt', () => {
89
89
  expect(systemPrompt).toContain('Write it with your file-writing tool, not a shell command.');
90
90
  });
91
91
 
92
- it('should tell the agent to fix failing checks rather than suppress them', () => {
93
- expect(systemPrompt).toContain('fix the cause. Never make it pass by suppressing it');
92
+ it('should tell the agent to fix failing checks rather than hide them', () => {
93
+ expect(systemPrompt).toContain('fix the cause. Never hide a failure');
94
+ });
95
+
96
+ it('should defer to the addition instructions for setting aside what cannot be applied', () => {
97
+ // additions keep the build passing by commenting out unportable code under a TODO marker,
98
+ // so the contract must not forbid that or tell the agent to leave the check failing instead
99
+ expect(systemPrompt).toContain(
100
+ 'If the addition instructions say how to set aside something that cannot be applied, such as commenting it out under a `TODO(<addition-name>):` marker, follow them.'
101
+ );
102
+ expect(systemPrompt).not.toMatch(/or changed scripts\./);
94
103
  });
95
104
 
96
105
  it('should render the package manager commands', () => {
@@ -76,7 +76,7 @@ Before changing any file, print a short plan of what you intend to do so the use
76
76
 
77
77
  <scope_rules>
78
78
  Make only the changes the addition instructions require. Do not run package installs, builds, or tests unless the instructions ask for them. Run the repository formatter on files you touched if one is configured. Do not reformat files you did not change.
79
- When a check fails (typecheck, lint, build or tests), fix the cause. Never make it pass by suppressing it: no disable comments, skipped or deleted tests, loosened compiler or lint settings, or changed scripts. If you cannot fix the cause, leave it failing and say so in your summary.
79
+ When a check fails (typecheck, lint, build or tests), fix the cause. Never hide a failure: no disable comments, skipped or deleted tests, loosened compiler or lint settings, or scripts changed to skip the check. If the addition instructions say how to set aside something that cannot be applied, such as commenting it out under a \`TODO(<addition-name>):\` marker, follow them. Otherwise leave the check failing and say so in your summary.
80
80
  </scope_rules>
81
81
 
82
82
  <handoff_contract>
@@ -127,9 +127,22 @@ 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
+ },
136
+ {
137
+ name: '020-rename-rspack-feature-flag',
138
+ version: '7.18.0',
139
+ description:
140
+ 'Rename the useExperimentalRspack feature flag to useRspack in .config/.cprc.json, and remove it from the plugin .cprc.json.',
141
+ scriptPath: import.meta.resolve('./scripts/020-rename-rspack-feature-flag.js'),
142
+ },
130
143
  // Do not use LEGACY_UPDATE_CUTOFF_VERSION for new migrations. It is only used above to force migrations to run
131
144
  // for those written before the switch to updates as migrations.
132
145
  //
133
- // New migrations never pick a version: copy the unreleased version line from ../AGENTS.md exactly.
146
+ // New migrations should never specify a version: copy the unreleased version line from ../AGENTS.md exactly.
134
147
  // Do not paste it into a comment in this file, as release-please rewrites every line that carries it.
135
148
  ] 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
+ });