@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.
- package/CHANGELOG.md +9 -0
- package/dist/SwcOptimizer.js +13 -29
- package/dist/minifyWithSourceMap.js +71 -0
- package/lib/SwcOptimizer.js +13 -47
- package/lib/minifyWithSourceMap.js +108 -0
- package/lib/types/minifyWithSourceMap.d.ts +40 -0
- package/package.json +6 -6
- package/src/SwcOptimizer.ts +18 -29
- package/src/minifyWithSourceMap.ts +104 -0
- package/test/minifyWithSourceMap.test.ts +370 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
});
|