@savvy-web/github-action-builder 2.2.4 → 2.2.5

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.
@@ -24,7 +24,7 @@ const rootCommand = Command.make("github-action-builder").pipe(Command.withSubco
24
24
  /**
25
25
  * CLI application: reads argv from the Stdio service provided by NodeServices.
26
26
  */
27
- const cli = Command.run(rootCommand, { version: "2.2.4" });
27
+ const cli = Command.run(rootCommand, { version: "2.2.5" });
28
28
  /**
29
29
  * Combined layer: AppLayer + the Node implementations of the CLI
30
30
  * environment (FileSystem, Path, Terminal, Stdio, ChildProcessSpawner).
@@ -20,7 +20,7 @@ const forceOption = Flag.boolean("force").pipe(Flag.withAlias("f"), Flag.withDes
20
20
  * Get current package version (replaced at build time).
21
21
  */
22
22
  const getPackageVersion = () => {
23
- return "2.2.4";
23
+ return "2.2.5";
24
24
  };
25
25
  /**
26
26
  * Generate package.json content.
@@ -20,7 +20,7 @@
20
20
  * rspack (like webpack) respects a `/* webpackIgnore: true *\/` comment
21
21
  * immediately inside the `import(` call — it skips context-module analysis
22
22
  * for that call and leaves a plain, native, runtime `import()` in the
23
- * output. This loader is a pure string transform (no AST parsing, so no
23
+ * output. This loader is a string transform (no AST parsing, so no
24
24
  * source-map chaining — the returned source is treated as a 1:1 replacement
25
25
  * and any pre-existing source map for this file is not remapped) that runs
26
26
  * in two passes:
@@ -43,11 +43,28 @@
43
43
  * backtick it sees, which is a documented limitation of this non-AST
44
44
  * heuristic.
45
45
  *
46
+ * Both passes are gated by a lexical scan of their input: any `import(`
47
+ * whose keyword sits inside a line comment, block comment, string literal,
48
+ * template-literal text, or regex literal is left untouched. Without that
49
+ * gate a doc comment that merely *mentions* `import(...)` got the block
50
+ * comment injected into it, and the injected `*\/` closed the enclosing
51
+ * comment early — spilling the comment remainder into the token stream as a
52
+ * syntax error (savvy-web/systems#412). The scan is a lexer, not a parser:
53
+ * regex-vs-division at a `/` is decided by the standard
54
+ * preceding-token heuristic (identifier/`)`/`]` → division, unless the
55
+ * identifier is a keyword like `return` or `typeof` → regex), which can
56
+ * misread pathological code such as `if (x) /re/.test(s)` — acceptable for
57
+ * the bundled-package sources this loader sees.
58
+ *
46
59
  * Deliberately skipped (left untouched):
47
60
  * - `import("./static.js")` — a string-literal import; the bundler already
48
61
  * resolves and bundles these correctly, no need to touch them.
49
62
  * - `` import(`./static.js`) `` — a fully static template literal; same
50
63
  * reasoning as a plain string (see pass 2 above).
64
+ * - `import()` — an empty argument list is never a resolvable dynamic
65
+ * import, so there is nothing to preserve (this is the prose form doc
66
+ * comments overwhelmingly use, so it is also excluded by the lookahead as
67
+ * defense in depth alongside the lexical gate).
51
68
  * - `import(/* webpackIgnore: true *\/ x)` — already has the comment
52
69
  * injected (idempotent: running this loader twice must not double-inject).
53
70
  * - `important(x)` — the `\b` word-boundary guard on `import` prevents
@@ -58,19 +75,233 @@
58
75
  * `public/` in this package's `package.json`, which is copied verbatim to
59
76
  * the package root by the build (so this ships at `<pkg>/loaders/
60
77
  * webpack-ignore-dynamic-imports.cjs`, not under a `public/` prefix).
78
+ */
79
+
80
+ /**
81
+ * Keywords after which a `/` starts a regex literal rather than division,
82
+ * even though the preceding character is an identifier character.
83
+ */
84
+ const REGEX_PRECEDING_KEYWORDS = new Set([
85
+ "await",
86
+ "case",
87
+ "delete",
88
+ "do",
89
+ "else",
90
+ "in",
91
+ "instanceof",
92
+ "new",
93
+ "of",
94
+ "return",
95
+ "throw",
96
+ "typeof",
97
+ "void",
98
+ "yield",
99
+ ]);
100
+
101
+ const WORD_CHAR = /[$\w]/;
102
+
103
+ /**
104
+ * Lexically scans JS source and returns the sorted `[start, end)` ranges of
105
+ * every region where an `import(` occurrence is NOT a dynamic import call:
106
+ * line comments, block comments, string literals, template-literal text
107
+ * (interpolation code is deliberately left unprotected — a real dynamic
108
+ * import can live inside `${ ... }`), and regex literals.
61
109
  *
62
- * @param source - The original module source text.
63
- * @returns The source text with `webpackIgnore` comments injected into
64
- * every fully-dynamic `import(` call.
110
+ * @param {string} source - The module source text.
111
+ * @returns {Array<[number, number]>} Sorted, non-overlapping ranges.
112
+ */
113
+ function computeProtectedRanges(source) {
114
+ const ranges = [];
115
+ // Brace-depth counters for template literals whose `${` interpolation we
116
+ // are currently inside; the top of the stack tracks the innermost one.
117
+ const templateStack = [];
118
+ // Last significant character and trailing identifier word seen in code
119
+ // state — the standard regex-vs-division heuristic input.
120
+ let lastCode = "";
121
+ let lastWord = "";
122
+ const len = source.length;
123
+ let i = 0;
124
+
125
+ /**
126
+ * Scans template-literal text starting at the opening backtick or at a
127
+ * `}` resuming an interrupted template. Pushes the protected text range
128
+ * and returns the index to continue from (inside interpolation code or
129
+ * after the closing backtick).
130
+ *
131
+ * @param {number} start - Index of the backtick or resuming `}`.
132
+ * @returns {number} Index to continue scanning from.
133
+ */
134
+ const scanTemplateText = (start) => {
135
+ let j = start + 1;
136
+ while (j < len) {
137
+ const c = source[j];
138
+ if (c === "\\") {
139
+ j += 2;
140
+ continue;
141
+ }
142
+ if (c === "`") {
143
+ ranges.push([start, j + 1]);
144
+ return j + 1;
145
+ }
146
+ if (c === "$" && source[j + 1] === "{") {
147
+ ranges.push([start, j + 2]);
148
+ templateStack.push(0);
149
+ return j + 2;
150
+ }
151
+ j++;
152
+ }
153
+ ranges.push([start, len]);
154
+ return len;
155
+ };
156
+
157
+ while (i < len) {
158
+ const ch = source[i];
159
+ const next = source[i + 1];
160
+ if (ch === "/" && next === "/") {
161
+ const start = i;
162
+ i += 2;
163
+ while (i < len && source[i] !== "\n") {
164
+ i++;
165
+ }
166
+ ranges.push([start, i]);
167
+ continue;
168
+ }
169
+ if (ch === "/" && next === "*") {
170
+ const start = i;
171
+ i += 2;
172
+ while (i < len && !(source[i] === "*" && source[i + 1] === "/")) {
173
+ i++;
174
+ }
175
+ i = Math.min(i + 2, len);
176
+ ranges.push([start, i]);
177
+ continue;
178
+ }
179
+ if (ch === '"' || ch === "'") {
180
+ const start = i;
181
+ i++;
182
+ while (i < len && source[i] !== ch && source[i] !== "\n") {
183
+ if (source[i] === "\\") {
184
+ i++;
185
+ }
186
+ i++;
187
+ }
188
+ i++;
189
+ ranges.push([start, Math.min(i, len)]);
190
+ lastCode = ")";
191
+ lastWord = "";
192
+ continue;
193
+ }
194
+ if (ch === "`") {
195
+ i = scanTemplateText(i);
196
+ lastCode = ")";
197
+ lastWord = "";
198
+ continue;
199
+ }
200
+ if (ch === "/") {
201
+ // Regex literal vs division: regex unless the previous significant
202
+ // token can end an expression.
203
+ const afterWord = WORD_CHAR.test(lastCode);
204
+ const isDivision =
205
+ lastCode !== "" &&
206
+ ((afterWord && !REGEX_PRECEDING_KEYWORDS.has(lastWord)) || lastCode === ")" || lastCode === "]");
207
+ if (isDivision) {
208
+ lastCode = ch;
209
+ lastWord = "";
210
+ i++;
211
+ continue;
212
+ }
213
+ const start = i;
214
+ i++;
215
+ let inClass = false;
216
+ while (i < len) {
217
+ const c = source[i];
218
+ if (c === "\\") {
219
+ i += 2;
220
+ continue;
221
+ }
222
+ if (c === "\n") {
223
+ break;
224
+ }
225
+ if (c === "[") {
226
+ inClass = true;
227
+ } else if (c === "]") {
228
+ inClass = false;
229
+ } else if (c === "/" && !inClass) {
230
+ i++;
231
+ break;
232
+ }
233
+ i++;
234
+ }
235
+ ranges.push([start, Math.min(i, len)]);
236
+ lastCode = ")";
237
+ lastWord = "";
238
+ continue;
239
+ }
240
+ if (templateStack.length > 0) {
241
+ if (ch === "{") {
242
+ templateStack[templateStack.length - 1]++;
243
+ } else if (ch === "}") {
244
+ if (templateStack[templateStack.length - 1] === 0) {
245
+ templateStack.pop();
246
+ i = scanTemplateText(i);
247
+ lastCode = ")";
248
+ lastWord = "";
249
+ continue;
250
+ }
251
+ templateStack[templateStack.length - 1]--;
252
+ }
253
+ }
254
+ if (!/\s/.test(ch)) {
255
+ lastWord = WORD_CHAR.test(ch) ? (WORD_CHAR.test(lastCode) ? lastWord + ch : ch) : "";
256
+ lastCode = ch;
257
+ }
258
+ i++;
259
+ }
260
+ return ranges;
261
+ }
262
+
263
+ /**
264
+ * Whether `offset` falls inside any of the sorted protected `ranges`.
265
+ *
266
+ * @param {Array<[number, number]>} ranges - Output of {@link computeProtectedRanges}.
267
+ * @param {number} offset - Candidate match offset.
268
+ * @returns {boolean} True when the offset is protected.
269
+ */
270
+ function isProtected(ranges, offset) {
271
+ for (const [start, end] of ranges) {
272
+ if (offset < start) {
273
+ return false;
274
+ }
275
+ if (offset < end) {
276
+ return true;
277
+ }
278
+ }
279
+ return false;
280
+ }
281
+
282
+ /**
283
+ * The rspack loader entry point — see the file overview above for the full
284
+ * transform contract.
285
+ *
286
+ * @param {string} source - The original module source text.
287
+ * @returns {string} The source text with `webpackIgnore` comments injected
288
+ * into every fully-dynamic `import(` call.
65
289
  */
66
290
  module.exports = function webpackIgnoreDynamicImportsLoader(source) {
67
291
  // Pass 1: string-literal and backtick arguments are excluded from this
68
292
  // pass (the former never needs the comment; the latter is handled by
69
- // pass 2 below). An optional single leading `/* ... */` magic comment is
70
- // captured so it can be inspected: if it already contains
293
+ // pass 2 below), as is an empty argument list `import()` is prose, not
294
+ // a resolvable import. An optional single leading `/* ... */` magic
295
+ // comment is captured so it can be inspected: if it already contains
71
296
  // `webpackIgnore`, the call is left untouched (idempotent); otherwise the
72
- // new comment is prepended ahead of it.
73
- let result = source.replace(/\bimport\s*\(\s*(\/\*[\s\S]*?\*\/\s*)?(?!["'`])/g, (match, existingComment) => {
297
+ // new comment is prepended ahead of it. Matches whose `import` keyword
298
+ // sits in a protected lexical region (comment/string/template-text/regex)
299
+ // are skipped.
300
+ let ranges = computeProtectedRanges(source);
301
+ let result = source.replace(/\bimport\s*\(\s*(\/\*[\s\S]*?\*\/\s*)?(?!["'`)])/g, (match, existingComment, offset) => {
302
+ if (isProtected(ranges, offset)) {
303
+ return match;
304
+ }
74
305
  if (existingComment && /webpackIgnore/.test(existingComment)) {
75
306
  return match;
76
307
  }
@@ -79,8 +310,12 @@ module.exports = function webpackIgnoreDynamicImportsLoader(source) {
79
310
 
80
311
  // Pass 2: backtick template-literal arguments. Only interpolated
81
312
  // template literals (containing `${`) need the comment; a fully static
82
- // one is left untouched.
313
+ // one is left untouched. Re-scan the pass-1 output — offsets shifted.
314
+ ranges = computeProtectedRanges(result);
83
315
  result = result.replace(/\bimport\s*\(\s*`/g, (match, offset, full) => {
316
+ if (isProtected(ranges, offset)) {
317
+ return match;
318
+ }
84
319
  const backtickIndex = offset + match.length - 1;
85
320
  let end = backtickIndex + 1;
86
321
  while (end < full.length && full[end] !== "`") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/github-action-builder",
3
- "version": "2.2.4",
3
+ "version": "2.2.5",
4
4
  "private": false,
5
5
  "description": "A zero-config build tool for creating GitHub Actions from TypeScript. Bundles with rsbuild, validates action.yml against GitHub's schema, and outputs production-ready Node.js 24 actions.",
6
6
  "keywords": [