@backstage/eslint-plugin 0.1.8 → 0.1.9

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
  # @backstage/eslint-plugin
2
2
 
3
+ ## 0.1.9
4
+
5
+ ### Patch Changes
6
+
7
+ - 08895e3: Added support for linting dependencies on workspace packages with the `backstage.inline` flag.
8
+
9
+ ## 0.1.9-next.0
10
+
11
+ ### Patch Changes
12
+
13
+ - 08895e3: Added support for linting dependencies on workspace packages with the `backstage.inline` flag.
14
+
3
15
  ## 0.1.8
4
16
 
5
17
  ### Patch Changes
@@ -21,7 +21,7 @@ const manypkg = require('@manypkg/get-packages');
21
21
 
22
22
  /**
23
23
  * @typedef ExtendedPackage
24
- * @type {import('@manypkg/get-packages').Package & { packageJson: { exports?: Record<string, string>, files?: Array<string> }}} packageJson
24
+ * @type {import('@manypkg/get-packages').Package & { packageJson: { exports?: Record<string, string>, files?: Array<string>, backstage?: { inline?: boolean } }}} packageJson
25
25
  */
26
26
 
27
27
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backstage/eslint-plugin",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Backstage ESLint plugin",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -22,7 +22,7 @@
22
22
  "minimatch": "^9.0.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@backstage/cli": "^0.26.5",
25
+ "@backstage/cli": "^0.27.1",
26
26
  "@types/estree": "^1.0.5",
27
27
  "eslint": "^8.33.0"
28
28
  }
@@ -22,11 +22,11 @@ const visitImports = require('../lib/visitImports');
22
22
  const minimatch = require('minimatch');
23
23
  const { execFileSync } = require('child_process');
24
24
 
25
- const depFields = {
25
+ const depFields = /** @type {const} */ ({
26
26
  dep: 'dependencies',
27
27
  dev: 'devDependencies',
28
28
  peer: 'peerDependencies',
29
- };
29
+ });
30
30
 
31
31
  const devModulePatterns = [
32
32
  new minimatch.Minimatch('!src/**'),
@@ -154,6 +154,124 @@ function addVersionQuery(name, flag, packages) {
154
154
  return `${name}@${mostCommonRange}`;
155
155
  }
156
156
 
157
+ /**
158
+ * Add missing package imports
159
+ * @param {Array<{name: string, flag: string, node: import('estree').Node}>} toAdd
160
+ * @param {import('../lib/getPackages').PackageMap} packages
161
+ * @param {import('../lib/getPackages').ExtendedPackage} localPkg
162
+ */
163
+ function addMissingImports(toAdd, packages, localPkg) {
164
+ /** @type Record<string, Set<string>> */
165
+ const byFlag = {};
166
+
167
+ for (const { name, flag } of toAdd) {
168
+ byFlag[flag] = byFlag[flag] ?? new Set();
169
+ byFlag[flag].add(name);
170
+ }
171
+
172
+ for (const name of byFlag[''] ?? []) {
173
+ byFlag['--dev']?.delete(name);
174
+ }
175
+ for (const name of byFlag['--peer'] ?? []) {
176
+ byFlag['']?.delete(name);
177
+ byFlag['--dev']?.delete(name);
178
+ }
179
+
180
+ for (const [flag, names] of Object.entries(byFlag)) {
181
+ // Look up existing version queries in the repo for the same dependency
182
+ const namesWithQuery = [...names].map(name =>
183
+ addVersionQuery(name, flag, packages),
184
+ );
185
+
186
+ // The security implication of this is a bit interesting, as crafted add-import
187
+ // directives could be used to install malicious packages. However, the same is true
188
+ // for adding malicious packages to package.json, so there's no significant difference.
189
+ execFileSync('yarn', ['add', ...(flag ? [flag] : []), ...namesWithQuery], {
190
+ cwd: localPkg.dir,
191
+ stdio: 'inherit',
192
+ });
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Removes dependency entries pointing to inlined workspace packages.
198
+ * @param {Array<{pkg: import('../lib/getPackages').ExtendedPackage, node: import('estree').Node}>} toInline
199
+ * @param {import('../lib/getPackages').ExtendedPackage} localPkg
200
+ */
201
+ function removeInlineImports(toInline, localPkg) {
202
+ /** @type Set<string> */
203
+ const toRemove = new Set();
204
+
205
+ for (const { pkg } of toInline) {
206
+ const name = pkg.packageJson.name;
207
+ for (const depType of Object.values(depFields)) {
208
+ if (localPkg.packageJson[depType]?.[name]) {
209
+ toRemove.add(name);
210
+ }
211
+ }
212
+ }
213
+ if (toRemove.size > 0) {
214
+ execFileSync('yarn', ['remove', ...toRemove], {
215
+ cwd: localPkg.dir,
216
+ stdio: 'inherit',
217
+ });
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Adds dependencies that are not properly forwarded from inline dependencies.
223
+ * @param {Array<{pkg: import('../lib/getPackages').ExtendedPackage, node: import('estree').Node}>} toInline
224
+ * @param {import('../lib/getPackages').ExtendedPackage} localPkg
225
+ */
226
+ function addForwardedInlineImports(toInline, localPkg) {
227
+ const declaredProdDeps = new Set([
228
+ ...Object.keys(localPkg.packageJson.dependencies ?? {}),
229
+ ...Object.keys(localPkg.packageJson.peerDependencies ?? {}),
230
+ localPkg.packageJson.name, // include self
231
+ ]);
232
+
233
+ /** @type Map<string, Map<string, string>> */
234
+ const byFlagByName = new Map();
235
+
236
+ for (const { pkg } of toInline) {
237
+ for (const depType of /** @type {const} */ ([
238
+ 'dependencies',
239
+ 'peerDependencies',
240
+ ])) {
241
+ for (const [depName, depQuery] of Object.entries(
242
+ pkg.packageJson[depType] ?? {},
243
+ )) {
244
+ if (!declaredProdDeps.has(depName)) {
245
+ const flag = getAddFlagForDepsField(depType);
246
+ const byName = byFlagByName.get(flag);
247
+ if (byName) {
248
+ const query = byName.get(depName);
249
+ if (query && query !== depQuery) {
250
+ throw new Error(
251
+ `Conflicting dependency queries for inlined package dep ${depName}, got ${query} and ${depQuery}`,
252
+ );
253
+ } else {
254
+ byName.set(depName, depQuery);
255
+ }
256
+ } else {
257
+ byFlagByName.set(flag, new Map([[depName, depQuery]]));
258
+ }
259
+ }
260
+ }
261
+ }
262
+ }
263
+
264
+ for (const [flag, byName] of byFlagByName) {
265
+ const namesWithQuery = [...byName.entries()].map(
266
+ ([name, query]) => `${name}@${query}`,
267
+ );
268
+ execFileSync('yarn', ['add', ...(flag ? [flag] : []), ...namesWithQuery], {
269
+ cwd: localPkg.dir,
270
+ stdio: 'inherit',
271
+ });
272
+ }
273
+ }
274
+
157
275
  /** @type {import('eslint').Rule.RuleModule} */
158
276
  module.exports = {
159
277
  meta: {
@@ -165,6 +283,8 @@ module.exports = {
165
283
  switch:
166
284
  '{{ packageName }} is declared in {{ oldDepsField }}, but should be moved to {{ depsField }} in {{ packageJsonPath }}.',
167
285
  switchBack: 'Switch back to import declaration',
286
+ inlineDirect: `The dependency on the inline package {{ packageName }} must not be declared in package dependencies.`,
287
+ inlineMissing: `Each production dependency from the inline package {{ packageName }} must be re-declared by this package, the following dependencies are missing: {{ missingDeps }}`,
168
288
  },
169
289
  docs: {
170
290
  description:
@@ -189,57 +309,48 @@ module.exports = {
189
309
  /** @type Array<{name: string, flag: string, node: import('estree').Node}> */
190
310
  const importsToAdd = [];
191
311
 
312
+ /** @type Array<{pkg: import('../lib/getPackages').ExtendedPackage, node: import('estree').Node}> */
313
+ const importsToInline = [];
314
+
192
315
  return {
193
316
  // All missing imports that we detect are collected as we traverse, and then we use
194
317
  // the program exit to execute all install directives that have been found.
195
318
  ['Program:exit']() {
196
- /** @type Record<string, Set<string>> */
197
- const byFlag = {};
319
+ if (importsToAdd.length > 0) {
320
+ addMissingImports(importsToAdd, packages, localPkg);
198
321
 
199
- for (const { name, flag } of importsToAdd) {
200
- byFlag[flag] = byFlag[flag] ?? new Set();
201
- byFlag[flag].add(name);
202
- }
203
-
204
- for (const name of byFlag[''] ?? []) {
205
- byFlag['--dev']?.delete(name);
206
- }
207
- for (const name of byFlag['--peer'] ?? []) {
208
- byFlag['']?.delete(name);
209
- byFlag['--dev']?.delete(name);
322
+ // This switches all import directives back to the original import.
323
+ for (const added of importsToAdd) {
324
+ context.report({
325
+ node: added.node,
326
+ messageId: 'switchBack',
327
+ fix(fixer) {
328
+ return fixer.replaceText(added.node, `'${added.name}'`);
329
+ },
330
+ });
331
+ }
332
+ importsToAdd.length = 0;
210
333
  }
211
334
 
212
- for (const [flag, names] of Object.entries(byFlag)) {
213
- // Look up existing version queries in the repo for the same dependency
214
- const namesWithQuery = [...names].map(name =>
215
- addVersionQuery(name, flag, packages),
216
- );
217
-
218
- // The security implication of this is a bit interesting, as crafted add-import
219
- // directives could be used to install malicious packages. However, the same is true
220
- // for adding malicious packages to package.json, so there's no significant difference.
221
- execFileSync(
222
- 'yarn',
223
- ['add', ...(flag ? [flag] : []), ...namesWithQuery],
224
- {
225
- cwd: localPkg.dir,
226
- stdio: 'inherit',
227
- },
228
- );
229
- }
335
+ if (importsToInline.length > 0) {
336
+ removeInlineImports(importsToInline, localPkg);
337
+ addForwardedInlineImports(importsToInline, localPkg);
230
338
 
231
- // This switches all import directives back to the original import.
232
- for (const added of importsToAdd) {
233
- context.report({
234
- node: added.node,
235
- messageId: 'switchBack',
236
- fix(fixer) {
237
- return fixer.replaceText(added.node, `'${added.name}'`);
238
- },
239
- });
339
+ for (const inlined of importsToInline) {
340
+ context.report({
341
+ node: inlined.node,
342
+ messageId: 'switchBack',
343
+ fix(fixer) {
344
+ return fixer.replaceText(
345
+ inlined.node,
346
+ `'${inlined.pkg.packageJson.name}'`,
347
+ );
348
+ },
349
+ });
350
+ }
351
+ importsToInline.length = 0;
240
352
  }
241
353
 
242
- importsToAdd.length = 0;
243
354
  packages.clearCache();
244
355
  },
245
356
  ...visitImports(context, (node, imp) => {
@@ -255,22 +366,99 @@ module.exports = {
255
366
 
256
367
  // Any import directive that is found is collected for processing later
257
368
  if (imp.type === 'directive') {
258
- const parts = imp.path.split(':');
259
- if (parts[1] !== 'add-import') {
260
- return;
369
+ const [, directive, ...args] = imp.path.split(':');
370
+
371
+ if (directive === 'add-import') {
372
+ const [type, name] = args;
373
+ if (!name.match(/^(@[-\w\.~]+\/)?[-\w\.~]*$/i)) {
374
+ throw new Error(
375
+ `Invalid package name to add as dependency: '${name}'`,
376
+ );
377
+ }
378
+
379
+ importsToAdd.push({
380
+ flag: getAddFlagForDepsField(type).trim(),
381
+ name,
382
+ node: imp.node,
383
+ });
384
+ }
385
+
386
+ if (directive === 'inline-imports') {
387
+ const [name] = args;
388
+ const pkg = packages.map.get(name);
389
+ if (!pkg) {
390
+ throw new Error(`Unexpectedly missing inline package: ${name}`);
391
+ }
392
+
393
+ importsToInline.push({
394
+ pkg: pkg,
395
+ node: imp.node,
396
+ });
397
+ }
398
+
399
+ return;
400
+ }
401
+
402
+ // Importing an internal inlined package, whose imports are inlined too
403
+ if (
404
+ imp.type === 'internal' &&
405
+ imp.package.packageJson.backstage?.inline
406
+ ) {
407
+ for (const depType of Object.values(depFields)) {
408
+ if (localPkg.packageJson[depType]?.[imp.packageName]) {
409
+ context.report({
410
+ node,
411
+ messageId: 'inlineDirect',
412
+ data: {
413
+ packageName: imp.packageName,
414
+ },
415
+ fix: fixer => {
416
+ return fixer.replaceText(
417
+ imp.node,
418
+ `'directive:inline-imports:${imp.packageName}'`,
419
+ );
420
+ },
421
+ });
422
+ return;
423
+ }
261
424
  }
262
- const [type, name] = parts.slice(2);
263
- if (!name.match(/^(@[-\w\.~]+\/)?[-\w\.~]*$/i)) {
264
- throw new Error(
265
- `Invalid package name to add as dependency: '${name}'`,
266
- );
425
+
426
+ const missingDeps = [];
427
+ const declaredProdDeps = new Set([
428
+ ...Object.keys(localPkg.packageJson.dependencies ?? {}),
429
+ ...Object.keys(localPkg.packageJson.peerDependencies ?? {}),
430
+ localPkg.packageJson.name, // include self
431
+ ]);
432
+ for (const depType of /** @type {const} */ ([
433
+ 'dependencies',
434
+ 'peerDependencies',
435
+ ])) {
436
+ for (const depName of Object.keys(
437
+ imp.package.packageJson[depType] ?? {},
438
+ )) {
439
+ if (!declaredProdDeps.has(depName)) {
440
+ missingDeps.push(depName);
441
+ }
442
+ }
443
+ }
444
+
445
+ if (missingDeps.length > 0) {
446
+ context.report({
447
+ node,
448
+ messageId: 'inlineMissing',
449
+ data: {
450
+ packageName: imp.packageName,
451
+ missingDeps: missingDeps.join(', '),
452
+ },
453
+ fix: fixer => {
454
+ return fixer.replaceText(
455
+ imp.node,
456
+ `'directive:inline-imports:${imp.packageName}'`,
457
+ );
458
+ },
459
+ });
267
460
  }
268
461
 
269
- importsToAdd.push({
270
- flag: getAddFlagForDepsField(type).trim(),
271
- name,
272
- node: imp.node,
273
- });
274
462
  return;
275
463
  }
276
464
 
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "@internal/bar",
3
+ "backstage": {
4
+ "role": "frontend-plugin"
5
+ },
3
6
  "exports": {
4
7
  ".": "./src/index.ts",
5
8
  "./BarPage": "./src/components/Bar.tsx",
6
9
  "./package.json": "./package.json"
7
10
  },
8
- "backstage": {
9
- "role": "frontend-plugin"
10
- },
11
11
  "dependencies": {
12
+ "inline-dep": "*",
12
13
  "react-router": "*"
13
14
  },
14
15
  "devDependencies": {
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@internal/inline",
3
+ "backstage": {
4
+ "inline": true
5
+ },
6
+ "files": [
7
+ "dist",
8
+ "type-utils"
9
+ ],
10
+ "dependencies": {
11
+ "@internal/inline-dep-valid": "workspace:^"
12
+ },
13
+ "peerDependencies": {
14
+ "react": "*"
15
+ }
16
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@internal/inline-dep-direct",
3
+ "files": [
4
+ "dist",
5
+ "type-utils"
6
+ ],
7
+ "dependencies": {
8
+ "@internal/inline": "workspace:^",
9
+ "@internal/inline-dep-valid": "workspace:^",
10
+ "react": "*"
11
+ }
12
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "@internal/inline-dep-missing",
3
+ "files": [
4
+ "dist",
5
+ "type-utils"
6
+ ]
7
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@internal/inline-dep-valid",
3
+ "files": [
4
+ "dist",
5
+ "type-utils"
6
+ ],
7
+ "peerDependencies": {
8
+ "react": "*"
9
+ }
10
+ }
@@ -52,6 +52,12 @@ const ERR_SWITCHED = (
52
52
  const ERR_SWITCH_BACK = () => ({
53
53
  message: 'Switch back to import declaration',
54
54
  });
55
+ const ERR_INLINE_DIRECT = (name: string) => ({
56
+ message: `The dependency on the inline package ${name} must not be declared in package dependencies.`,
57
+ });
58
+ const ERR_INLINE_MISSING = (name: string, missing: string) => ({
59
+ message: `Each production dependency from the inline package ${name} must be re-declared by this package, the following dependencies are missing: ${missing}`,
60
+ });
55
61
 
56
62
  // cwd must be restored
57
63
  const origDir = process.cwd();
@@ -102,6 +108,17 @@ ruleTester.run(RULE, rule, {
102
108
  code: `require('lod' + 'ash')`,
103
109
  filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
104
110
  },
111
+ {
112
+ code: `import '@internal/inline'`,
113
+ filename: joinPath(FIXTURE, 'packages/inline-dep-valid/src/index.ts'),
114
+ },
115
+ {
116
+ code: `import '@internal/inline'`,
117
+ filename: joinPath(
118
+ FIXTURE,
119
+ 'packages/inline-dep-valid/src/index.test.ts',
120
+ ),
121
+ },
105
122
  ],
106
123
  invalid: [
107
124
  {
@@ -264,6 +281,29 @@ ruleTester.run(RULE, rule, {
264
281
  ),
265
282
  ],
266
283
  },
284
+ {
285
+ code: `import '@internal/inline'`,
286
+ output: `import 'directive:inline-imports:@internal/inline'`,
287
+ filename: joinPath(
288
+ FIXTURE,
289
+ 'packages/inline-dep-invalid-direct/src/index.ts',
290
+ ),
291
+ errors: [ERR_INLINE_DIRECT('@internal/inline')],
292
+ },
293
+ {
294
+ code: `import '@internal/inline'`,
295
+ output: `import 'directive:inline-imports:@internal/inline'`,
296
+ filename: joinPath(
297
+ FIXTURE,
298
+ 'packages/inline-dep-invalid-missing/src/index.ts',
299
+ ),
300
+ errors: [
301
+ ERR_INLINE_MISSING(
302
+ '@internal/inline',
303
+ '@internal/inline-dep-valid, react',
304
+ ),
305
+ ],
306
+ },
267
307
 
268
308
  // Switching back to original import declarations
269
309
  {
@@ -302,5 +342,14 @@ ruleTester.run(RULE, rule, {
302
342
  filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
303
343
  errors: [ERR_SWITCH_BACK()],
304
344
  },
345
+ {
346
+ code: `import 'directive:inline-imports:@internal/inline'`,
347
+ output: `import '@internal/inline'`,
348
+ filename: joinPath(
349
+ FIXTURE,
350
+ 'packages/inline-dep-invalid-direct/src/index.ts',
351
+ ),
352
+ errors: [ERR_SWITCH_BACK()],
353
+ },
305
354
  ],
306
355
  });