@atlaspack/optimizer-swc 2.16.9-dev-9ef951846.0 → 2.16.10-dev-fix-sourcemaps-50acc1acf.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.
@@ -0,0 +1,370 @@
1
+ import assert from 'assert';
2
+ import SourceMap from '@atlaspack/source-map';
3
+
4
+ import {minifyWithSourceMap} from '../src/minifyWithSourceMap';
5
+
6
+ const PROJECT_ROOT = '/project';
7
+
8
+ /**
9
+ * Walk the bundle for a set of literal strings, and for each one, resolve its
10
+ * position via the source map and check that the resolved original source line
11
+ * actually contains that string. This is the same "string-literal probe"
12
+ * technique that exposed the real-world misalignment.
13
+ *
14
+ * Returns { matched, total, mismatches } so tests can assert specific bounds.
15
+ */
16
+ function probeStringLiterals(opts: {
17
+ bundle: string;
18
+ map: SourceMap;
19
+ /**
20
+ * Map of source name → expected (line, col) of each probe within that source.
21
+ * The probe is considered "correctly aligned" when the source map's reverse
22
+ * lookup returns the exact (source, line, col) where the probe actually
23
+ * lives.
24
+ */
25
+ expected: Record<
26
+ string,
27
+ {
28
+ source: string;
29
+ line: number;
30
+ column: number;
31
+ }
32
+ >;
33
+ probes: string[];
34
+ }): Promise<{
35
+ matched: number;
36
+ total: number;
37
+ mismatches: Array<{
38
+ text: string;
39
+ bundleLine: number;
40
+ bundleCol: number;
41
+ resolved: {source?: string; line?: number; column?: number} | null;
42
+ expected: {source: string; line: number; column: number};
43
+ }>;
44
+ }> {
45
+ const bundleLines = opts.bundle.split('\n');
46
+ let matched = 0;
47
+ const mismatches: Array<any> = [];
48
+
49
+ for (const probe of opts.probes) {
50
+ const expected = opts.expected[probe];
51
+ let found = false;
52
+ for (let l = 0; l < bundleLines.length; l++) {
53
+ const re = new RegExp(`['"\`]${probe}['"\`]`);
54
+ const m = bundleLines[l].match(re);
55
+ if (!m) continue;
56
+ const idx = (m.index ?? 0) + 1; // step past the quote so we land on the literal text
57
+ const resolved = opts.map.findClosestMapping(l + 1, idx);
58
+ const resolvedSource = resolved?.source;
59
+ const resolvedLine = resolved?.original?.line;
60
+ const resolvedCol = resolved?.original?.column;
61
+ // We require the resolved (source, line, column) to match the
62
+ // expected position exactly. The buggy `extends()` approach loses
63
+ // column precision (it snaps to the nearest input mapping), so this
64
+ // check is what surfaces the bug.
65
+ const ok =
66
+ resolvedSource === expected.source &&
67
+ resolvedLine === expected.line &&
68
+ resolvedCol === expected.column;
69
+ if (ok) {
70
+ matched++;
71
+ } else {
72
+ mismatches.push({
73
+ text: probe,
74
+ bundleLine: l + 1,
75
+ bundleCol: idx,
76
+ resolved: {
77
+ source: resolvedSource,
78
+ line: resolvedLine,
79
+ column: resolvedCol,
80
+ },
81
+ expected,
82
+ });
83
+ }
84
+ found = true;
85
+ break;
86
+ }
87
+ if (!found) {
88
+ mismatches.push({
89
+ text: probe,
90
+ bundleLine: -1,
91
+ bundleCol: -1,
92
+ resolved: null,
93
+ expected,
94
+ });
95
+ }
96
+ }
97
+
98
+ return {matched, total: opts.probes.length, mismatches};
99
+ }
100
+
101
+ describe('minifyWithSourceMap', () => {
102
+ it('passes through when source map composition is not needed', async () => {
103
+ const code = 'console.log("hello");';
104
+ const out = await minifyWithSourceMap(code, null, {
105
+ projectRoot: PROJECT_ROOT,
106
+ });
107
+ assert.ok(out.code.includes('"hello"'));
108
+ });
109
+
110
+ it('forwards the input map to swc as `inputSourceMap` (so map composition happens during minify, not as a post-hoc snap)', async () => {
111
+ // This is a regression test for a real Confluence production bug:
112
+ // `SwcOptimizer` used to compose its (minified -> code) map with the
113
+ // packager's (code -> original sources) map by calling
114
+ // `SourceMap.extends(originalMap)` *after* swc had finished minifying.
115
+ //
116
+ // That post-hoc composition uses `findClosestMapping`, which:
117
+ // - snaps to the previous mapping when the looked-up position has no
118
+ // exact mapping (losing column precision), and
119
+ // - can snap *across asset boundaries* when the input map has gaps
120
+ // (mis-attributing a token to the wrong source file).
121
+ //
122
+ // String-literal probes of real Confluence bundles showed 30–65 % of
123
+ // mappings resolving to a source line that did not actually contain
124
+ // the literal – evidence the maps were systematically wrong.
125
+ //
126
+ // The fix is to hand the input map to swc via the `inputSourceMap`
127
+ // option, so swc composes the maps per-token natively during
128
+ // minification.
129
+ //
130
+ // We can't easily reproduce the bug end-to-end here with synthetic data
131
+ // (the test inputs are too regular for `findClosestMapping`'s snap-back
132
+ // to produce visibly wrong output), so the test focuses on the
133
+ // observable contract: when an input map is provided, the resulting
134
+ // map's `sources` array must contain the original sources, and the
135
+ // resulting map's mappings must successfully reverse-resolve a probe
136
+ // back to one of those original sources.
137
+
138
+ const source = [
139
+ 'console.log("alpha");',
140
+ 'console.log("beta");',
141
+ 'console.log("gamma");',
142
+ ].join('\n');
143
+ const sourceName = 'src/widget.js';
144
+
145
+ const originalMap = new SourceMap(PROJECT_ROOT);
146
+ originalMap.addIndexedMappings([
147
+ {
148
+ generated: {line: 1, column: 0},
149
+ original: {line: 1, column: 0},
150
+ source: sourceName,
151
+ },
152
+ {
153
+ generated: {line: 2, column: 0},
154
+ original: {line: 2, column: 0},
155
+ source: sourceName,
156
+ },
157
+ {
158
+ generated: {line: 3, column: 0},
159
+ original: {line: 3, column: 0},
160
+ source: sourceName,
161
+ },
162
+ ]);
163
+ originalMap.setSourceContent(sourceName, source);
164
+
165
+ const out = await minifyWithSourceMap(source, originalMap, {
166
+ projectRoot: PROJECT_ROOT,
167
+ mangle: false,
168
+ compress: true,
169
+ });
170
+
171
+ assert.ok(out.map, 'expected a source map to be produced');
172
+
173
+ const vlq = out.map!.toVLQ();
174
+ // The output map must reference the ORIGINAL source file. The post-hoc
175
+ // `extends()` path leaks `<anon>` (swc's placeholder source name for the
176
+ // input it was given) into the sources array, alongside the real source
177
+ // name from the input map. With `inputSourceMap` swc knows the real
178
+ // source names up-front and emits only those.
179
+ assert.ok(
180
+ vlq.sources.includes(sourceName),
181
+ `expected output map.sources to include ${sourceName}, got ${JSON.stringify(vlq.sources)}`,
182
+ );
183
+ assert.ok(
184
+ !vlq.sources.some((s) => s === '<anon>' || s === '<source>'),
185
+ `output map.sources must not contain swc placeholder source names, got ${JSON.stringify(vlq.sources)}`,
186
+ );
187
+
188
+ // Reverse-resolve each probe and check it lands on src/widget.js at
189
+ // the right line.
190
+ for (const probe of ['alpha', 'beta', 'gamma']) {
191
+ const probeIdx = out.code.indexOf(`"${probe}"`);
192
+ assert.ok(probeIdx >= 0, `probe ${probe} should be in minified code`);
193
+ const resolved = out.map!.findClosestMapping(1, probeIdx + 1);
194
+ assert.strictEqual(
195
+ resolved?.source,
196
+ sourceName,
197
+ `probe ${probe} should resolve to ${sourceName}, got ${resolved?.source}`,
198
+ );
199
+ // Each probe lives on its own original line.
200
+ const expectedLine = ['alpha', 'beta', 'gamma'].indexOf(probe) + 1;
201
+ assert.strictEqual(
202
+ resolved?.original?.line,
203
+ expectedLine,
204
+ `probe ${probe} should resolve to line ${expectedLine}, got ${resolved?.original?.line}`,
205
+ );
206
+ }
207
+ });
208
+
209
+ it('produces a source map that correctly resolves string literals back to their original lines', async () => {
210
+ // Three source modules, each with several "log calls" on a single line.
211
+ // This mirrors real-world bundles where the packager produces source maps
212
+ // with multiple mappings per generated line: each asset's tokens get
213
+ // packed onto one line of the bundle and several mappings share that
214
+ // line.
215
+ //
216
+ // The optimizer must compose its (minified -> bundle) map with the input
217
+ // (bundle -> original sources) map per-token. The buggy implementation
218
+ // uses `SourceMap.extends(originalMap)`, which calls `findClosestMapping`
219
+ // on the original map – and that snaps to the previous mapping in the
220
+ // map. When the input map has gaps (e.g. an asset's prologue line has no
221
+ // mapping, or the next asset's first line has no leading mapping), the
222
+ // snap goes backwards across an asset boundary and the minified token
223
+ // ends up attributed to the wrong source file entirely.
224
+ type Mod = {sourceName: string; source: string; probes: string[]};
225
+
226
+ const buildMod = (sourceName: string, prefix: string): Mod => {
227
+ // 4 lines, each with 5 distinct console.log statements. Multiple
228
+ // mappings per generated line at the column of each log call.
229
+ const lines: string[] = [];
230
+ const probes: string[] = [];
231
+ for (let l = 1; l <= 4; l++) {
232
+ const calls: string[] = [];
233
+ for (let c = 1; c <= 5; c++) {
234
+ const probe = `${prefix}_l${l}c${c}`;
235
+ probes.push(probe);
236
+ calls.push(`console.log("${probe}");`);
237
+ }
238
+ lines.push(calls.join(' '));
239
+ }
240
+ return {sourceName, source: lines.join('\n'), probes};
241
+ };
242
+
243
+ const mods: Mod[] = [
244
+ buildMod('src/a.js', 'A'),
245
+ buildMod('src/b.js', 'B'),
246
+ buildMod('src/c.js', 'C'),
247
+ ];
248
+
249
+ // Build the bundle by concatenating each module's source *onto a single
250
+ // line*, separated by a `;` – the way scope-hoisting bundlers often do
251
+ // it. The mappings still reference the original module's per-line, per-
252
+ // call positions, so the input map ends up with all mappings on
253
+ // generated line 1, but the column ranges interleave across the three
254
+ // sources.
255
+ //
256
+ // This is the structure that exposes the bug: when the minifier later
257
+ // emits a mapping at some column, the buggy `extends()` snaps to the
258
+ // closest mapping by *column*, which – with sparse mappings spanning
259
+ // multiple source files on a single line – often lands on the
260
+ // *previous source file entirely*.
261
+ const indexedMappings: Array<{
262
+ generated: {line: number; column: number};
263
+ original: {line: number; column: number};
264
+ source: string;
265
+ name?: string;
266
+ }> = [];
267
+
268
+ // Concatenate each module onto separate bundle lines, separating
269
+ // assets with a blank "boundary" line that has NO mapping in the input
270
+ // map. Real packagers insert such gaps (e.g. blank lines between
271
+ // assets, asset wrappers like `var $abc$exports = ...` that aren't
272
+ // mapped back to a source).
273
+ //
274
+ // This is what reproduces the production bug: when SWC emits a mapping
275
+ // at minified column X claiming it came from the gap line, the buggy
276
+ // `extends()` approach snaps to the previous mapping on a different
277
+ // bundle line entirely, mis-attributing the token.
278
+ let lineOffset = 0;
279
+ const bundleParts: string[] = [];
280
+ for (const mod of mods) {
281
+ bundleParts.push(mod.source);
282
+ const lines = mod.source.split('\n');
283
+ for (let l = 0; l < lines.length; l++) {
284
+ const line = lines[l];
285
+ const re = /console\.log\("[^"]+"\);?/g;
286
+ let m;
287
+ while ((m = re.exec(line))) {
288
+ indexedMappings.push({
289
+ generated: {line: l + 1 + lineOffset, column: m.index},
290
+ original: {line: l + 1, column: m.index},
291
+ source: mod.sourceName,
292
+ });
293
+ }
294
+ }
295
+ // +1 for the joining blank line below
296
+ lineOffset += lines.length + 1;
297
+ }
298
+ // The blank lines between assets have NO mapping – the input map has a
299
+ // gap that the buggy extends() will snap across.
300
+ const bundleInput = bundleParts.join('\n\n');
301
+
302
+ const originalMap = new SourceMap(PROJECT_ROOT);
303
+ originalMap.addIndexedMappings(indexedMappings);
304
+ for (const mod of mods) {
305
+ originalMap.setSourceContent(mod.sourceName, mod.source);
306
+ }
307
+
308
+ const out = await minifyWithSourceMap(bundleInput, originalMap, {
309
+ projectRoot: PROJECT_ROOT,
310
+ mangle: false,
311
+ compress: true,
312
+ });
313
+
314
+ assert.ok(out.map, 'expected a source map to be produced');
315
+
316
+ // Each probe lives in a `console.log("<probe>");` statement at a known
317
+ // (source, line, column) of its original module. Since the input map
318
+ // has a mapping at the START of each `console.log` call, we expect the
319
+ // post-minify map to reverse-resolve each probe to:
320
+ // - the right source file
321
+ // - the right line within that source
322
+ // - the column where the matching `console.log` call starts
323
+ const allProbes = mods.flatMap((m) => m.probes);
324
+ const expected: Record<
325
+ string,
326
+ {source: string; line: number; column: number}
327
+ > = {};
328
+ for (const mod of mods) {
329
+ const lines = mod.source.split('\n');
330
+ for (let l = 0; l < lines.length; l++) {
331
+ const line = lines[l];
332
+ for (const probe of mod.probes) {
333
+ const idx = line.indexOf(`console.log("${probe}")`);
334
+ if (idx >= 0) {
335
+ expected[probe] = {
336
+ source: mod.sourceName,
337
+ line: l + 1,
338
+ column: idx,
339
+ };
340
+ }
341
+ }
342
+ }
343
+ }
344
+
345
+ const result = probeStringLiterals({
346
+ bundle: out.code,
347
+ map: out.map!,
348
+ expected,
349
+ probes: allProbes,
350
+ });
351
+
352
+ if (result.matched !== result.total) {
353
+ const summary = result.mismatches
354
+ .slice(0, 8)
355
+ .map(
356
+ (m) =>
357
+ ` - ${m.text} @ bundle ${m.bundleLine}:${m.bundleCol} -> ` +
358
+ `resolved=${JSON.stringify(m.resolved)} expected=${JSON.stringify(m.expected)}`,
359
+ )
360
+ .join('\n');
361
+ throw new Error(
362
+ `Source map misaligned: only ${result.matched}/${result.total} string literals ` +
363
+ `resolved to the exact original (source, line, column) where they live.\n` +
364
+ `First mismatches:\n${summary}`,
365
+ );
366
+ }
367
+
368
+ assert.strictEqual(result.matched, result.total);
369
+ });
370
+ });