@untangle-code/scanner 0.0.0-stage → 0.1.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.
Files changed (75) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +90 -0
  3. package/README.md +303 -2
  4. package/THIRD_PARTY_LICENSES.md +24 -0
  5. package/dist/binary-classifier.d.ts +45 -0
  6. package/dist/binary-classifier.d.ts.map +1 -0
  7. package/dist/binary-classifier.js +296 -0
  8. package/dist/binary-classifier.js.map +1 -0
  9. package/dist/concurrency.d.ts +8 -0
  10. package/dist/concurrency.d.ts.map +1 -0
  11. package/dist/concurrency.js +64 -0
  12. package/dist/concurrency.js.map +1 -0
  13. package/dist/discovery.d.ts +9 -0
  14. package/dist/discovery.d.ts.map +1 -0
  15. package/dist/discovery.js +254 -0
  16. package/dist/discovery.js.map +1 -0
  17. package/dist/gitignore-pattern.d.ts +35 -0
  18. package/dist/gitignore-pattern.d.ts.map +1 -0
  19. package/dist/gitignore-pattern.js +155 -0
  20. package/dist/gitignore-pattern.js.map +1 -0
  21. package/dist/hashing.d.ts +14 -0
  22. package/dist/hashing.d.ts.map +1 -0
  23. package/dist/hashing.js +32 -0
  24. package/dist/hashing.js.map +1 -0
  25. package/dist/ignore-engine.d.ts +27 -0
  26. package/dist/ignore-engine.d.ts.map +1 -0
  27. package/dist/ignore-engine.js +130 -0
  28. package/dist/ignore-engine.js.map +1 -0
  29. package/dist/index.d.ts +21 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +28 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/language-classifier.d.ts +19 -0
  34. package/dist/language-classifier.d.ts.map +1 -0
  35. package/dist/language-classifier.js +114 -0
  36. package/dist/language-classifier.js.map +1 -0
  37. package/dist/node-fs/index.d.ts +10 -0
  38. package/dist/node-fs/index.d.ts.map +1 -0
  39. package/dist/node-fs/index.js +10 -0
  40. package/dist/node-fs/index.js.map +1 -0
  41. package/dist/node-fs/node-file-system.d.ts +19 -0
  42. package/dist/node-fs/node-file-system.d.ts.map +1 -0
  43. package/dist/node-fs/node-file-system.js +104 -0
  44. package/dist/node-fs/node-file-system.js.map +1 -0
  45. package/dist/node-fs/node-streaming-hasher.d.ts +4 -0
  46. package/dist/node-fs/node-streaming-hasher.d.ts.map +1 -0
  47. package/dist/node-fs/node-streaming-hasher.js +33 -0
  48. package/dist/node-fs/node-streaming-hasher.js.map +1 -0
  49. package/dist/port-options.d.ts +8 -0
  50. package/dist/port-options.d.ts.map +1 -0
  51. package/dist/port-options.js +4 -0
  52. package/dist/port-options.js.map +1 -0
  53. package/dist/semaphore.d.ts +18 -0
  54. package/dist/semaphore.d.ts.map +1 -0
  55. package/dist/semaphore.js +42 -0
  56. package/dist/semaphore.js.map +1 -0
  57. package/dist/types.d.ts +102 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/dist/types.js +2 -0
  60. package/dist/types.js.map +1 -0
  61. package/package.json +63 -3
  62. package/src/binary-classifier.ts +365 -0
  63. package/src/concurrency.ts +78 -0
  64. package/src/discovery.ts +362 -0
  65. package/src/gitignore-pattern.ts +170 -0
  66. package/src/hashing.ts +37 -0
  67. package/src/ignore-engine.ts +157 -0
  68. package/src/index.ts +48 -0
  69. package/src/language-classifier.ts +137 -0
  70. package/src/node-fs/index.ts +9 -0
  71. package/src/node-fs/node-file-system.ts +137 -0
  72. package/src/node-fs/node-streaming-hasher.ts +35 -0
  73. package/src/port-options.ts +10 -0
  74. package/src/semaphore.ts +45 -0
  75. package/src/types.ts +121 -0
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Binary / encoding / minified-file classification from a content sample
3
+ * (spec.md §6.1: "binäre Dateien erkennen"; WP-05 order: "Nullbytes, Anteil
4
+ * nicht druckbarer Bytes, BOM-/Encoding-Erkennung" + minified heuristics).
5
+ *
6
+ * Pure functions over bytes already in memory — no I/O.
7
+ */
8
+ import { DEFAULT_MINIFIED_RULE, isMinifiedShape } from "@untangle-code/domain";
9
+ import type { TextFormatKind } from "@untangle-code/domain";
10
+
11
+ import type { BinaryClassification, TextEncodingGuess } from "./types.js";
12
+
13
+ interface BomSpec {
14
+ bytes: readonly number[];
15
+ encoding: TextEncodingGuess;
16
+ }
17
+
18
+ // Longest match first (utf16le/be are 2-byte prefixes of nothing else here,
19
+ // but ordering is kept explicit and defensive).
20
+ //
21
+ // NOTE (checked per WP defect audit, not fixed — out of spec.md/architecture.md
22
+ // scope): UTF-32 is not a recognized `TextEncodingGuess` and has no BOM_SPECS
23
+ // entry. A UTF-32LE-BOM'd sample (FF FE 00 00 …) therefore still matches the
24
+ // 2-byte utf16le prefix above and is (mis-)tagged "utf16le" with a 2-byte BOM
25
+ // length. `classifyUtf16Body` below decodes the remaining bytes as UTF-16LE
26
+ // in that case, which — because every UTF-32LE code unit contributes two
27
+ // extra 0x00 bytes — reliably decodes to text containing genuine embedded
28
+ // NUL characters, so the sample still ends up classified `isBinary: true`.
29
+ // That is the pre-existing, undocumented-until-now behavior for UTF-32
30
+ // input: not a regression introduced by this fix, but also not a real fix —
31
+ // neither spec.md nor architecture.md specify UTF-32 support, so adding a
32
+ // dedicated "utf32le"/"utf32be" encoding is a separate, out-of-scope
33
+ // decision rather than something to guess at here.
34
+ const BOM_SPECS: readonly BomSpec[] = [
35
+ { bytes: [0xef, 0xbb, 0xbf], encoding: "utf8" },
36
+ { bytes: [0xff, 0xfe], encoding: "utf16le" },
37
+ { bytes: [0xfe, 0xff], encoding: "utf16be" },
38
+ ];
39
+
40
+ function detectBom(sample: Uint8Array): {
41
+ hasBom: boolean;
42
+ encoding: TextEncodingGuess;
43
+ length: number;
44
+ } {
45
+ for (const spec of BOM_SPECS) {
46
+ if (sample.length < spec.bytes.length) {
47
+ continue;
48
+ }
49
+ let matches = true;
50
+ for (let i = 0; i < spec.bytes.length; i += 1) {
51
+ if (sample[i] !== spec.bytes[i]) {
52
+ matches = false;
53
+ break;
54
+ }
55
+ }
56
+ if (matches) {
57
+ return { hasBom: true, encoding: spec.encoding, length: spec.bytes.length };
58
+ }
59
+ }
60
+ return { hasBom: false, encoding: "unknown", length: 0 };
61
+ }
62
+
63
+ /** Control bytes that indicate non-text content; tab/LF/CR are excluded (they're common in text). */
64
+ function isNonPrintableControlByte(byte: number): boolean {
65
+ return (
66
+ byte <= 0x08 ||
67
+ byte === 0x0b ||
68
+ byte === 0x0c ||
69
+ (byte >= 0x0e && byte <= 0x1f) ||
70
+ byte === 0x7f
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Classifies the body of a sample confirmed (by BOM or by the BOM-less
76
+ * heuristic in `detectUtf16WithoutBom`) to be UTF-16 (LE or BE).
77
+ *
78
+ * The raw "a NUL byte means binary" rule from `classifyBinarySample` cannot
79
+ * be applied to UTF-16 bytes directly: every Basic-Latin code unit (i.e.
80
+ * every plain ASCII character) carries a 0x00 byte as one half of its
81
+ * 16-bit unit, so a legitimate UTF-16 text file is close to 50% NUL bytes
82
+ * by construction. Decoding first and inspecting actual *characters*
83
+ * avoids that false positive while still catching real NUL characters and
84
+ * a high ratio of real control characters.
85
+ */
86
+ function classifyUtf16Body(
87
+ body: Uint8Array,
88
+ encoding: "utf16le" | "utf16be",
89
+ threshold: number,
90
+ hasBom: boolean,
91
+ ): BinaryClassification {
92
+ // Non-fatal decode: a byte sequence that only coincidentally starts with a
93
+ // UTF-16 BOM (rare, but the classifier must stay robust for arbitrary
94
+ // binary input) must not throw here — invalid sequences become U+FFFD
95
+ // instead, which is handled like any other non-control character below.
96
+ const text = new TextDecoder(encoding === "utf16le" ? "utf-16le" : "utf-16be", {
97
+ fatal: false,
98
+ }).decode(body);
99
+
100
+ let hasNullByte = false;
101
+ let nonPrintable = 0;
102
+ let unitCount = 0;
103
+ for (const char of text) {
104
+ unitCount += 1;
105
+ const codePoint = char.codePointAt(0) ?? 0;
106
+ if (codePoint === 0) {
107
+ hasNullByte = true;
108
+ continue;
109
+ }
110
+ if (codePoint < 0x100 && isNonPrintableControlByte(codePoint)) {
111
+ nonPrintable += 1;
112
+ }
113
+ }
114
+
115
+ const nonPrintableRatio = unitCount === 0 ? 0 : nonPrintable / unitCount;
116
+ const isBinary = hasNullByte || nonPrintableRatio > threshold;
117
+
118
+ return { isBinary, hasNullByte, nonPrintableRatio, encoding, hasBom };
119
+ }
120
+
121
+ // --- BOM-less UTF-16 detection (defect fix, spec.md E4) --------------------
122
+ //
123
+ // A UTF-16 file with no BOM is legal (spec.md/architecture.md do not require
124
+ // one, and plenty of real tooling emits BOM-less UTF-16). Without a BOM,
125
+ // `detectBom` reports `hasBom:false` and — before this fix — the sample fell
126
+ // straight into the raw byte-level loop below, which sees every other byte
127
+ // as 0x00 (the high or low half of each Basic-Latin code unit) and
128
+ // classifies the file as binary. That is a false NEGATIVE (real source text
129
+ // silently vanishes from the index), which is worse than the false-POSITIVE
130
+ // risk of a speculative heuristic, so this is a deliberate, narrow, and
131
+ // documented exception to "don't guess encodings beyond spec": see
132
+ // README.md "Entscheidung 12".
133
+ //
134
+ // Detection requires BOTH of the following (either alone is too weak):
135
+ //
136
+ // 1. Pattern: over the sample, one specific byte parity (even bytes for
137
+ // UTF-16BE, odd bytes for UTF-16LE) is zero at a very high, regular
138
+ // rate (>= UTF16_HEURISTIC_DOMINANT_NULL_RATIO), while the *opposite*
139
+ // parity is zero only rarely (<= UTF16_HEURISTIC_OPPOSITE_NULL_RATIO).
140
+ // A real binary file's NUL bytes are not reliably tied to byte parity
141
+ // like that; this is the "regelmäßig alternierende Nullbytes"
142
+ // signature of Basic-Latin text encoded as fixed-width 16-bit units.
143
+ // 2. Validation: decoding the sample under the *candidate* encoding and
144
+ // counting actual code units, at least
145
+ // UTF16_HEURISTIC_MIN_PRINTABLE_RATIO of them must be printable
146
+ // (excluding NUL, other control characters, and the U+FFFD decode-
147
+ // error marker). This is the same "decode and inspect real
148
+ // characters" principle `classifyUtf16Body` already applies to
149
+ // BOM-confirmed UTF-16 — it is what rejects a real binary blob whose
150
+ // bytes happen to satisfy the parity pattern by chance.
151
+ //
152
+ // Both gates apply only once the sample reaches UTF16_HEURISTIC_MIN_SAMPLE_BYTES
153
+ // (32 bytes = 16 code units) — below that, parity ratios are too noisy to
154
+ // trust one way or the other, so short samples fall through to the existing
155
+ // (conservative: binary-by-default) raw byte-level classification. This is
156
+ // the documented "sehr kurze Probe" edge case; see the co-located test.
157
+ const UTF16_HEURISTIC_MIN_SAMPLE_BYTES = 32;
158
+ const UTF16_HEURISTIC_DOMINANT_NULL_RATIO = 0.6;
159
+ const UTF16_HEURISTIC_OPPOSITE_NULL_RATIO = 0.1;
160
+ const UTF16_HEURISTIC_MIN_PRINTABLE_RATIO = 0.9;
161
+
162
+ /**
163
+ * Attempts to identify BOM-less UTF-16 text in `sample` via the two-gate
164
+ * heuristic documented above. Returns the detected encoding, or `null` when
165
+ * the sample is too short, does not show the alternating-NUL pattern, or
166
+ * fails the printability validation (including: genuinely random/binary
167
+ * data that happens to satisfy the parity check alone).
168
+ */
169
+ function detectUtf16WithoutBom(sample: Uint8Array): "utf16le" | "utf16be" | null {
170
+ const pairCount = Math.floor(sample.length / 2);
171
+ if (sample.length < UTF16_HEURISTIC_MIN_SAMPLE_BYTES || pairCount === 0) {
172
+ return null;
173
+ }
174
+
175
+ let nullAtEven = 0; // high byte of each pair is 0x00 -> UTF-16BE ASCII signal
176
+ let nullAtOdd = 0; // low byte of each pair is 0x00 -> UTF-16LE ASCII signal
177
+ const bodyLength = pairCount * 2;
178
+ for (let i = 0; i < bodyLength; i += 2) {
179
+ if (sample[i] === 0) {
180
+ nullAtEven += 1;
181
+ }
182
+ if (sample[i + 1] === 0) {
183
+ nullAtOdd += 1;
184
+ }
185
+ }
186
+
187
+ const evenRatio = nullAtEven / pairCount;
188
+ const oddRatio = nullAtOdd / pairCount;
189
+
190
+ let candidate: "utf16le" | "utf16be" | null = null;
191
+ if (
192
+ oddRatio >= UTF16_HEURISTIC_DOMINANT_NULL_RATIO &&
193
+ evenRatio <= UTF16_HEURISTIC_OPPOSITE_NULL_RATIO
194
+ ) {
195
+ candidate = "utf16le";
196
+ } else if (
197
+ evenRatio >= UTF16_HEURISTIC_DOMINANT_NULL_RATIO &&
198
+ oddRatio <= UTF16_HEURISTIC_OPPOSITE_NULL_RATIO
199
+ ) {
200
+ candidate = "utf16be";
201
+ }
202
+ if (candidate === null) {
203
+ return null;
204
+ }
205
+
206
+ // Gate 2: decode under the candidate encoding and require the result to be
207
+ // overwhelmingly printable — this is what keeps real binary data (which
208
+ // can coincidentally satisfy the byte-parity check above, e.g. a run of
209
+ // small little-endian integers) from being misread as text.
210
+ const body = sample.subarray(0, bodyLength);
211
+ const text = new TextDecoder(candidate === "utf16le" ? "utf-16le" : "utf-16be", {
212
+ fatal: false,
213
+ }).decode(body);
214
+
215
+ let printable = 0;
216
+ let total = 0;
217
+ for (const char of text) {
218
+ total += 1;
219
+ const codePoint = char.codePointAt(0) ?? 0;
220
+ if (codePoint === 0) {
221
+ continue;
222
+ }
223
+ if (codePoint === 0xfffd) {
224
+ // TextDecoder's replacement character: an invalid code unit sequence
225
+ // under this candidate encoding, not evidence of real text.
226
+ continue;
227
+ }
228
+ if (codePoint < 0x100 && isNonPrintableControlByte(codePoint)) {
229
+ continue;
230
+ }
231
+ printable += 1;
232
+ }
233
+
234
+ if (total === 0 || printable / total < UTF16_HEURISTIC_MIN_PRINTABLE_RATIO) {
235
+ return null;
236
+ }
237
+
238
+ return candidate;
239
+ }
240
+
241
+ /**
242
+ * Classifies a content sample as binary or text.
243
+ *
244
+ * A sample is binary when it contains a NUL byte or when the ratio of
245
+ * non-printable control bytes exceeds `nonPrintableThreshold` (default
246
+ * 0.3). Bytes in the 0x80–0xFF range are NOT counted as non-printable —
247
+ * valid UTF-8 text uses them for non-ASCII characters, and flagging them
248
+ * would misclassify ordinary internationalized source/comments as binary.
249
+ *
250
+ * When the sample carries a UTF-16 BOM, the body is decoded per the
251
+ * confirmed encoding and classified over actual characters instead
252
+ * (`classifyUtf16Body`) — see that function's doc comment for why the raw
253
+ * byte-level NUL rule does not apply there.
254
+ *
255
+ * Absent a BOM, `detectUtf16WithoutBom` still checks for a conservative,
256
+ * two-gate BOM-less UTF-16 signature (regular alternating NUL bytes at one
257
+ * parity, validated by decoding and requiring the result be overwhelmingly
258
+ * printable) before falling back to the raw byte-level rule — see that
259
+ * function's doc comment for the documented thresholds and rationale
260
+ * (spec.md E4: a false negative here silently drops real source text from
261
+ * the index).
262
+ */
263
+ export function classifyBinarySample(
264
+ sample: Uint8Array,
265
+ options?: { nonPrintableThreshold?: number },
266
+ ): BinaryClassification {
267
+ const threshold = options?.nonPrintableThreshold ?? 0.3;
268
+ const bom = detectBom(sample);
269
+ const body = bom.hasBom ? sample.subarray(bom.length) : sample;
270
+
271
+ if (bom.hasBom && (bom.encoding === "utf16le" || bom.encoding === "utf16be")) {
272
+ return classifyUtf16Body(body, bom.encoding, threshold, true);
273
+ }
274
+
275
+ if (!bom.hasBom) {
276
+ const bomlessUtf16 = detectUtf16WithoutBom(sample);
277
+ if (bomlessUtf16 !== null) {
278
+ return classifyUtf16Body(sample, bomlessUtf16, threshold, false);
279
+ }
280
+ }
281
+
282
+ let hasNullByte = false;
283
+ let nonPrintable = 0;
284
+ let highBit = false;
285
+ for (const byte of body) {
286
+ if (byte === 0) {
287
+ hasNullByte = true;
288
+ continue;
289
+ }
290
+ if (isNonPrintableControlByte(byte)) {
291
+ nonPrintable += 1;
292
+ } else if (byte >= 0x80) {
293
+ highBit = true;
294
+ }
295
+ }
296
+
297
+ const nonPrintableRatio = body.length === 0 ? 0 : nonPrintable / body.length;
298
+ const isBinary = hasNullByte || nonPrintableRatio > threshold;
299
+
300
+ const encoding: TextEncodingGuess = bom.hasBom
301
+ ? bom.encoding
302
+ : isBinary
303
+ ? "unknown"
304
+ : highBit
305
+ ? "utf8"
306
+ : "ascii";
307
+
308
+ return { isBinary, hasNullByte, nonPrintableRatio, encoding, hasBom: bom.hasBom };
309
+ }
310
+
311
+ export interface MinifiedHeuristics {
312
+ /**
313
+ * A single line at or above this byte length signals minification. Default 500.
314
+ * Does NOT apply to prose (`textFormat === "prose"`): a long Markdown table
315
+ * row is ordinary prose, not minification.
316
+ */
317
+ maxLineLengthThreshold?: number;
318
+ /** Average bytes/line at or above this signals minification (low line-to-byte ratio). Default 300. */
319
+ averageBytesPerLineThreshold?: number;
320
+ }
321
+
322
+ /**
323
+ * Heuristic minified-file detection ("sehr lange Zeilen, geringes
324
+ * Zeilen-zu-Byte-Verhaeltnis") from a raw content sample. Operates on
325
+ * newline bytes (0x0A) directly rather than decoding, so it is safe to run
326
+ * on the same sample used for binary classification.
327
+ */
328
+ export function detectMinified(
329
+ sample: Uint8Array,
330
+ heuristics?: MinifiedHeuristics,
331
+ textFormat?: TextFormatKind | null,
332
+ ): boolean {
333
+ if (sample.length === 0) {
334
+ return false;
335
+ }
336
+ const maxLineLengthThreshold =
337
+ heuristics?.maxLineLengthThreshold ?? DEFAULT_MINIFIED_RULE.maxLineLengthThreshold;
338
+ const averageBytesPerLineThreshold =
339
+ heuristics?.averageBytesPerLineThreshold ?? DEFAULT_MINIFIED_RULE.averageLineLengthThreshold;
340
+
341
+ let lineBreaks = 0;
342
+ let longestLine = 0;
343
+ let currentLine = 0;
344
+ for (const byte of sample) {
345
+ if (byte === 0x0a) {
346
+ lineBreaks += 1;
347
+ longestLine = Math.max(longestLine, currentLine);
348
+ currentLine = 0;
349
+ } else {
350
+ currentLine += 1;
351
+ }
352
+ }
353
+ longestLine = Math.max(longestLine, currentLine);
354
+
355
+ const averageBytesPerLine = sample.length / Math.max(lineBreaks, 1);
356
+ // Die Entscheidung selbst liegt in `@untangle-code/domain`, damit Scanner und
357
+ // Chunker dieselbe Frage nicht mit verschiedenen Schwellen beantworten. Hier
358
+ // wird nur GEMESSEN — auf Bytes, weil dieselbe Stichprobe schon fuer die
359
+ // Binaerklassifikation gelesen wurde.
360
+ return isMinifiedShape(
361
+ { maxLineLength: longestLine, averageLineLength: averageBytesPerLine },
362
+ { textFormat: textFormat ?? null },
363
+ { maxLineLengthThreshold, averageLineLengthThreshold: averageBytesPerLineThreshold },
364
+ );
365
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Bounded-concurrency mapping over an (async) iterable, yielding results as
3
+ * soon as they complete (not necessarily in source order). Backs the
4
+ * per-file processing stage of discovery (architecture.md §13.1/§13.2).
5
+ */
6
+ import { Semaphore } from "./semaphore.js";
7
+
8
+ interface Settled<R> {
9
+ id: number;
10
+ value: R;
11
+ }
12
+
13
+ /**
14
+ * Applies `worker` to every item of `source` with at most `concurrency` in
15
+ * flight at once, yielding each result as it settles. Checks `signal` before
16
+ * pulling each new item and before yielding each result, throwing (via
17
+ * `AbortSignal.throwIfAborted`) as soon as an abort is observed.
18
+ */
19
+ export async function* mapConcurrent<T, R>(
20
+ source: AsyncIterable<T> | Iterable<T>,
21
+ concurrency: number,
22
+ worker: (item: T) => Promise<R>,
23
+ signal?: AbortSignal,
24
+ ): AsyncGenerator<R, void, void> {
25
+ const semaphore = new Semaphore(concurrency);
26
+ const iterator: AsyncIterator<T> | Iterator<T> =
27
+ Symbol.asyncIterator in source
28
+ ? (source as AsyncIterable<T>)[Symbol.asyncIterator]()
29
+ : (source as Iterable<T>)[Symbol.iterator]();
30
+
31
+ const inFlight = new Map<number, Promise<Settled<R>>>();
32
+ let nextId = 0;
33
+ let sourceDone = false;
34
+
35
+ async function pullOne(): Promise<void> {
36
+ if (sourceDone) {
37
+ return;
38
+ }
39
+ const release = await semaphore.acquire();
40
+ signal?.throwIfAborted();
41
+ const step = await iterator.next();
42
+ if (step.done) {
43
+ sourceDone = true;
44
+ release();
45
+ return;
46
+ }
47
+ const id = nextId;
48
+ nextId += 1;
49
+ const task = worker(step.value)
50
+ .then((value) => ({ id, value }))
51
+ .finally(release);
52
+ inFlight.set(id, task);
53
+ }
54
+
55
+ try {
56
+ signal?.throwIfAborted();
57
+ const starters: Array<Promise<void>> = [];
58
+ for (let i = 0; i < concurrency; i += 1) {
59
+ starters.push(pullOne());
60
+ }
61
+ await Promise.all(starters);
62
+
63
+ while (inFlight.size > 0) {
64
+ signal?.throwIfAborted();
65
+ const settled = await Promise.race(inFlight.values());
66
+ inFlight.delete(settled.id);
67
+ if (!sourceDone) {
68
+ await pullOne();
69
+ }
70
+ signal?.throwIfAborted();
71
+ yield settled.value;
72
+ }
73
+ } finally {
74
+ if (typeof (iterator as AsyncIterator<T>).return === "function") {
75
+ await (iterator as AsyncIterator<T>).return?.();
76
+ }
77
+ }
78
+ }