@k8slens/package-build 3.2.0 → 3.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k8slens/package-build",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "lens": {
5
5
  "env": "agnostic",
6
6
  "layer": "infrastructure",
@@ -42,7 +42,8 @@
42
42
  },
43
43
  "bin": {
44
44
  "lens-package-build": "bin/package-build",
45
- "lens-extension-dependencies-check": "bin/extension-dependencies-check"
45
+ "lens-extension-dependencies-check": "bin/extension-dependencies-check",
46
+ "lens-extension-publish-check": "bin/extension-publish-check"
46
47
  },
47
48
  "files": [
48
49
  "index.js",
@@ -60,6 +61,7 @@
60
61
  "test:unit": "jest"
61
62
  },
62
63
  "dependencies": {
64
+ "acorn": "^8.15.0",
63
65
  "esbuild": "^0.25.4",
64
66
  "fast-glob": "^3.3.3",
65
67
  "lodash": "^4.17.21",
@@ -72,6 +74,7 @@
72
74
  },
73
75
  "devDependencies": {
74
76
  "@async-fn/jest": "^1.6.4",
77
+ "@jridgewell/trace-mapping": "^0.3.31",
75
78
  "@k8slens/typescript": "^8.1.0",
76
79
  "@lensapp/jest": "^7.2.0",
77
80
  "@types/semver": "^7.7.0"
@@ -86,6 +89,7 @@
86
89
  "inputs": [
87
90
  "{projectRoot}/bin/package-build",
88
91
  "{projectRoot}/bin/extension-dependencies-check",
92
+ "{projectRoot}/bin/extension-publish-check",
89
93
  "{projectRoot}/index.ts",
90
94
  "{projectRoot}/src/**/*.ts"
91
95
  ],
@@ -0,0 +1,2 @@
1
+ export { default as identity } from "lodash/identity";
2
+ export { sep } from "node:path";
@@ -0,0 +1,141 @@
1
+ import { originalPositionFor, TraceMap } from "@jridgewell/trace-mapping";
2
+ import { build } from "esbuild";
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { asciiOutputPlugin } from "./ascii-output-plugin";
7
+
8
+ const source = [
9
+ "export const commented = 1 && // A comment — with an em dash",
10
+ " 2;",
11
+ "const fortyTwo = 42;",
12
+ "const eightyFour = 84;",
13
+ "export const adWordsAndMore = [/^(广告|Реклама|publicité)$/iu, fortyTwo];",
14
+ "export const constantAndMore = [/π|ℯ/, /ℯ/, eightyFour];",
15
+ "export const emoji = /🪦+/u;",
16
+ "export const emojiWithoutUnicodeFlag = /^🪦$/;",
17
+ "export const escapedDash = /\\—/;",
18
+ "export const backslashThenDash = /\\\\—/;",
19
+ "export const raw = String.raw`—`;",
20
+ "export const plain = `—`;",
21
+ ].join("\n");
22
+
23
+ describe("ascii output of a build", () => {
24
+ let directory: string;
25
+ let bundle: string;
26
+ let warnings: string[];
27
+ let exportsOfBundle: Record<string, unknown>;
28
+ let sourceMap: TraceMap;
29
+
30
+ beforeAll(async () => {
31
+ directory = mkdtempSync(path.join(tmpdir(), "ascii-output-plugin-"));
32
+
33
+ const entryPoint = path.join(directory, "entry.js");
34
+ const outfile = path.join(directory, "bundle.js");
35
+
36
+ writeFileSync(entryPoint, source);
37
+
38
+ const result = await build({
39
+ entryPoints: [entryPoint],
40
+ outfile,
41
+ bundle: true,
42
+ format: "cjs",
43
+ platform: "node",
44
+ sourcemap: true,
45
+ logLevel: "silent",
46
+ plugins: [asciiOutputPlugin()],
47
+ });
48
+
49
+ warnings = result.warnings.map((warning) => warning.text);
50
+ bundle = readFileSync(outfile, "utf8");
51
+ sourceMap = new TraceMap(readFileSync(`${outfile}.map`, "utf8"));
52
+ exportsOfBundle = evaluate(bundle);
53
+ });
54
+
55
+ afterAll(() => {
56
+ rmSync(directory, { recursive: true, force: true });
57
+ });
58
+
59
+ it("leaves nothing outside ASCII but the raw text of a tagged template literal", () => {
60
+ expect(bundle.match(/[\u0080-\uffff]/g)).toEqual(["—"]);
61
+ });
62
+
63
+ it("warns of the character left outside Latin-1", () => {
64
+ expect(warnings).toEqual([expect.stringContaining("1 characters above Latin-1 could not be escaped")]);
65
+ });
66
+
67
+ it("escapes the characters of a comment", () => {
68
+ expect(bundle).toContain("// A comment \\u2014 with an em dash");
69
+ });
70
+
71
+ it("keeps a regular expression with the unicode flag matching what it matched", () => {
72
+ const [adWords] = exportsOfBundle.adWordsAndMore as RegExp[];
73
+
74
+ expect(["广告", "РЕКЛАМА", "PUBLICITÉ", "other"].map((word) => adWords.test(word))).toEqual([
75
+ true,
76
+ true,
77
+ true,
78
+ false,
79
+ ]);
80
+ });
81
+
82
+ it("keeps a regular expression without flags matching what it matched", () => {
83
+ const [constant] = exportsOfBundle.constantAndMore as RegExp[];
84
+
85
+ expect(["π", "ℯ", "p"].map((word) => constant.test(word))).toEqual([true, true, false]);
86
+ });
87
+
88
+ it("keeps a character beyond the basic plane a single character to a quantifier with the unicode flag", () => {
89
+ expect((exportsOfBundle.emoji as RegExp).test("🪦🪦")).toBe(true);
90
+ });
91
+
92
+ it("keeps a character beyond the basic plane two characters without the unicode flag", () => {
93
+ expect((exportsOfBundle.emojiWithoutUnicodeFlag as RegExp).test("🪦")).toBe(true);
94
+ });
95
+
96
+ it("keeps a character escaped by a backslash in a regular expression the character", () => {
97
+ expect((exportsOfBundle.escapedDash as RegExp).source).toBe("\\u2014");
98
+ });
99
+
100
+ it("keeps a backslash escaped by a backslash before a character in a regular expression a backslash", () => {
101
+ expect((exportsOfBundle.backslashThenDash as RegExp).test("\\—")).toBe(true);
102
+ });
103
+
104
+ it("keeps the raw text of a tagged template literal", () => {
105
+ expect(exportsOfBundle.raw).toBe("—");
106
+ });
107
+
108
+ it("keeps an untagged template literal its text", () => {
109
+ expect(exportsOfBundle.plain).toBe("—");
110
+ });
111
+
112
+ it("maps code after an escaped regular expression to where it came from", () => {
113
+ expect(originalPositionOf(bundle, sourceMap, "fortyTwo];")).toEqual(positionInSource("fortyTwo];"));
114
+ });
115
+
116
+ it("maps code after two escaped regular expressions on a line to where it came from", () => {
117
+ expect(originalPositionOf(bundle, sourceMap, "eightyFour]")).toEqual(positionInSource("eightyFour]"));
118
+ });
119
+ });
120
+
121
+ const evaluate = (bundle: string) => {
122
+ const module = { exports: {} as Record<string, unknown> };
123
+
124
+ new Function("module", "exports", "require", bundle)(module, module.exports, require);
125
+
126
+ return module.exports;
127
+ };
128
+
129
+ const positionOf = (code: string, text: string) => {
130
+ const linesBefore = code.slice(0, code.indexOf(text)).split("\n");
131
+
132
+ return { line: linesBefore.length, column: linesBefore[linesBefore.length - 1].length };
133
+ };
134
+
135
+ const positionInSource = (text: string) => positionOf(source, text);
136
+
137
+ const originalPositionOf = (bundle: string, sourceMap: TraceMap, text: string) => {
138
+ const { line, column } = originalPositionFor(sourceMap, positionOf(bundle, text));
139
+
140
+ return { line, column };
141
+ };
@@ -0,0 +1,342 @@
1
+ import { tokenizer, tokTypes } from "acorn";
2
+ import type { Plugin } from "esbuild";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+
5
+ /** How much a replacement on a line moved what came after it, for the source map. */
6
+ interface ColumnShift {
7
+ /** Zero-based line of the generated code. */
8
+ readonly line: number;
9
+ /** Zero-based column, in the code before escaping, where the replaced text started. */
10
+ readonly column: number;
11
+ /** How many characters longer the replacement is than what it replaced. */
12
+ readonly delta: number;
13
+ }
14
+
15
+ /** A character above Latin-1 that could not be escaped without changing what the code does. */
16
+ interface UnescapedCharacter {
17
+ /** One-based, as an editor shows it. */
18
+ readonly line: number;
19
+ /** One-based, as an editor shows it. */
20
+ readonly column: number;
21
+ readonly character: string;
22
+ }
23
+
24
+ interface EscapedCode {
25
+ readonly code: string;
26
+ readonly columnShifts: ColumnShift[];
27
+ readonly unescapedCharacters: UnescapedCharacter[];
28
+ }
29
+
30
+ const nonAscii = /[\u0080-\uffff]/g;
31
+
32
+ /**
33
+ * Escapes every character outside ASCII that esbuild's `charset: "ascii"` leaves as it is: those in
34
+ * comments, which esbuild keeps verbatim, and those in regular expression literals. In both, `\uXXXX`
35
+ * (a surrogate pair as two of them) means what the character did, with or without the `u` flag.
36
+ *
37
+ * A character anywhere else is left alone and reported: esbuild escapes strings, untagged template
38
+ * literals and identifiers already, so what remains is a tagged template literal, whose raw text the
39
+ * tag can read (`String.raw`), or a line or paragraph separator in a comment, where it ends a line.
40
+ */
41
+ const escapeNonAscii = (code: string): EscapedCode => {
42
+ nonAscii.lastIndex = 0;
43
+
44
+ if (!nonAscii.test(code)) {
45
+ return { code, columnShifts: [], unescapedCharacters: [] };
46
+ }
47
+
48
+ const escapableRanges = getRangesOfCommentsAndRegularExpressions(code);
49
+ const pieces: string[] = [];
50
+ const columnShifts: ColumnShift[] = [];
51
+ const unescapedCharacters: UnescapedCharacter[] = [];
52
+
53
+ let copiedUntil = 0;
54
+ let rangeIndex = 0;
55
+ let line = 0;
56
+ let lineStart = 0;
57
+
58
+ nonAscii.lastIndex = 0;
59
+
60
+ for (let match = nonAscii.exec(code); match; match = nonAscii.exec(code)) {
61
+ const position = match.index;
62
+
63
+ for (let newline = code.indexOf("\n", lineStart); newline !== -1 && newline < position; ) {
64
+ line++;
65
+ lineStart = newline + 1;
66
+ newline = code.indexOf("\n", lineStart);
67
+ }
68
+
69
+ while (rangeIndex < escapableRanges.length && escapableRanges[rangeIndex].end <= position) {
70
+ rangeIndex++;
71
+ }
72
+
73
+ const range = escapableRanges[rangeIndex];
74
+ const characterCode = code.charCodeAt(position);
75
+ const isLineTerminator = characterCode === 0x2028 || characterCode === 0x2029;
76
+
77
+ if (!range || range.start > position || isLineTerminator) {
78
+ if (characterCode > 0xff) {
79
+ unescapedCharacters.push({ line: line + 1, column: position - lineStart + 1, character: match[0] });
80
+ }
81
+
82
+ continue;
83
+ }
84
+
85
+ // In a regular expression `\—` is an identity escape of the character, which `\\u2014` would not be.
86
+ const replacedFrom =
87
+ range.kind === "regular-expression" && isEscapedByBackslash(code, position) ? position - 1 : position;
88
+
89
+ const replacement = `\\u${characterCode.toString(16).toUpperCase().padStart(4, "0")}`;
90
+
91
+ pieces.push(code.slice(copiedUntil, replacedFrom), replacement);
92
+ copiedUntil = position + 1;
93
+
94
+ columnShifts.push({
95
+ line,
96
+ column: replacedFrom - lineStart,
97
+ delta: replacement.length - (copiedUntil - replacedFrom),
98
+ });
99
+ }
100
+
101
+ pieces.push(code.slice(copiedUntil));
102
+
103
+ return { code: pieces.join(""), columnShifts, unescapedCharacters };
104
+ };
105
+
106
+ /**
107
+ * Moves the generated columns of a source map's mappings as the escaping moved the code. Works on the
108
+ * bytes: the mappings are ASCII, and decoding the sources' contents around them would take long.
109
+ */
110
+ const shiftColumnsOfSourceMap = (sourceMap: Buffer, columnShifts: ColumnShift[]): Buffer => {
111
+ const key = sourceMap.indexOf('"mappings"');
112
+
113
+ if (key === -1 || columnShifts.length === 0) {
114
+ return sourceMap;
115
+ }
116
+
117
+ const start = sourceMap.indexOf('"', sourceMap.indexOf(":", key)) + 1;
118
+ const end = sourceMap.indexOf('"', start);
119
+ const mappings = sourceMap.toString("latin1", start, end);
120
+ const shiftsOfLines = [...Map.groupBy(columnShifts, (shift) => shift.line)].sort(([left], [right]) => left - right);
121
+ const pieces: string[] = [];
122
+
123
+ let line = 0;
124
+ let lineStart = 0;
125
+ let copiedUntil = 0;
126
+
127
+ for (const [lineToShift, shifts] of shiftsOfLines) {
128
+ while (line < lineToShift && lineStart !== -1) {
129
+ const semicolon = mappings.indexOf(";", lineStart);
130
+
131
+ lineStart = semicolon === -1 ? -1 : semicolon + 1;
132
+ line++;
133
+ }
134
+
135
+ if (lineStart === -1) {
136
+ break;
137
+ }
138
+
139
+ const semicolon = mappings.indexOf(";", lineStart);
140
+ const lineEnd = semicolon === -1 ? mappings.length : semicolon;
141
+
142
+ pieces.push(mappings.slice(copiedUntil, lineStart), shiftColumnsOfLine(mappings.slice(lineStart, lineEnd), shifts));
143
+ copiedUntil = lineEnd;
144
+ }
145
+
146
+ pieces.push(mappings.slice(copiedUntil));
147
+
148
+ return Buffer.concat([sourceMap.subarray(0, start), Buffer.from(pieces.join(""), "latin1"), sourceMap.subarray(end)]);
149
+ };
150
+
151
+ /**
152
+ * Escapes what esbuild left outside ASCII in the bundle it wrote to `outfile`, and moves the columns
153
+ * of its source map to match. A string of the source of a script is one byte per character in V8
154
+ * only when every character of it is Latin-1: a single em dash in a comment makes it two.
155
+ */
156
+ export const asciiOutputPlugin = (): Plugin => ({
157
+ name: "ascii-output",
158
+
159
+ setup: (build) => {
160
+ build.onEnd(async (result) => {
161
+ const { outfile, write } = build.initialOptions;
162
+
163
+ if (result.errors.length > 0 || !outfile || write === false) {
164
+ return;
165
+ }
166
+
167
+ const code = await readFile(outfile, "utf8");
168
+
169
+ let escaped: EscapedCode;
170
+
171
+ try {
172
+ escaped = escapeNonAscii(code);
173
+ } catch (error) {
174
+ return { warnings: [{ text: `Left ${outfile} as it is, failing to escape it: ${String(error)}` }] };
175
+ }
176
+
177
+ if (escaped.code !== code) {
178
+ await writeFile(outfile, escaped.code);
179
+ await shiftColumnsOfSourceMapFile(`${outfile}.map`, escaped.columnShifts);
180
+ }
181
+
182
+ return { warnings: getWarnings(outfile, escaped.unescapedCharacters) };
183
+ });
184
+ },
185
+ });
186
+
187
+ interface EscapableRange {
188
+ readonly kind: "comment" | "regular-expression";
189
+ readonly start: number;
190
+ readonly end: number;
191
+ }
192
+
193
+ const getRangesOfCommentsAndRegularExpressions = (code: string): EscapableRange[] => {
194
+ const comments: EscapableRange[] = [];
195
+ const regularExpressions: EscapableRange[] = [];
196
+
197
+ const tokens = tokenizer(code, {
198
+ ecmaVersion: "latest",
199
+ sourceType: "script",
200
+ allowHashBang: true,
201
+ allowReturnOutsideFunction: true,
202
+ allowAwaitOutsideFunction: true,
203
+ onComment: (_, __, start, end) => {
204
+ comments.push({ kind: "comment", start, end });
205
+ },
206
+ });
207
+
208
+ for (const token of tokens) {
209
+ if (token.type === tokTypes.regexp) {
210
+ // The flags after the closing slash are letters.
211
+ const closingSlash = code.lastIndexOf("/", token.end - 1);
212
+
213
+ regularExpressions.push({ kind: "regular-expression", start: token.start + 1, end: closingSlash });
214
+ }
215
+ }
216
+
217
+ return mergeSorted(comments, regularExpressions);
218
+ };
219
+
220
+ const mergeSorted = (left: EscapableRange[], right: EscapableRange[]) => {
221
+ const merged: EscapableRange[] = [];
222
+ let leftIndex = 0;
223
+ let rightIndex = 0;
224
+
225
+ while (leftIndex < left.length || rightIndex < right.length) {
226
+ if (rightIndex >= right.length || (leftIndex < left.length && left[leftIndex].start < right[rightIndex].start)) {
227
+ merged.push(left[leftIndex++]);
228
+ } else {
229
+ merged.push(right[rightIndex++]);
230
+ }
231
+ }
232
+
233
+ return merged;
234
+ };
235
+
236
+ const isEscapedByBackslash = (code: string, position: number) => {
237
+ let backslashes = 0;
238
+
239
+ while (code[position - 1 - backslashes] === "\\") {
240
+ backslashes++;
241
+ }
242
+
243
+ return backslashes % 2 === 1;
244
+ };
245
+
246
+ // Read without checking first that it is there, which the file could stop being in between: a
247
+ // bundle built without a source map simply has none to shift.
248
+ const shiftColumnsOfSourceMapFile = async (path: string, columnShifts: ColumnShift[]) => {
249
+ let sourceMap: Buffer;
250
+
251
+ try {
252
+ sourceMap = await readFile(path);
253
+ } catch (error) {
254
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
255
+ return;
256
+ }
257
+
258
+ throw error;
259
+ }
260
+
261
+ await writeFile(path, shiftColumnsOfSourceMap(sourceMap, columnShifts));
262
+ };
263
+
264
+ // Within a line, a segment's generated column is relative to the previous segment's, and its
265
+ // other fields relative to the previous segment's of the whole map: only the column changes.
266
+ const shiftColumnsOfLine = (line: string, shifts: ColumnShift[]) => {
267
+ if (line === "") {
268
+ return line;
269
+ }
270
+
271
+ const sortedShifts = shifts.toSorted((left, right) => left.column - right.column);
272
+
273
+ let shiftIndex = 0;
274
+ let shiftSoFar = 0;
275
+ let column = 0;
276
+ let previousShiftedColumn = 0;
277
+
278
+ return line
279
+ .split(",")
280
+ .map((segment) => {
281
+ const { value, length } = decodeVlq(segment);
282
+
283
+ column += value;
284
+
285
+ while (shiftIndex < sortedShifts.length && sortedShifts[shiftIndex].column < column) {
286
+ shiftSoFar += sortedShifts[shiftIndex++].delta;
287
+ }
288
+
289
+ const shiftedColumn = column + shiftSoFar;
290
+ const shiftedSegment = encodeVlq(shiftedColumn - previousShiftedColumn) + segment.slice(length);
291
+
292
+ previousShiftedColumn = shiftedColumn;
293
+
294
+ return shiftedSegment;
295
+ })
296
+ .join(",");
297
+ };
298
+
299
+ const base64Digits = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
300
+
301
+ const decodeVlq = (segment: string) => {
302
+ let result = 0;
303
+ let shift = 0;
304
+ let length = 0;
305
+ let digit: number;
306
+
307
+ do {
308
+ digit = base64Digits.indexOf(segment[length++]);
309
+ result += (digit & 31) * 2 ** shift;
310
+ shift += 5;
311
+ } while (digit & 32);
312
+
313
+ const magnitude = Math.floor(result / 2);
314
+
315
+ return { value: result % 2 === 1 ? -magnitude : magnitude, length };
316
+ };
317
+
318
+ const encodeVlq = (value: number) => {
319
+ let rest = value < 0 ? -value * 2 + 1 : value * 2;
320
+ let encoded = "";
321
+
322
+ do {
323
+ const digit = rest % 32;
324
+
325
+ rest = Math.floor(rest / 32);
326
+ encoded += base64Digits[rest > 0 ? digit | 32 : digit];
327
+ } while (rest > 0);
328
+
329
+ return encoded;
330
+ };
331
+
332
+ const getWarnings = (outfile: string, unescapedCharacters: UnescapedCharacter[]) =>
333
+ unescapedCharacters.length === 0
334
+ ? []
335
+ : [
336
+ {
337
+ text: `${unescapedCharacters.length} characters above Latin-1 could not be escaped in ${outfile}, which V8 therefore keeps at two bytes per character: ${unescapedCharacters
338
+ .slice(0, 5)
339
+ .map(({ line, column, character }) => `"${character}" at ${line}:${column}`)
340
+ .join(", ")}`,
341
+ },
342
+ ];
@@ -0,0 +1,129 @@
1
+ import { build } from "esbuild";
2
+ import path from "node:path";
3
+ import { type DeclaredImportsPluginParams, declaredImportsPlugin } from "./declared-imports-plugin";
4
+
5
+ // A package of the Lens monorepo has a "lens" section in its package.json.
6
+ const ofMonorepo = { lens: {} };
7
+
8
+ describe("imports of other packages in a package build", () => {
9
+ describe("given the imported package is declared in dependencies, when built", () => {
10
+ let exportsOfBundle: Record<string, unknown>;
11
+
12
+ beforeAll(async () => {
13
+ const bundle = await buildFixture({ dependencies: { lodash: "^4.0.0" } });
14
+
15
+ exportsOfBundle = evaluate(bundle);
16
+ });
17
+
18
+ it("requires the package at runtime instead of inlining it", () => {
19
+ expect(exportsOfBundle.identity).toBe("some-required-lodash/identity");
20
+ });
21
+
22
+ it("requires node builtins at runtime", () => {
23
+ expect(exportsOfBundle.sep).toBe("some-required-node:path");
24
+ });
25
+ });
26
+
27
+ it("given the imported package is declared in peerDependencies, when built, requires it at runtime", async () => {
28
+ const bundle = await buildFixture({ peerDependencies: { lodash: "^4.0.0" } });
29
+
30
+ expect(evaluate(bundle).identity).toBe("some-required-lodash/identity");
31
+ });
32
+
33
+ it("given a package of the monorepo imports a package that is only a devDependency, when built, fails naming the import, the importer and the fix", async () => {
34
+ await expect(buildFixture({ ...ofMonorepo, devDependencies: { lodash: "^4.0.0" } })).rejects.toThrow(
35
+ [
36
+ '"@some-scope/package-importing-a-package" imports "lodash/identity" from "index.js", but "lodash" is only a devDependency, so the build would inline it, and everything it imports, into dist.',
37
+ 'Fix: move "lodash" to "dependencies" (or "peerDependencies") if production code needs it.',
38
+ "If it is test machinery, keep the import out of files the entry point reaches.",
39
+ 'If inlining it is deliberate, list "lodash" in "lens.inlinedDependencies" of package.json.',
40
+ ].join("\n"),
41
+ );
42
+ });
43
+
44
+ it("given the imported package is a devDependency declared as inlined, when built, inlines it", async () => {
45
+ const bundle = await buildFixture({
46
+ devDependencies: { lodash: "^4.0.0" },
47
+ lens: { inlinedDependencies: ["lodash"] },
48
+ });
49
+
50
+ expect((evaluate(bundle).identity as (value: string) => string)("some-value")).toBe("some-value");
51
+ });
52
+
53
+ it("given the imported package is declared as inlined but not as a devDependency, when built, fails naming the fix", async () => {
54
+ await expect(buildFixture({ lens: { inlinedDependencies: ["lodash"] } })).rejects.toThrow(
55
+ '"@some-scope/package-importing-a-package" imports "lodash/identity" from "index.js", and "lens.inlinedDependencies" lists "lodash", but it is not a devDependency. Fix: declare it in "devDependencies".',
56
+ );
57
+ });
58
+
59
+ it("given a package of the monorepo imports a package not declared at all, when built, fails naming the import, the importer and the fix", async () => {
60
+ await expect(buildFixture(ofMonorepo)).rejects.toThrow(
61
+ '"@some-scope/package-importing-a-package" imports "lodash/identity" from "index.js", but "lodash" is not a dependency of the package. Fix: declare it in "dependencies" (or "peerDependencies").',
62
+ );
63
+ });
64
+
65
+ describe("given a package outside the monorepo, such as an extension, imports a package that is only a devDependency, when built", () => {
66
+ let result: Awaited<ReturnType<typeof buildFixtureWithWarnings>>;
67
+
68
+ beforeAll(async () => {
69
+ result = await buildFixtureWithWarnings({ devDependencies: { lodash: "^4.0.0" } });
70
+ });
71
+
72
+ it("inlines it, as the build did before", () => {
73
+ expect((evaluate(result.bundle).identity as (value: string) => string)("some-value")).toBe("some-value");
74
+ });
75
+
76
+ it("warns naming the import, the importer and the fix", () => {
77
+ expect(result.warnings).toEqual([
78
+ expect.stringContaining(
79
+ '"@some-scope/package-importing-a-package" imports "lodash/identity" from "index.js", but "lodash" is only a devDependency',
80
+ ),
81
+ ]);
82
+ });
83
+ });
84
+
85
+ it("given a package outside the monorepo imports a package not declared at all, when built, warns naming the fix", async () => {
86
+ const { warnings } = await buildFixtureWithWarnings({});
87
+
88
+ expect(warnings).toEqual([expect.stringContaining('Fix: declare it in "dependencies" (or "peerDependencies").')]);
89
+ });
90
+ });
91
+
92
+ const buildFixture = async (declarations: Omit<DeclaredImportsPluginParams["packageJson"], "name">) =>
93
+ (await buildFixtureWithWarnings(declarations)).bundle;
94
+
95
+ const buildFixtureWithWarnings = async (declarations: Omit<DeclaredImportsPluginParams["packageJson"], "name">) => {
96
+ const packageDirectory = path.join(__dirname, "__fixtures__", "package-importing-a-package");
97
+
98
+ const result = await build({
99
+ entryPoints: [path.join(packageDirectory, "index.js")],
100
+ bundle: true,
101
+ write: false,
102
+ format: "cjs",
103
+ platform: "node",
104
+ logLevel: "silent",
105
+ plugins: [
106
+ declaredImportsPlugin({
107
+ packageDirectory,
108
+ packageJson: { name: "@some-scope/package-importing-a-package", ...declarations },
109
+ }),
110
+ ],
111
+ });
112
+
113
+ return {
114
+ bundle: result.outputFiles[0].text,
115
+ warnings: result.warnings.map(({ text }) => text),
116
+ };
117
+ };
118
+
119
+ const evaluate = (bundle: string) => {
120
+ const module = { exports: {} as Record<string, unknown> };
121
+ const requireStub = (specifier: string) =>
122
+ specifier === "node:path"
123
+ ? { sep: `some-required-${specifier}` }
124
+ : { __esModule: true, default: `some-required-${specifier}` };
125
+
126
+ new Function("require", "module", "exports", bundle)(requireStub, module, module.exports);
127
+
128
+ return module.exports;
129
+ };