@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.
- package/LICENSE +661 -0
- package/NOTICE +90 -0
- package/README.md +303 -2
- package/THIRD_PARTY_LICENSES.md +24 -0
- package/dist/binary-classifier.d.ts +45 -0
- package/dist/binary-classifier.d.ts.map +1 -0
- package/dist/binary-classifier.js +296 -0
- package/dist/binary-classifier.js.map +1 -0
- package/dist/concurrency.d.ts +8 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +64 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/discovery.d.ts +9 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +254 -0
- package/dist/discovery.js.map +1 -0
- package/dist/gitignore-pattern.d.ts +35 -0
- package/dist/gitignore-pattern.d.ts.map +1 -0
- package/dist/gitignore-pattern.js +155 -0
- package/dist/gitignore-pattern.js.map +1 -0
- package/dist/hashing.d.ts +14 -0
- package/dist/hashing.d.ts.map +1 -0
- package/dist/hashing.js +32 -0
- package/dist/hashing.js.map +1 -0
- package/dist/ignore-engine.d.ts +27 -0
- package/dist/ignore-engine.d.ts.map +1 -0
- package/dist/ignore-engine.js +130 -0
- package/dist/ignore-engine.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/language-classifier.d.ts +19 -0
- package/dist/language-classifier.d.ts.map +1 -0
- package/dist/language-classifier.js +114 -0
- package/dist/language-classifier.js.map +1 -0
- package/dist/node-fs/index.d.ts +10 -0
- package/dist/node-fs/index.d.ts.map +1 -0
- package/dist/node-fs/index.js +10 -0
- package/dist/node-fs/index.js.map +1 -0
- package/dist/node-fs/node-file-system.d.ts +19 -0
- package/dist/node-fs/node-file-system.d.ts.map +1 -0
- package/dist/node-fs/node-file-system.js +104 -0
- package/dist/node-fs/node-file-system.js.map +1 -0
- package/dist/node-fs/node-streaming-hasher.d.ts +4 -0
- package/dist/node-fs/node-streaming-hasher.d.ts.map +1 -0
- package/dist/node-fs/node-streaming-hasher.js +33 -0
- package/dist/node-fs/node-streaming-hasher.js.map +1 -0
- package/dist/port-options.d.ts +8 -0
- package/dist/port-options.d.ts.map +1 -0
- package/dist/port-options.js +4 -0
- package/dist/port-options.js.map +1 -0
- package/dist/semaphore.d.ts +18 -0
- package/dist/semaphore.d.ts.map +1 -0
- package/dist/semaphore.js +42 -0
- package/dist/semaphore.js.map +1 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +63 -3
- package/src/binary-classifier.ts +365 -0
- package/src/concurrency.ts +78 -0
- package/src/discovery.ts +362 -0
- package/src/gitignore-pattern.ts +170 -0
- package/src/hashing.ts +37 -0
- package/src/ignore-engine.ts +157 -0
- package/src/index.ts +48 -0
- package/src/language-classifier.ts +137 -0
- package/src/node-fs/index.ts +9 -0
- package/src/node-fs/node-file-system.ts +137 -0
- package/src/node-fs/node-streaming-hasher.ts +35 -0
- package/src/port-options.ts +10 -0
- package/src/semaphore.ts +45 -0
- package/src/types.ts +121 -0
|
@@ -0,0 +1,296 @@
|
|
|
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
|
+
// Longest match first (utf16le/be are 2-byte prefixes of nothing else here,
|
|
10
|
+
// but ordering is kept explicit and defensive).
|
|
11
|
+
//
|
|
12
|
+
// NOTE (checked per WP defect audit, not fixed — out of spec.md/architecture.md
|
|
13
|
+
// scope): UTF-32 is not a recognized `TextEncodingGuess` and has no BOM_SPECS
|
|
14
|
+
// entry. A UTF-32LE-BOM'd sample (FF FE 00 00 …) therefore still matches the
|
|
15
|
+
// 2-byte utf16le prefix above and is (mis-)tagged "utf16le" with a 2-byte BOM
|
|
16
|
+
// length. `classifyUtf16Body` below decodes the remaining bytes as UTF-16LE
|
|
17
|
+
// in that case, which — because every UTF-32LE code unit contributes two
|
|
18
|
+
// extra 0x00 bytes — reliably decodes to text containing genuine embedded
|
|
19
|
+
// NUL characters, so the sample still ends up classified `isBinary: true`.
|
|
20
|
+
// That is the pre-existing, undocumented-until-now behavior for UTF-32
|
|
21
|
+
// input: not a regression introduced by this fix, but also not a real fix —
|
|
22
|
+
// neither spec.md nor architecture.md specify UTF-32 support, so adding a
|
|
23
|
+
// dedicated "utf32le"/"utf32be" encoding is a separate, out-of-scope
|
|
24
|
+
// decision rather than something to guess at here.
|
|
25
|
+
const BOM_SPECS = [
|
|
26
|
+
{ bytes: [0xef, 0xbb, 0xbf], encoding: "utf8" },
|
|
27
|
+
{ bytes: [0xff, 0xfe], encoding: "utf16le" },
|
|
28
|
+
{ bytes: [0xfe, 0xff], encoding: "utf16be" },
|
|
29
|
+
];
|
|
30
|
+
function detectBom(sample) {
|
|
31
|
+
for (const spec of BOM_SPECS) {
|
|
32
|
+
if (sample.length < spec.bytes.length) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
let matches = true;
|
|
36
|
+
for (let i = 0; i < spec.bytes.length; i += 1) {
|
|
37
|
+
if (sample[i] !== spec.bytes[i]) {
|
|
38
|
+
matches = false;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (matches) {
|
|
43
|
+
return { hasBom: true, encoding: spec.encoding, length: spec.bytes.length };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { hasBom: false, encoding: "unknown", length: 0 };
|
|
47
|
+
}
|
|
48
|
+
/** Control bytes that indicate non-text content; tab/LF/CR are excluded (they're common in text). */
|
|
49
|
+
function isNonPrintableControlByte(byte) {
|
|
50
|
+
return (byte <= 0x08 ||
|
|
51
|
+
byte === 0x0b ||
|
|
52
|
+
byte === 0x0c ||
|
|
53
|
+
(byte >= 0x0e && byte <= 0x1f) ||
|
|
54
|
+
byte === 0x7f);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Classifies the body of a sample confirmed (by BOM or by the BOM-less
|
|
58
|
+
* heuristic in `detectUtf16WithoutBom`) to be UTF-16 (LE or BE).
|
|
59
|
+
*
|
|
60
|
+
* The raw "a NUL byte means binary" rule from `classifyBinarySample` cannot
|
|
61
|
+
* be applied to UTF-16 bytes directly: every Basic-Latin code unit (i.e.
|
|
62
|
+
* every plain ASCII character) carries a 0x00 byte as one half of its
|
|
63
|
+
* 16-bit unit, so a legitimate UTF-16 text file is close to 50% NUL bytes
|
|
64
|
+
* by construction. Decoding first and inspecting actual *characters*
|
|
65
|
+
* avoids that false positive while still catching real NUL characters and
|
|
66
|
+
* a high ratio of real control characters.
|
|
67
|
+
*/
|
|
68
|
+
function classifyUtf16Body(body, encoding, threshold, hasBom) {
|
|
69
|
+
// Non-fatal decode: a byte sequence that only coincidentally starts with a
|
|
70
|
+
// UTF-16 BOM (rare, but the classifier must stay robust for arbitrary
|
|
71
|
+
// binary input) must not throw here — invalid sequences become U+FFFD
|
|
72
|
+
// instead, which is handled like any other non-control character below.
|
|
73
|
+
const text = new TextDecoder(encoding === "utf16le" ? "utf-16le" : "utf-16be", {
|
|
74
|
+
fatal: false,
|
|
75
|
+
}).decode(body);
|
|
76
|
+
let hasNullByte = false;
|
|
77
|
+
let nonPrintable = 0;
|
|
78
|
+
let unitCount = 0;
|
|
79
|
+
for (const char of text) {
|
|
80
|
+
unitCount += 1;
|
|
81
|
+
const codePoint = char.codePointAt(0) ?? 0;
|
|
82
|
+
if (codePoint === 0) {
|
|
83
|
+
hasNullByte = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (codePoint < 0x100 && isNonPrintableControlByte(codePoint)) {
|
|
87
|
+
nonPrintable += 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const nonPrintableRatio = unitCount === 0 ? 0 : nonPrintable / unitCount;
|
|
91
|
+
const isBinary = hasNullByte || nonPrintableRatio > threshold;
|
|
92
|
+
return { isBinary, hasNullByte, nonPrintableRatio, encoding, hasBom };
|
|
93
|
+
}
|
|
94
|
+
// --- BOM-less UTF-16 detection (defect fix, spec.md E4) --------------------
|
|
95
|
+
//
|
|
96
|
+
// A UTF-16 file with no BOM is legal (spec.md/architecture.md do not require
|
|
97
|
+
// one, and plenty of real tooling emits BOM-less UTF-16). Without a BOM,
|
|
98
|
+
// `detectBom` reports `hasBom:false` and — before this fix — the sample fell
|
|
99
|
+
// straight into the raw byte-level loop below, which sees every other byte
|
|
100
|
+
// as 0x00 (the high or low half of each Basic-Latin code unit) and
|
|
101
|
+
// classifies the file as binary. That is a false NEGATIVE (real source text
|
|
102
|
+
// silently vanishes from the index), which is worse than the false-POSITIVE
|
|
103
|
+
// risk of a speculative heuristic, so this is a deliberate, narrow, and
|
|
104
|
+
// documented exception to "don't guess encodings beyond spec": see
|
|
105
|
+
// README.md "Entscheidung 12".
|
|
106
|
+
//
|
|
107
|
+
// Detection requires BOTH of the following (either alone is too weak):
|
|
108
|
+
//
|
|
109
|
+
// 1. Pattern: over the sample, one specific byte parity (even bytes for
|
|
110
|
+
// UTF-16BE, odd bytes for UTF-16LE) is zero at a very high, regular
|
|
111
|
+
// rate (>= UTF16_HEURISTIC_DOMINANT_NULL_RATIO), while the *opposite*
|
|
112
|
+
// parity is zero only rarely (<= UTF16_HEURISTIC_OPPOSITE_NULL_RATIO).
|
|
113
|
+
// A real binary file's NUL bytes are not reliably tied to byte parity
|
|
114
|
+
// like that; this is the "regelmäßig alternierende Nullbytes"
|
|
115
|
+
// signature of Basic-Latin text encoded as fixed-width 16-bit units.
|
|
116
|
+
// 2. Validation: decoding the sample under the *candidate* encoding and
|
|
117
|
+
// counting actual code units, at least
|
|
118
|
+
// UTF16_HEURISTIC_MIN_PRINTABLE_RATIO of them must be printable
|
|
119
|
+
// (excluding NUL, other control characters, and the U+FFFD decode-
|
|
120
|
+
// error marker). This is the same "decode and inspect real
|
|
121
|
+
// characters" principle `classifyUtf16Body` already applies to
|
|
122
|
+
// BOM-confirmed UTF-16 — it is what rejects a real binary blob whose
|
|
123
|
+
// bytes happen to satisfy the parity pattern by chance.
|
|
124
|
+
//
|
|
125
|
+
// Both gates apply only once the sample reaches UTF16_HEURISTIC_MIN_SAMPLE_BYTES
|
|
126
|
+
// (32 bytes = 16 code units) — below that, parity ratios are too noisy to
|
|
127
|
+
// trust one way or the other, so short samples fall through to the existing
|
|
128
|
+
// (conservative: binary-by-default) raw byte-level classification. This is
|
|
129
|
+
// the documented "sehr kurze Probe" edge case; see the co-located test.
|
|
130
|
+
const UTF16_HEURISTIC_MIN_SAMPLE_BYTES = 32;
|
|
131
|
+
const UTF16_HEURISTIC_DOMINANT_NULL_RATIO = 0.6;
|
|
132
|
+
const UTF16_HEURISTIC_OPPOSITE_NULL_RATIO = 0.1;
|
|
133
|
+
const UTF16_HEURISTIC_MIN_PRINTABLE_RATIO = 0.9;
|
|
134
|
+
/**
|
|
135
|
+
* Attempts to identify BOM-less UTF-16 text in `sample` via the two-gate
|
|
136
|
+
* heuristic documented above. Returns the detected encoding, or `null` when
|
|
137
|
+
* the sample is too short, does not show the alternating-NUL pattern, or
|
|
138
|
+
* fails the printability validation (including: genuinely random/binary
|
|
139
|
+
* data that happens to satisfy the parity check alone).
|
|
140
|
+
*/
|
|
141
|
+
function detectUtf16WithoutBom(sample) {
|
|
142
|
+
const pairCount = Math.floor(sample.length / 2);
|
|
143
|
+
if (sample.length < UTF16_HEURISTIC_MIN_SAMPLE_BYTES || pairCount === 0) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
let nullAtEven = 0; // high byte of each pair is 0x00 -> UTF-16BE ASCII signal
|
|
147
|
+
let nullAtOdd = 0; // low byte of each pair is 0x00 -> UTF-16LE ASCII signal
|
|
148
|
+
const bodyLength = pairCount * 2;
|
|
149
|
+
for (let i = 0; i < bodyLength; i += 2) {
|
|
150
|
+
if (sample[i] === 0) {
|
|
151
|
+
nullAtEven += 1;
|
|
152
|
+
}
|
|
153
|
+
if (sample[i + 1] === 0) {
|
|
154
|
+
nullAtOdd += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const evenRatio = nullAtEven / pairCount;
|
|
158
|
+
const oddRatio = nullAtOdd / pairCount;
|
|
159
|
+
let candidate = null;
|
|
160
|
+
if (oddRatio >= UTF16_HEURISTIC_DOMINANT_NULL_RATIO &&
|
|
161
|
+
evenRatio <= UTF16_HEURISTIC_OPPOSITE_NULL_RATIO) {
|
|
162
|
+
candidate = "utf16le";
|
|
163
|
+
}
|
|
164
|
+
else if (evenRatio >= UTF16_HEURISTIC_DOMINANT_NULL_RATIO &&
|
|
165
|
+
oddRatio <= UTF16_HEURISTIC_OPPOSITE_NULL_RATIO) {
|
|
166
|
+
candidate = "utf16be";
|
|
167
|
+
}
|
|
168
|
+
if (candidate === null) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
// Gate 2: decode under the candidate encoding and require the result to be
|
|
172
|
+
// overwhelmingly printable — this is what keeps real binary data (which
|
|
173
|
+
// can coincidentally satisfy the byte-parity check above, e.g. a run of
|
|
174
|
+
// small little-endian integers) from being misread as text.
|
|
175
|
+
const body = sample.subarray(0, bodyLength);
|
|
176
|
+
const text = new TextDecoder(candidate === "utf16le" ? "utf-16le" : "utf-16be", {
|
|
177
|
+
fatal: false,
|
|
178
|
+
}).decode(body);
|
|
179
|
+
let printable = 0;
|
|
180
|
+
let total = 0;
|
|
181
|
+
for (const char of text) {
|
|
182
|
+
total += 1;
|
|
183
|
+
const codePoint = char.codePointAt(0) ?? 0;
|
|
184
|
+
if (codePoint === 0) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (codePoint === 0xfffd) {
|
|
188
|
+
// TextDecoder's replacement character: an invalid code unit sequence
|
|
189
|
+
// under this candidate encoding, not evidence of real text.
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (codePoint < 0x100 && isNonPrintableControlByte(codePoint)) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
printable += 1;
|
|
196
|
+
}
|
|
197
|
+
if (total === 0 || printable / total < UTF16_HEURISTIC_MIN_PRINTABLE_RATIO) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
return candidate;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Classifies a content sample as binary or text.
|
|
204
|
+
*
|
|
205
|
+
* A sample is binary when it contains a NUL byte or when the ratio of
|
|
206
|
+
* non-printable control bytes exceeds `nonPrintableThreshold` (default
|
|
207
|
+
* 0.3). Bytes in the 0x80–0xFF range are NOT counted as non-printable —
|
|
208
|
+
* valid UTF-8 text uses them for non-ASCII characters, and flagging them
|
|
209
|
+
* would misclassify ordinary internationalized source/comments as binary.
|
|
210
|
+
*
|
|
211
|
+
* When the sample carries a UTF-16 BOM, the body is decoded per the
|
|
212
|
+
* confirmed encoding and classified over actual characters instead
|
|
213
|
+
* (`classifyUtf16Body`) — see that function's doc comment for why the raw
|
|
214
|
+
* byte-level NUL rule does not apply there.
|
|
215
|
+
*
|
|
216
|
+
* Absent a BOM, `detectUtf16WithoutBom` still checks for a conservative,
|
|
217
|
+
* two-gate BOM-less UTF-16 signature (regular alternating NUL bytes at one
|
|
218
|
+
* parity, validated by decoding and requiring the result be overwhelmingly
|
|
219
|
+
* printable) before falling back to the raw byte-level rule — see that
|
|
220
|
+
* function's doc comment for the documented thresholds and rationale
|
|
221
|
+
* (spec.md E4: a false negative here silently drops real source text from
|
|
222
|
+
* the index).
|
|
223
|
+
*/
|
|
224
|
+
export function classifyBinarySample(sample, options) {
|
|
225
|
+
const threshold = options?.nonPrintableThreshold ?? 0.3;
|
|
226
|
+
const bom = detectBom(sample);
|
|
227
|
+
const body = bom.hasBom ? sample.subarray(bom.length) : sample;
|
|
228
|
+
if (bom.hasBom && (bom.encoding === "utf16le" || bom.encoding === "utf16be")) {
|
|
229
|
+
return classifyUtf16Body(body, bom.encoding, threshold, true);
|
|
230
|
+
}
|
|
231
|
+
if (!bom.hasBom) {
|
|
232
|
+
const bomlessUtf16 = detectUtf16WithoutBom(sample);
|
|
233
|
+
if (bomlessUtf16 !== null) {
|
|
234
|
+
return classifyUtf16Body(sample, bomlessUtf16, threshold, false);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
let hasNullByte = false;
|
|
238
|
+
let nonPrintable = 0;
|
|
239
|
+
let highBit = false;
|
|
240
|
+
for (const byte of body) {
|
|
241
|
+
if (byte === 0) {
|
|
242
|
+
hasNullByte = true;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (isNonPrintableControlByte(byte)) {
|
|
246
|
+
nonPrintable += 1;
|
|
247
|
+
}
|
|
248
|
+
else if (byte >= 0x80) {
|
|
249
|
+
highBit = true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const nonPrintableRatio = body.length === 0 ? 0 : nonPrintable / body.length;
|
|
253
|
+
const isBinary = hasNullByte || nonPrintableRatio > threshold;
|
|
254
|
+
const encoding = bom.hasBom
|
|
255
|
+
? bom.encoding
|
|
256
|
+
: isBinary
|
|
257
|
+
? "unknown"
|
|
258
|
+
: highBit
|
|
259
|
+
? "utf8"
|
|
260
|
+
: "ascii";
|
|
261
|
+
return { isBinary, hasNullByte, nonPrintableRatio, encoding, hasBom: bom.hasBom };
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Heuristic minified-file detection ("sehr lange Zeilen, geringes
|
|
265
|
+
* Zeilen-zu-Byte-Verhaeltnis") from a raw content sample. Operates on
|
|
266
|
+
* newline bytes (0x0A) directly rather than decoding, so it is safe to run
|
|
267
|
+
* on the same sample used for binary classification.
|
|
268
|
+
*/
|
|
269
|
+
export function detectMinified(sample, heuristics, textFormat) {
|
|
270
|
+
if (sample.length === 0) {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
const maxLineLengthThreshold = heuristics?.maxLineLengthThreshold ?? DEFAULT_MINIFIED_RULE.maxLineLengthThreshold;
|
|
274
|
+
const averageBytesPerLineThreshold = heuristics?.averageBytesPerLineThreshold ?? DEFAULT_MINIFIED_RULE.averageLineLengthThreshold;
|
|
275
|
+
let lineBreaks = 0;
|
|
276
|
+
let longestLine = 0;
|
|
277
|
+
let currentLine = 0;
|
|
278
|
+
for (const byte of sample) {
|
|
279
|
+
if (byte === 0x0a) {
|
|
280
|
+
lineBreaks += 1;
|
|
281
|
+
longestLine = Math.max(longestLine, currentLine);
|
|
282
|
+
currentLine = 0;
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
currentLine += 1;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
longestLine = Math.max(longestLine, currentLine);
|
|
289
|
+
const averageBytesPerLine = sample.length / Math.max(lineBreaks, 1);
|
|
290
|
+
// Die Entscheidung selbst liegt in `@untangle-code/domain`, damit Scanner und
|
|
291
|
+
// Chunker dieselbe Frage nicht mit verschiedenen Schwellen beantworten. Hier
|
|
292
|
+
// wird nur GEMESSEN — auf Bytes, weil dieselbe Stichprobe schon fuer die
|
|
293
|
+
// Binaerklassifikation gelesen wurde.
|
|
294
|
+
return isMinifiedShape({ maxLineLength: longestLine, averageLineLength: averageBytesPerLine }, { textFormat: textFormat ?? null }, { maxLineLengthThreshold, averageLineLengthThreshold: averageBytesPerLineThreshold });
|
|
295
|
+
}
|
|
296
|
+
//# sourceMappingURL=binary-classifier.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"binary-classifier.js","sourceRoot":"","sources":["../src/binary-classifier.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies `worker` to every item of `source` with at most `concurrency` in
|
|
3
|
+
* flight at once, yielding each result as it settles. Checks `signal` before
|
|
4
|
+
* pulling each new item and before yielding each result, throwing (via
|
|
5
|
+
* `AbortSignal.throwIfAborted`) as soon as an abort is observed.
|
|
6
|
+
*/
|
|
7
|
+
export declare function mapConcurrent<T, R>(source: AsyncIterable<T> | Iterable<T>, concurrency: number, worker: (item: T) => Promise<R>, signal?: AbortSignal): AsyncGenerator<R, void, void>;
|
|
8
|
+
//# sourceMappingURL=concurrency.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"concurrency.d.ts","sourceRoot":"","sources":["../src/concurrency.ts"],"names":[],"mappings":"AAYA;;;;;GAKG;AACH,wBAAuB,aAAa,CAAC,CAAC,EAAE,CAAC,EACvC,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,EACtC,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,EAC/B,MAAM,CAAC,EAAE,WAAW,GACnB,cAAc,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,CAsD/B"}
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
* Applies `worker` to every item of `source` with at most `concurrency` in
|
|
9
|
+
* flight at once, yielding each result as it settles. Checks `signal` before
|
|
10
|
+
* pulling each new item and before yielding each result, throwing (via
|
|
11
|
+
* `AbortSignal.throwIfAborted`) as soon as an abort is observed.
|
|
12
|
+
*/
|
|
13
|
+
export async function* mapConcurrent(source, concurrency, worker, signal) {
|
|
14
|
+
const semaphore = new Semaphore(concurrency);
|
|
15
|
+
const iterator = Symbol.asyncIterator in source
|
|
16
|
+
? source[Symbol.asyncIterator]()
|
|
17
|
+
: source[Symbol.iterator]();
|
|
18
|
+
const inFlight = new Map();
|
|
19
|
+
let nextId = 0;
|
|
20
|
+
let sourceDone = false;
|
|
21
|
+
async function pullOne() {
|
|
22
|
+
if (sourceDone) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const release = await semaphore.acquire();
|
|
26
|
+
signal?.throwIfAborted();
|
|
27
|
+
const step = await iterator.next();
|
|
28
|
+
if (step.done) {
|
|
29
|
+
sourceDone = true;
|
|
30
|
+
release();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const id = nextId;
|
|
34
|
+
nextId += 1;
|
|
35
|
+
const task = worker(step.value)
|
|
36
|
+
.then((value) => ({ id, value }))
|
|
37
|
+
.finally(release);
|
|
38
|
+
inFlight.set(id, task);
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
signal?.throwIfAborted();
|
|
42
|
+
const starters = [];
|
|
43
|
+
for (let i = 0; i < concurrency; i += 1) {
|
|
44
|
+
starters.push(pullOne());
|
|
45
|
+
}
|
|
46
|
+
await Promise.all(starters);
|
|
47
|
+
while (inFlight.size > 0) {
|
|
48
|
+
signal?.throwIfAborted();
|
|
49
|
+
const settled = await Promise.race(inFlight.values());
|
|
50
|
+
inFlight.delete(settled.id);
|
|
51
|
+
if (!sourceDone) {
|
|
52
|
+
await pullOne();
|
|
53
|
+
}
|
|
54
|
+
signal?.throwIfAborted();
|
|
55
|
+
yield settled.value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
if (typeof iterator.return === "function") {
|
|
60
|
+
await iterator.return?.();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=concurrency.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"concurrency.js","sourceRoot":"","sources":["../src/concurrency.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAO3C;;;;;GAKG;AACH,MAAM,CAAC,KAAK,SAAS,CAAC,CAAC,aAAa,CAClC,MAAsC,EACtC,WAAmB,EACnB,MAA+B,EAC/B,MAAoB;IAEpB,MAAM,SAAS,GAAG,IAAI,SAAS,CAAC,WAAW,CAAC,CAAC;IAC7C,MAAM,QAAQ,GACZ,MAAM,CAAC,aAAa,IAAI,MAAM;QAC5B,CAAC,CAAE,MAA2B,CAAC,MAAM,CAAC,aAAa,CAAC,EAAE;QACtD,CAAC,CAAE,MAAsB,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;IAEjD,MAAM,QAAQ,GAAG,IAAI,GAAG,EAA+B,CAAC;IACxD,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,IAAI,UAAU,GAAG,KAAK,CAAC;IAEvB,KAAK,UAAU,OAAO;QACpB,IAAI,UAAU,EAAE,CAAC;YACf,OAAO;QACT,CAAC;QACD,MAAM,OAAO,GAAG,MAAM,SAAS,CAAC,OAAO,EAAE,CAAC;QAC1C,MAAM,EAAE,cAAc,EAAE,CAAC;QACzB,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;QACnC,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC;YACd,UAAU,GAAG,IAAI,CAAC;YAClB,OAAO,EAAE,CAAC;YACV,OAAO;QACT,CAAC;QACD,MAAM,EAAE,GAAG,MAAM,CAAC;QAClB,MAAM,IAAI,CAAC,CAAC;QACZ,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC;aAC5B,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;aAChC,OAAO,CAAC,OAAO,CAAC,CAAC;QACpB,QAAQ,CAAC,GAAG,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC;IACzB,CAAC;IAED,IAAI,CAAC;QACH,MAAM,EAAE,cAAc,EAAE,CAAC;QACzB,MAAM,QAAQ,GAAyB,EAAE,CAAC;QAC1C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,WAAW,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;YACxC,QAAQ,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC;QAC3B,CAAC;QACD,MAAM,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAE5B,OAAO,QAAQ,CAAC,IAAI,GAAG,CAAC,EAAE,CAAC;YACzB,MAAM,EAAE,cAAc,EAAE,CAAC;YACzB,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;YACtD,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;YAC5B,IAAI,CAAC,UAAU,EAAE,CAAC;gBAChB,MAAM,OAAO,EAAE,CAAC;YAClB,CAAC;YACD,MAAM,EAAE,cAAc,EAAE,CAAC;YACzB,MAAM,OAAO,CAAC,KAAK,CAAC;QACtB,CAAC;IACH,CAAC;YAAS,CAAC;QACT,IAAI,OAAQ,QAA6B,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;YAChE,MAAO,QAA6B,CAAC,MAAM,EAAE,EAAE,CAAC;QAClD,CAAC;IACH,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ScannedFileV1, ScanOptions } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Discovers files under `options.repoRoot`, applying ignore rules and
|
|
4
|
+
* symlink safety, and yields a `ScannedFileV1` for every file accepted for
|
|
5
|
+
* downstream analysis. Excluded/skipped paths are reported via `onSkip`,
|
|
6
|
+
* never yielded. Respects `options.signal` throughout.
|
|
7
|
+
*/
|
|
8
|
+
export declare function discoverFiles(options: ScanOptions): AsyncGenerator<ScannedFileV1, void, void>;
|
|
9
|
+
//# sourceMappingURL=discovery.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discovery.d.ts","sourceRoot":"","sources":["../src/discovery.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAEV,aAAa,EACb,WAAW,EAGZ,MAAM,YAAY,CAAC;AA6QpB;;;;;GAKG;AACH,wBAAuB,aAAa,CAClC,OAAO,EAAE,WAAW,GACnB,cAAc,CAAC,aAAa,EAAE,IAAI,EAAE,IAAI,CAAC,CAwD3C"}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursive discovery (spec.md §6.1; architecture.md §6.4, §23.1).
|
|
3
|
+
*
|
|
4
|
+
* Walks the repository tree through `FileSystemPort` only (never
|
|
5
|
+
* `node:fs`), applies the ignore engine, resolves and guards symlinks, and
|
|
6
|
+
* hands each surviving path to bounded-concurrency classification/hashing.
|
|
7
|
+
*/
|
|
8
|
+
import { classifyTextFormat, assertWithinWorkspace, isUntangleError, } from "@untangle-code/domain";
|
|
9
|
+
import { classifyBinarySample, detectMinified } from "./binary-classifier.js";
|
|
10
|
+
import { mapConcurrent } from "./concurrency.js";
|
|
11
|
+
import { createPortContentHasher } from "./hashing.js";
|
|
12
|
+
import { createIgnoreEngine } from "./ignore-engine.js";
|
|
13
|
+
import { detectLanguage } from "./language-classifier.js";
|
|
14
|
+
import { portOptions } from "./port-options.js";
|
|
15
|
+
function skip(context, relativePath, reason, detail) {
|
|
16
|
+
context.onSkip?.(detail === undefined ? { relativePath, reason } : { relativePath, reason, detail });
|
|
17
|
+
}
|
|
18
|
+
async function* walkDirectory(context, dir) {
|
|
19
|
+
context.signal?.throwIfAborted();
|
|
20
|
+
const entries = await context.fs.readDir(dir.absolutePath, portOptions(context.signal));
|
|
21
|
+
let pushedGitignore = false;
|
|
22
|
+
let pushedUntangleIgnore = false;
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
if (entry.kind === "file" && entry.name === context.gitIgnoreFileName) {
|
|
25
|
+
const content = await context.fs.readTextFile(entry.path, portOptions(context.signal));
|
|
26
|
+
context.ignoreEngine.pushGitignoreLayer(dir.relativePath, content);
|
|
27
|
+
pushedGitignore = true;
|
|
28
|
+
}
|
|
29
|
+
else if (entry.kind === "file" && entry.name === context.untangleIgnoreFileName) {
|
|
30
|
+
const content = await context.fs.readTextFile(entry.path, portOptions(context.signal));
|
|
31
|
+
context.ignoreEngine.pushUntangleIgnoreLayer(dir.relativePath, content);
|
|
32
|
+
pushedUntangleIgnore = true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
context.signal?.throwIfAborted();
|
|
38
|
+
const childRelative = dir.relativePath === "" ? entry.name : `${dir.relativePath}/${entry.name}`;
|
|
39
|
+
if (entry.kind === "directory") {
|
|
40
|
+
const decision = context.ignoreEngine.decide(childRelative, true);
|
|
41
|
+
if (decision.ignored) {
|
|
42
|
+
skip(context, childRelative, decision.reason ?? "default-exclude");
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (context.visitedRealDirs.has(entry.path)) {
|
|
46
|
+
skip(context, childRelative, "symlink-cycle", "directory real path already visited");
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
context.visitedRealDirs.add(entry.path);
|
|
50
|
+
yield* walkDirectory(context, { relativePath: childRelative, absolutePath: entry.path });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (entry.kind === "symlink") {
|
|
54
|
+
yield* handleSymlink(context, entry.path, childRelative);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (entry.kind === "file") {
|
|
58
|
+
const decision = context.ignoreEngine.decide(childRelative, false);
|
|
59
|
+
if (decision.ignored) {
|
|
60
|
+
skip(context, childRelative, decision.reason ?? "default-exclude");
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
yield { relativePath: childRelative, absolutePath: entry.path };
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
skip(context, childRelative, "unreadable", `unsupported directory entry kind "${entry.kind}"`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
if (pushedUntangleIgnore) {
|
|
71
|
+
context.ignoreEngine.popUntangleIgnoreLayer();
|
|
72
|
+
}
|
|
73
|
+
if (pushedGitignore) {
|
|
74
|
+
context.ignoreEngine.popGitignoreLayer();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function* handleSymlink(context, entryAbsolutePath, childRelative) {
|
|
79
|
+
let real;
|
|
80
|
+
try {
|
|
81
|
+
real = await context.fs.realPath(entryAbsolutePath, portOptions(context.signal));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
skip(context, childRelative, "unreadable", "symlink target could not be resolved");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
assertWithinWorkspace(real, context.roots);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (isUntangleError(error) && error.code === "PATH_OUTSIDE_WORKSPACE") {
|
|
92
|
+
skip(context, childRelative, "symlink-escape", real);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
const targetStat = await context.fs.stat(real, portOptions(context.signal));
|
|
98
|
+
if (targetStat === null) {
|
|
99
|
+
skip(context, childRelative, "unreadable", "dangling symlink");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (targetStat.kind === "directory") {
|
|
103
|
+
const decision = context.ignoreEngine.decide(childRelative, true);
|
|
104
|
+
if (decision.ignored) {
|
|
105
|
+
skip(context, childRelative, decision.reason ?? "default-exclude");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (context.visitedRealDirs.has(real)) {
|
|
109
|
+
skip(context, childRelative, "symlink-cycle", real);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
context.visitedRealDirs.add(real);
|
|
113
|
+
yield* walkDirectory(context, { relativePath: childRelative, absolutePath: real });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (targetStat.kind === "file") {
|
|
117
|
+
const decision = context.ignoreEngine.decide(childRelative, false);
|
|
118
|
+
if (decision.ignored) {
|
|
119
|
+
skip(context, childRelative, decision.reason ?? "default-exclude");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
yield { relativePath: childRelative, absolutePath: real, stat: targetStat };
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
skip(context, childRelative, "unreadable", `symlink target has unsupported kind "${targetStat.kind}"`);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Decodes a content sample as text using the *detected* encoding
|
|
129
|
+
* (WP-64 fix, spec.md E4: silent data loss is worse than a slightly slower
|
|
130
|
+
* decode). Before this fix, the sample was always decoded as UTF-8
|
|
131
|
+
* regardless of `classifyBinarySample`'s detected `encoding`, which produced
|
|
132
|
+
* mojibake for BOM-less UTF-16 files (their bytes are ~50% NUL under a
|
|
133
|
+
* UTF-8 reading) and, worse, fed that mojibake into `detectLanguage`'s
|
|
134
|
+
* shebang sniff for extensionless scripts — silently misclassifying a
|
|
135
|
+
* legitimate `#!/usr/bin/env python3` UTF-16 script as unknown/no language.
|
|
136
|
+
*
|
|
137
|
+
* UTF-16 (LE/BE, whether BOM-confirmed or detected BOM-less by
|
|
138
|
+
* `detectUtf16WithoutBom`) decodes under its own `TextDecoder` label so the
|
|
139
|
+
* BOM — if present — is consumed correctly. Every other guess (`utf8`,
|
|
140
|
+
* `ascii`, `unknown`) falls back to UTF-8 non-fatal decoding, which is a
|
|
141
|
+
* safe superset of ASCII and the correct default per spec.md.
|
|
142
|
+
*/
|
|
143
|
+
function decodeSampleText(sample, encoding) {
|
|
144
|
+
try {
|
|
145
|
+
if (encoding === "utf16le" || encoding === "utf16be") {
|
|
146
|
+
return new TextDecoder(encoding === "utf16le" ? "utf-16le" : "utf-16be", {
|
|
147
|
+
fatal: false,
|
|
148
|
+
}).decode(sample);
|
|
149
|
+
}
|
|
150
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(sample);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return "";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function processCandidate(config, candidate) {
|
|
157
|
+
const stat = candidate.stat ?? (await config.fs.stat(candidate.absolutePath, portOptions(config.signal)));
|
|
158
|
+
if (stat === null) {
|
|
159
|
+
config.onSkip?.({
|
|
160
|
+
relativePath: candidate.relativePath,
|
|
161
|
+
reason: "unreadable",
|
|
162
|
+
detail: "vanished before stat",
|
|
163
|
+
});
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
if (stat.sizeBytes > config.maxFileSizeBytes) {
|
|
167
|
+
config.onSkip?.({ relativePath: candidate.relativePath, reason: "size-limit" });
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const bytes = await config.fs.readFile(candidate.absolutePath, portOptions(config.signal));
|
|
171
|
+
const sample = bytes.subarray(0, Math.min(bytes.length, config.sampleBytes));
|
|
172
|
+
const binary = classifyBinarySample(sample, {
|
|
173
|
+
nonPrintableThreshold: config.nonPrintableThreshold,
|
|
174
|
+
});
|
|
175
|
+
if (binary.isBinary) {
|
|
176
|
+
config.onSkip?.({ relativePath: candidate.relativePath, reason: "binary" });
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const sampleText = decodeSampleText(sample, binary.encoding);
|
|
180
|
+
const language = detectLanguage(candidate.relativePath, sampleText);
|
|
181
|
+
// Die Textklasse steht eine Zeile weiter oben schon fest und kostet nichts:
|
|
182
|
+
// sie ist rein pfadbasiert. Ohne sie entschied die Einzelzeilenregel auch
|
|
183
|
+
// ueber Prosa — und ob sie zuschlug, haing daran, ob die erste lange Zeile
|
|
184
|
+
// zufaellig in die 8-KB-Stichprobe fiel.
|
|
185
|
+
const isMinified = detectMinified(sample, undefined, classifyTextFormat(candidate.relativePath));
|
|
186
|
+
const contentHash = await config.hasher.hashFile(candidate.absolutePath, stat.sizeBytes, portOptions(config.signal));
|
|
187
|
+
return {
|
|
188
|
+
relativePath: candidate.relativePath,
|
|
189
|
+
absolutePath: candidate.absolutePath,
|
|
190
|
+
sizeBytes: stat.sizeBytes,
|
|
191
|
+
mtimeEpochMs: stat.mtimeEpochMs,
|
|
192
|
+
contentHash,
|
|
193
|
+
languageId: language.languageId,
|
|
194
|
+
languageDetectedBy: language.detectedBy,
|
|
195
|
+
isMinified,
|
|
196
|
+
encoding: binary.encoding,
|
|
197
|
+
hasBom: binary.hasBom,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const DEFAULT_MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
|
201
|
+
const DEFAULT_SAMPLE_BYTES = 8192;
|
|
202
|
+
const DEFAULT_CONCURRENCY = 8;
|
|
203
|
+
const DEFAULT_NON_PRINTABLE_THRESHOLD = 0.3;
|
|
204
|
+
/**
|
|
205
|
+
* Discovers files under `options.repoRoot`, applying ignore rules and
|
|
206
|
+
* symlink safety, and yields a `ScannedFileV1` for every file accepted for
|
|
207
|
+
* downstream analysis. Excluded/skipped paths are reported via `onSkip`,
|
|
208
|
+
* never yielded. Respects `options.signal` throughout.
|
|
209
|
+
*/
|
|
210
|
+
export async function* discoverFiles(options) {
|
|
211
|
+
const maxFileSizeBytes = options.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE_BYTES;
|
|
212
|
+
const sampleBytes = options.sampleBytes ?? DEFAULT_SAMPLE_BYTES;
|
|
213
|
+
const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;
|
|
214
|
+
const nonPrintableThreshold = options.nonPrintableThreshold ?? DEFAULT_NON_PRINTABLE_THRESHOLD;
|
|
215
|
+
const gitIgnoreFileName = options.ignore?.gitIgnoreFileName ?? ".gitignore";
|
|
216
|
+
const untangleIgnoreFileName = options.ignore?.untangleIgnoreFileName ?? ".untangleignore";
|
|
217
|
+
options.signal?.throwIfAborted();
|
|
218
|
+
// Step 2 (architecture.md §23.1): lexical check before any I/O.
|
|
219
|
+
assertWithinWorkspace(options.repoRoot, options.roots);
|
|
220
|
+
// Step 1+3: canonically resolve the roots themselves too, not just the
|
|
221
|
+
// repo root and symlink targets encountered later — a root path can
|
|
222
|
+
// itself contain an unresolved symlink component (e.g. macOS's
|
|
223
|
+
// `/var` -> `/private/var`), and comparing a realpath-resolved candidate
|
|
224
|
+
// against a *literal* root would then spuriously look like an escape.
|
|
225
|
+
const resolvedRoots = await Promise.all(options.roots.map((root) => options.fs.realPath(root, portOptions(options.signal))));
|
|
226
|
+
const rootReal = await options.fs.realPath(options.repoRoot, portOptions(options.signal));
|
|
227
|
+
assertWithinWorkspace(rootReal, resolvedRoots);
|
|
228
|
+
const context = {
|
|
229
|
+
fs: options.fs,
|
|
230
|
+
ignoreEngine: createIgnoreEngine(options.ignore),
|
|
231
|
+
roots: resolvedRoots,
|
|
232
|
+
visitedRealDirs: new Set([rootReal]),
|
|
233
|
+
gitIgnoreFileName,
|
|
234
|
+
untangleIgnoreFileName,
|
|
235
|
+
onSkip: options.onSkip,
|
|
236
|
+
signal: options.signal,
|
|
237
|
+
};
|
|
238
|
+
const candidates = walkDirectory(context, { relativePath: "", absolutePath: rootReal });
|
|
239
|
+
const processConfig = {
|
|
240
|
+
fs: options.fs,
|
|
241
|
+
maxFileSizeBytes,
|
|
242
|
+
sampleBytes,
|
|
243
|
+
nonPrintableThreshold,
|
|
244
|
+
hasher: options.hasher ?? createPortContentHasher(options.fs),
|
|
245
|
+
onSkip: options.onSkip,
|
|
246
|
+
signal: options.signal,
|
|
247
|
+
};
|
|
248
|
+
for await (const result of mapConcurrent(candidates, concurrency, (candidate) => processCandidate(processConfig, candidate), options.signal)) {
|
|
249
|
+
if (result !== null) {
|
|
250
|
+
yield result;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
//# sourceMappingURL=discovery.js.map
|