@mh-alikhani/bunready 0.3.0 → 0.3.2
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 +31 -1
- package/package.json +2 -1
- package/src/scanner/semver.ts +6 -6
- package/src/scanner/sources.ts +40 -11
- package/src/scanner/workspaces.ts +27 -5
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
Nothing yet.
|
|
11
11
|
|
|
12
|
+
## [0.3.2] - 2026-09-20
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Source files are read with bounded concurrency instead of one at a time. A scan
|
|
17
|
+
of 800 files went from a 200.9ms median to 86.2ms on Windows (un run bench),
|
|
18
|
+
with identical output.
|
|
19
|
+
|
|
20
|
+
### Notes
|
|
21
|
+
|
|
22
|
+
- Measured and rejected: --bytecode cannot compile the entry point because it
|
|
23
|
+
uses top-level wait, --minify produces a byte-identical 82.2MB binary (the
|
|
24
|
+
size is the Bun runtime), and deferring the scanner behind dynamic imports moved
|
|
25
|
+
the cost into the scan path without a repeatable win.
|
|
26
|
+
|
|
27
|
+
## [0.3.1] - 2026-09-19
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Three polynomial-time regular expressions, reported by CodeQL as
|
|
32
|
+
`js/polynomial-redos`. bunready parses files it does not control - a
|
|
33
|
+
`pnpm-workspace.yaml` from a repository being scanned, a semver range in its
|
|
34
|
+
`engines` - so a pathological string could have made a scan take quadratic
|
|
35
|
+
time. The pnpm list item and the semver operator split are now parsed
|
|
36
|
+
directly, and trailing-slash stripping is a linear loop instead of `/+$`.
|
|
37
|
+
Behaviour is unchanged; `tests/workspaces.test.ts` and `tests/semver.test.ts`
|
|
38
|
+
cover the affected paths.
|
|
39
|
+
|
|
12
40
|
## [0.3.0] - 2026-09-18
|
|
13
41
|
|
|
14
42
|
### Added
|
|
@@ -188,7 +216,9 @@ Nothing yet.
|
|
|
188
216
|
and that claim needs a primary source. Until then the rule reports what the
|
|
189
217
|
repository imports and cites the compatibility table.
|
|
190
218
|
|
|
191
|
-
[Unreleased]: https://github.com/MHAlikhani/bunready/compare/v0.3.
|
|
219
|
+
[Unreleased]: https://github.com/MHAlikhani/bunready/compare/v0.3.2...HEAD
|
|
220
|
+
[0.3.2]: https://github.com/MHAlikhani/bunready/releases/tag/v0.3.2
|
|
221
|
+
[0.3.1]: https://github.com/MHAlikhani/bunready/releases/tag/v0.3.1
|
|
192
222
|
[0.3.0]: https://github.com/MHAlikhani/bunready/releases/tag/v0.3.0
|
|
193
223
|
[0.2.0]: https://github.com/MHAlikhani/bunready/releases/tag/v0.2.0
|
|
194
224
|
[0.1.0]: https://github.com/MHAlikhani/bunready/releases/tag/v0.1.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mh-alikhani/bunready",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Bun-readiness scanner: one command that shows what will break before you move a Node/TS repo to Bun.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"check": "bun run typecheck && bun run lint && bun run test",
|
|
65
65
|
"og": "bun run scripts/generate-og-image.ts",
|
|
66
66
|
"smoke": "bun run scripts/smoke-real.ts",
|
|
67
|
+
"bench": "bun run scripts/bench.ts",
|
|
67
68
|
"prepare": "simple-git-hooks"
|
|
68
69
|
},
|
|
69
70
|
"devDependencies": {
|
package/src/scanner/semver.ts
CHANGED
|
@@ -92,12 +92,12 @@ function comparatorsForToken(token: string): Comparator[] | undefined {
|
|
|
92
92
|
return [];
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const operator = operatorMatch[1];
|
|
100
|
-
const rest = operatorMatch[
|
|
95
|
+
// Anchored-prefix parse rather than a trailing \s*(.*)$, which backtracks
|
|
96
|
+
// quadratically on a long run of spaces - and the range comes from a
|
|
97
|
+
// repository bunready does not control.
|
|
98
|
+
const operatorMatch = /^(>=|<=|>|<|=|\^|~)?/.exec(trimmed);
|
|
99
|
+
const operator = operatorMatch?.[1];
|
|
100
|
+
const rest = trimmed.slice(operatorMatch?.[0].length ?? 0).trim();
|
|
101
101
|
if (rest === "" || rest === "*" || rest === "x") {
|
|
102
102
|
return [];
|
|
103
103
|
}
|
package/src/scanner/sources.ts
CHANGED
|
@@ -301,8 +301,41 @@ export interface ScanSourcesOptions {
|
|
|
301
301
|
}
|
|
302
302
|
|
|
303
303
|
/**
|
|
304
|
-
*
|
|
305
|
-
*
|
|
304
|
+
* Bounded-concurrency reads.
|
|
305
|
+
*
|
|
306
|
+
* Files are independent, so the ordered part (the walk) and the slow part (the
|
|
307
|
+
* reads) are separated: the walk decides the order, the pool does the waiting.
|
|
308
|
+
* Results keep their walk order because each file lands in its own slot.
|
|
309
|
+
*/
|
|
310
|
+
const MAX_READ_CONCURRENCY = 16;
|
|
311
|
+
|
|
312
|
+
async function readSourceFiles(paths: readonly string[], fs: FileSystem): Promise<SourceFile[]> {
|
|
313
|
+
const slots = new Array<SourceFile | undefined>(paths.length);
|
|
314
|
+
let cursor = 0;
|
|
315
|
+
|
|
316
|
+
const worker = async (): Promise<void> => {
|
|
317
|
+
while (cursor < paths.length) {
|
|
318
|
+
const index = cursor;
|
|
319
|
+
cursor += 1;
|
|
320
|
+
const path = paths[index];
|
|
321
|
+
if (path === undefined) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const outcome = await fs.readTextFile(path);
|
|
325
|
+
if (outcome.kind === "text") {
|
|
326
|
+
slots[index] = { path, imports: extractImports(outcome.text) };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
await Promise.all(Array.from({ length: Math.min(MAX_READ_CONCURRENCY, paths.length) }, worker));
|
|
332
|
+
return slots.filter((file): file is SourceFile => file !== undefined);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Walk the target's own source, breadth first and sorted, then read it. Reading
|
|
337
|
+
* a repository of hundreds of files one at a time was the slowest thing a scan
|
|
338
|
+
* did, and the files have no dependency on each other.
|
|
306
339
|
*/
|
|
307
340
|
export async function scanSources(
|
|
308
341
|
dir: string,
|
|
@@ -313,7 +346,7 @@ export async function scanSources(
|
|
|
313
346
|
const excludePaths = options.excludePaths ?? [];
|
|
314
347
|
const ignored = new Set<string>(IGNORED_DIRECTORIES);
|
|
315
348
|
const queue: string[] = [dir];
|
|
316
|
-
const
|
|
349
|
+
const candidates: string[] = [];
|
|
317
350
|
let truncated = false;
|
|
318
351
|
|
|
319
352
|
while (queue.length > 0) {
|
|
@@ -322,8 +355,7 @@ export async function scanSources(
|
|
|
322
355
|
break;
|
|
323
356
|
}
|
|
324
357
|
|
|
325
|
-
const
|
|
326
|
-
for (const entry of entries) {
|
|
358
|
+
for (const entry of await fs.listDirectory(current)) {
|
|
327
359
|
if (entry.isDirectory) {
|
|
328
360
|
if (!ignored.has(entry.name)) {
|
|
329
361
|
queue.push(join(current, entry.name));
|
|
@@ -333,7 +365,7 @@ export async function scanSources(
|
|
|
333
365
|
if (!hasSourceExtension(entry.name)) {
|
|
334
366
|
continue;
|
|
335
367
|
}
|
|
336
|
-
if (
|
|
368
|
+
if (candidates.length >= maxFiles) {
|
|
337
369
|
truncated = true;
|
|
338
370
|
continue;
|
|
339
371
|
}
|
|
@@ -341,11 +373,7 @@ export async function scanSources(
|
|
|
341
373
|
if (excludePaths.some((fragment) => path.includes(fragment))) {
|
|
342
374
|
continue;
|
|
343
375
|
}
|
|
344
|
-
|
|
345
|
-
if (outcome.kind !== "text") {
|
|
346
|
-
continue;
|
|
347
|
-
}
|
|
348
|
-
files.push({ path: path.replace(/\\/g, "/"), imports: extractImports(outcome.text) });
|
|
376
|
+
candidates.push(path);
|
|
349
377
|
}
|
|
350
378
|
|
|
351
379
|
if (truncated) {
|
|
@@ -353,5 +381,6 @@ export async function scanSources(
|
|
|
353
381
|
}
|
|
354
382
|
}
|
|
355
383
|
|
|
384
|
+
const files = await readSourceFiles(candidates, fs);
|
|
356
385
|
return { files, filesScanned: files.length, truncated };
|
|
357
386
|
}
|
|
@@ -54,9 +54,22 @@ export function patternsFromPnpmWorkspace(text: string): string[] {
|
|
|
54
54
|
if (!inPackages) {
|
|
55
55
|
continue;
|
|
56
56
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
// Walked rather than matched: a pattern mixing a dash run, a space run and a
|
|
58
|
+
// catch-all is ambiguous, and CodeQL is right that it backtracks on a string
|
|
59
|
+
// of dashes and spaces. The value is trimmed here instead.
|
|
60
|
+
let cursor = 0;
|
|
61
|
+
while (cursor < trimmed.length && trimmed[cursor] === "-") {
|
|
62
|
+
cursor += 1;
|
|
63
|
+
}
|
|
64
|
+
const value =
|
|
65
|
+
cursor === 0
|
|
66
|
+
? ""
|
|
67
|
+
: trimmed
|
|
68
|
+
.slice(cursor)
|
|
69
|
+
.trim()
|
|
70
|
+
.replace(/^['"]|['"]$/g, "");
|
|
71
|
+
if (value !== "") {
|
|
72
|
+
patterns.push(value);
|
|
60
73
|
}
|
|
61
74
|
}
|
|
62
75
|
|
|
@@ -123,6 +136,15 @@ async function expandGlob(root: string, segments: string[], fs: FileSystem): Pro
|
|
|
123
136
|
return prefixes.filter((prefix) => prefix !== "" && prefix !== ".");
|
|
124
137
|
}
|
|
125
138
|
|
|
139
|
+
/** Linear replacement for `/+$`, which backtracks on a run of slashes. */
|
|
140
|
+
function stripTrailingSlashes(value: string): string {
|
|
141
|
+
let end = value.length;
|
|
142
|
+
while (end > 0 && value[end - 1] === "/") {
|
|
143
|
+
end -= 1;
|
|
144
|
+
}
|
|
145
|
+
return value.slice(0, end);
|
|
146
|
+
}
|
|
147
|
+
|
|
126
148
|
export async function findWorkspacePackages(
|
|
127
149
|
root: string,
|
|
128
150
|
patterns: readonly string[],
|
|
@@ -131,7 +153,7 @@ export async function findWorkspacePackages(
|
|
|
131
153
|
const found = new Map<string, WorkspacePackage>();
|
|
132
154
|
|
|
133
155
|
for (const pattern of patterns) {
|
|
134
|
-
const cleaned = pattern.replace(/\\/g, "/")
|
|
156
|
+
const cleaned = stripTrailingSlashes(pattern.replace(/\\/g, "/")).replace(/^\.\//, "");
|
|
135
157
|
if (cleaned === "" || cleaned === ".") {
|
|
136
158
|
continue;
|
|
137
159
|
}
|
|
@@ -165,6 +187,6 @@ export async function readPnpmWorkspace(root: string, fs: FileSystem): Promise<s
|
|
|
165
187
|
|
|
166
188
|
/** Keep only the packages matching a scope string, by relative path or name. */
|
|
167
189
|
export function scopeMatches(relative: string, scope: string): boolean {
|
|
168
|
-
const needle = scope.replace(/\\/g, "/").replace(/^\.\//, "")
|
|
190
|
+
const needle = stripTrailingSlashes(scope.replace(/\\/g, "/")).replace(/^\.\//, "");
|
|
169
191
|
return relative === needle || relative.includes(needle);
|
|
170
192
|
}
|