@atlaspack/optimizer-swc 2.16.9 → 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/LICENSE +201 -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 +7 -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
package/src/SwcOptimizer.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import nullthrows from 'nullthrows';
|
|
2
|
-
import {transform} from '@swc/core';
|
|
3
1
|
import {Optimizer} from '@atlaspack/plugin';
|
|
4
2
|
import {blobToString, stripAnsi} from '@atlaspack/utils';
|
|
5
|
-
import SourceMap from '@atlaspack/source-map';
|
|
6
3
|
import ThrowableDiagnostic, {escapeMarkdown} from '@atlaspack/diagnostic';
|
|
7
4
|
import path from 'path';
|
|
8
5
|
|
|
6
|
+
import {minifyWithSourceMap} from './minifyWithSourceMap';
|
|
7
|
+
|
|
9
8
|
export default new Optimizer({
|
|
10
9
|
async loadConfig({config, options}) {
|
|
11
10
|
let userConfig = await config.getConfigFrom(
|
|
@@ -30,25 +29,21 @@ export default new Optimizer({
|
|
|
30
29
|
let code = await blobToString(contents);
|
|
31
30
|
let result;
|
|
32
31
|
try {
|
|
33
|
-
result = await
|
|
34
|
-
|
|
32
|
+
result = await minifyWithSourceMap(
|
|
33
|
+
code,
|
|
34
|
+
bundle.env.sourceMap ? (originalMap ?? null) : null,
|
|
35
|
+
{
|
|
35
36
|
target: 'es2022',
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
module: bundle.env.outputFormat === 'esmodule',
|
|
45
|
-
},
|
|
37
|
+
mangle: true,
|
|
38
|
+
compress: true,
|
|
39
|
+
toplevel:
|
|
40
|
+
bundle.env.outputFormat === 'esmodule' ||
|
|
41
|
+
bundle.env.outputFormat === 'commonjs',
|
|
42
|
+
module: bundle.env.outputFormat === 'esmodule',
|
|
43
|
+
userConfig: userConfig as Record<string, unknown> | undefined,
|
|
44
|
+
projectRoot: options.projectRoot,
|
|
46
45
|
},
|
|
47
|
-
|
|
48
|
-
sourceMaps: !!bundle.env.sourceMap,
|
|
49
|
-
configFile: false,
|
|
50
|
-
swcrc: false,
|
|
51
|
-
});
|
|
46
|
+
);
|
|
52
47
|
} catch (err: any) {
|
|
53
48
|
// SWC doesn't give us nice error objects, so we need to parse the message.
|
|
54
49
|
let message = escapeMarkdown(
|
|
@@ -107,15 +102,9 @@ export default new Optimizer({
|
|
|
107
102
|
throw err;
|
|
108
103
|
}
|
|
109
104
|
|
|
110
|
-
let
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (resultMap) {
|
|
114
|
-
sourceMap = new SourceMap(options.projectRoot);
|
|
115
|
-
sourceMap.addVLQMap(JSON.parse(resultMap));
|
|
116
|
-
if (originalMap) {
|
|
117
|
-
sourceMap.extends(originalMap);
|
|
118
|
-
}
|
|
105
|
+
let minifiedContents: string = result.code;
|
|
106
|
+
const sourceMap = result.map;
|
|
107
|
+
if (sourceMap) {
|
|
119
108
|
let sourcemapReference = await getSourceMapReference(sourceMap);
|
|
120
109
|
if (sourcemapReference) {
|
|
121
110
|
minifiedContents += `\n//# sourceMappingURL=${sourcemapReference}\n`;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import nullthrows from 'nullthrows';
|
|
2
|
+
import {transform} from '@swc/core';
|
|
3
|
+
import SourceMap from '@atlaspack/source-map';
|
|
4
|
+
|
|
5
|
+
export type MinifyOptions = {
|
|
6
|
+
/** ES target for SWC. */
|
|
7
|
+
target?: string;
|
|
8
|
+
/** Whether to enable name mangling. */
|
|
9
|
+
mangle?: boolean;
|
|
10
|
+
/** Whether to enable compression. */
|
|
11
|
+
compress?: boolean;
|
|
12
|
+
/** Mark output as a top-level module (esmodule/commonjs). */
|
|
13
|
+
toplevel?: boolean;
|
|
14
|
+
/** Treat the input as an ES module. */
|
|
15
|
+
module?: boolean;
|
|
16
|
+
/** Extra terser-style overrides forwarded into `jsc.minify`. */
|
|
17
|
+
userConfig?: Record<string, unknown>;
|
|
18
|
+
/** Project root used when constructing the SourceMap instance. */
|
|
19
|
+
projectRoot?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type MinifyResult = {
|
|
23
|
+
code: string;
|
|
24
|
+
map: SourceMap | null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Minify `code` with SWC and produce a SourceMap whose `original*` positions
|
|
29
|
+
* point back to the sources described by `originalMap`.
|
|
30
|
+
*
|
|
31
|
+
* When `originalMap` is provided, we hand its VLQ-encoded form to SWC via the
|
|
32
|
+
* `inputSourceMap` option. SWC then composes its own (minified -> code) map
|
|
33
|
+
* with the input (code -> original sources) map per-token during
|
|
34
|
+
* minification, producing a final map that points straight back to the
|
|
35
|
+
* original sources.
|
|
36
|
+
*
|
|
37
|
+
* This is much more accurate than composing the maps *after* the fact with
|
|
38
|
+
* `SourceMap.extends(originalMap)`. The post-hoc `extends` approach calls
|
|
39
|
+
* `findClosestMapping`, which snaps each minified token to the previous
|
|
40
|
+
* mapping in the input map. When the input map has many mappings on a single
|
|
41
|
+
* generated line (which is the norm – every asset's tokens get packed onto
|
|
42
|
+
* the same generated line by the packager), "the previous mapping" is often
|
|
43
|
+
* the wrong source/line entirely, producing systematically misaligned maps.
|
|
44
|
+
*/
|
|
45
|
+
export async function minifyWithSourceMap(
|
|
46
|
+
code: string,
|
|
47
|
+
originalMap: SourceMap | null,
|
|
48
|
+
options: MinifyOptions = {},
|
|
49
|
+
): Promise<MinifyResult> {
|
|
50
|
+
const {
|
|
51
|
+
target = 'es2022',
|
|
52
|
+
mangle = true,
|
|
53
|
+
compress = true,
|
|
54
|
+
toplevel = false,
|
|
55
|
+
module: isModule = false,
|
|
56
|
+
userConfig = {},
|
|
57
|
+
projectRoot = '/',
|
|
58
|
+
} = options;
|
|
59
|
+
|
|
60
|
+
let inputSourceMap: string | undefined;
|
|
61
|
+
if (originalMap) {
|
|
62
|
+
// Hand the input map to swc as `inputSourceMap` so swc composes the
|
|
63
|
+
// (minified -> code) and (code -> original sources) maps together
|
|
64
|
+
// per-token during minification. This is more accurate than calling
|
|
65
|
+
// `SourceMap.extends(originalMap)` after the fact, which uses
|
|
66
|
+
// `findClosestMapping` and snaps each minified token to the *nearest*
|
|
67
|
+
// mapping in the input map – losing column precision and sometimes
|
|
68
|
+
// mis-attributing tokens across asset boundaries when the input map
|
|
69
|
+
// has gaps. It also leaks swc's placeholder `<anon>` source into the
|
|
70
|
+
// resulting map's `sources` array.
|
|
71
|
+
// swc requires `version: 3` to parse the input map. The atlaspack
|
|
72
|
+
// SourceMap.toVLQ() helper doesn't include it, so we add it here.
|
|
73
|
+
inputSourceMap = JSON.stringify({version: 3, ...originalMap.toVLQ()});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const result = await transform(code, {
|
|
77
|
+
jsc: {
|
|
78
|
+
target: target as any,
|
|
79
|
+
minify: {
|
|
80
|
+
mangle,
|
|
81
|
+
compress,
|
|
82
|
+
...(userConfig as object),
|
|
83
|
+
toplevel,
|
|
84
|
+
module: isModule,
|
|
85
|
+
} as any,
|
|
86
|
+
},
|
|
87
|
+
minify: true,
|
|
88
|
+
sourceMaps: true,
|
|
89
|
+
inputSourceMap,
|
|
90
|
+
configFile: false,
|
|
91
|
+
swcrc: false,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const minifiedCode: string = nullthrows(result.code);
|
|
95
|
+
if (!result.map) {
|
|
96
|
+
return {code: minifiedCode, map: null};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// With `inputSourceMap` provided, swc has already composed the maps;
|
|
100
|
+
// the result already references the original sources directly.
|
|
101
|
+
const sourceMap = new SourceMap(projectRoot);
|
|
102
|
+
sourceMap.addVLQMap(JSON.parse(result.map));
|
|
103
|
+
return {code: minifiedCode, map: sourceMap};
|
|
104
|
+
}
|
|
@@ -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
|
+
});
|