@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,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":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAU/E,4EAA4E;AAC5E,gDAAgD;AAChD,EAAE;AACF,gFAAgF;AAChF,8EAA8E;AAC9E,6EAA6E;AAC7E,8EAA8E;AAC9E,4EAA4E;AAC5E,yEAAyE;AACzE,0EAA0E;AAC1E,2EAA2E;AAC3E,uEAAuE;AACvE,4EAA4E;AAC5E,0EAA0E;AAC1E,qEAAqE;AACrE,mDAAmD;AACnD,MAAM,SAAS,GAAuB;IACpC,EAAE,KAAK,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE;IAC/C,EAAE,KAAK,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE;IAC5C,EAAE,KAAK,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE;CAC7C,CAAC;AAEF,SAAS,SAAS,CAAC,MAAkB;IAKnC,KAAK,MAAM,IAAI,IAAI,SAAS,EAAE,CAAC;QAC7B,IAAI,MAAM,CAAC,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC;YACtC,SAAS;QACX,CAAC;QACD,IAAI,OAAO,GAAG,IAAI,CAAC;QACnB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;YAC9C,IAAI,MAAM,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;gBAChC,OAAO,GAAG,KAAK,CAAC;gBAChB,MAAM;YACR,CAAC;QACH,CAAC;QACD,IAAI,OAAO,EAAE,CAAC;YACZ,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC;QAC9E,CAAC;IACH,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;AAC3D,CAAC;AAED,qGAAqG;AACrG,SAAS,yBAAyB,CAAC,IAAY;IAC7C,OAAO,CACL,IAAI,IAAI,IAAI;QACZ,IAAI,KAAK,IAAI;QACb,IAAI,KAAK,IAAI;QACb,CAAC,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,CAAC;QAC9B,IAAI,KAAK,IAAI,CACd,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;GAWG;AACH,SAAS,iBAAiB,CACxB,IAAgB,EAChB,QAA+B,EAC/B,SAAiB,EACjB,MAAe;IAEf,2EAA2E;IAC3E,sEAAsE;IACtE,sEAAsE;IACtE,wEAAwE;IACxE,MAAM,IAAI,GAAG,IAAI,WAAW,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,UAAU,EAAE;QAC7E,KAAK,EAAE,KAAK;KACb,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAEhB,IAAI,WAAW,GAAG,KAAK,CAAC;IACxB,IAAI,YAAY,GAAG,CAAC,CAAC;IACrB,IAAI,SAAS,GAAG,CAAC,CAAC;IAClB,KAAK,MAAM,IAAI,IAAI,IAAI,EAAE,CAAC;QACxB,SAAS,IAAI,CAAC,CAAC;QACf,MAAM,SAAS,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QAC3C,IAAI,SAAS,KAAK,CAAC,EAAE,CAAC;YACpB,WAAW,GAAG,IAAI,CAAC;YACnB,SAAS;QACX,CAAC;QACD,IAAI,SAAS,GAAG,KAAK,IAAI,yBAAyB,CAAC,SAAS,CAAC,EAAE,CAAC;YAC9D,YAAY,IAAI,CAAC,CAAC;QACpB,CAAC;IACH,CAAC;IAED,MAAM,iBAAiB,GAAG,SAAS,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,YAAY,GAAG,SAAS,CAAC;IACzE,MAAM,QAAQ,GAAG,WAAW,IAAI,iBAAiB,GAAG,SAAS,CAAC;IAE9D,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,iBAAiB,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;AACxE,CAAC;AAED,8EAA8E;AAC9E,EAAE;AACF,6EAA6E;AAC7E,yEAAyE;AACzE,6EAA6E;AAC7E,2EAA2E;AAC3E,mEAAmE;AACnE,4EAA4E;AAC5E,4EAA4E;AAC5E,wEAAwE;AACxE,mEAAmE;AACnE,+BAA+B;AAC/B,EAAE;AACF,uEAAuE;AACvE,EAAE;AACF,0EAA0E;AAC1E,yEAAyE;AACzE,2EAA2E;AAC3E,4EAA4E;AAC5E,2EAA2E;AAC3E,mEAAmE;AACnE,0EAA0E;AAC1E,0EAA0E;AAC1E,4CAA4C;AAC5C,qEAAqE;AACrE,wEAAwE;AACxE,gEAAgE;AAChE,oEAAoE;AACpE,0EAA0E;AAC1E,6DAA6D;AAC7D,EAAE;AACF,iFAAiF;AACjF,0EAA0E;AAC1E,4EAA4E;AAC5E,2EAA2E;AAC3E,wEAAwE;AACxE,MAAM,gCAAgC,GAAG,EAAE,CAAC;AAC5C,MAAM,mCAAmC,GAAG,GAAG,CAAC;AAChD,MAAM,mCAAmC,GAAG,GAAG,CAAC;AAChD,MAAM,mCAAmC,GAAG,GAAG,CAAC;AAEhD;;;;;;GAMG;AACH,SAAS,qBAAqB,CAAC,MAAkB;IAC/C,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAChD,IAAI,MAAM,CAAC,MAAM,GAAG,gCAAgC,IAAI,SAAS,KAAK,CAAC,EAAE,CAAC;QACxE,OAAO,IAAI,CAAC;IACd,CAAC;IAED,IAAI,UAAU,GAAG,CAAC,CAAC,CAAC,0DAA0D;IAC9E,IAAI,SAAS,GAAG,CAAC,CAAC,CAAC,yDAAyD;IAC5E,MAAM,UAAU,GAAG,SAAS,GAAG,CAAC,CAAC;IACjC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,UAAU,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACvC,IAAI,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC;YACpB,UAAU,IAAI,CAAC,CAAC;QAClB,CAAC;QACD,IAAI,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC;YACxB,SAAS,IAAI,CAAC,CAAC;QACjB,CAAC;IACH,CAAC;IAED,MAAM,SAAS,GAAG,UAAU,GAAG,SAAS,CAAC;IACzC,MAAM,QAAQ,GAAG,SAAS,GAAG,SAAS,CAAC;IAEvC,IAAI,SAAS,GAAiC,IAAI,CAAC;IACnD,IACE,QAAQ,IAAI,mCAAmC;QAC/C,SAAS,IAAI,mCAAmC,EAChD,CAAC;QACD,SAAS,GAAG,SAAS,CAAC;IACxB,CAAC;SAAM,IACL,SAAS,IAAI,mCAAmC;QAChD,QAAQ,IAAI,mCAAmC,EAC/C,CAAC;QACD,SAAS,GAAG,SAAS,CAAC;IACxB,CAAC;IACD,IAAI,SAAS,KAAK,IAAI,EAAE,CAAC;QACvB,OAAO,IAAI,CAAC;IACd,CAAC;IAED,2EAA2E;IAC3E,wEAAwE;IACxE,wEAAwE;IACxE,4DAA4D;IAC5D,MAAM,IAAI,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC;IAC5C,MAAM,IAAI,GAAG,IAAI,WAAW,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,UAAU,EAAE;QAC9E,KAAK,EAAE,KAAK;KACb,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAEhB,IAAI,SAAS,GAAG,CAAC,CAAC;IAClB,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,MAAM,IAAI,IAAI,IAAI,EAAE,CAAC;QACxB,KAAK,IAAI,CAAC,CAAC;QACX,MAAM,SAAS,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QAC3C,IAAI,SAAS,KAAK,CAAC,EAAE,CAAC;YACpB,SAAS;QACX,CAAC;QACD,IAAI,SAAS,KAAK,MAAM,EAAE,CAAC;YACzB,qEAAqE;YACrE,4DAA4D;YAC5D,SAAS;QACX,CAAC;QACD,IAAI,SAAS,GAAG,KAAK,IAAI,yBAAyB,CAAC,SAAS,CAAC,EAAE,CAAC;YAC9D,SAAS;QACX,CAAC;QACD,SAAS,IAAI,CAAC,CAAC;IACjB,CAAC;IAED,IAAI,KAAK,KAAK,CAAC,IAAI,SAAS,GAAG,KAAK,GAAG,mCAAmC,EAAE,CAAC;QAC3E,OAAO,IAAI,CAAC;IACd,CAAC;IAED,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,oBAAoB,CAClC,MAAkB,EAClB,OAA4C;IAE5C,MAAM,SAAS,GAAG,OAAO,EAAE,qBAAqB,IAAI,GAAG,CAAC;IACxD,MAAM,GAAG,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC;IAC9B,MAAM,IAAI,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;IAE/D,IAAI,GAAG,CAAC,MAAM,IAAI,CAAC,GAAG,CAAC,QAAQ,KAAK,SAAS,IAAI,GAAG,CAAC,QAAQ,KAAK,SAAS,CAAC,EAAE,CAAC;QAC7E,OAAO,iBAAiB,CAAC,IAAI,EAAE,GAAG,CAAC,QAAQ,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC;IAChE,CAAC;IAED,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC;QAChB,MAAM,YAAY,GAAG,qBAAqB,CAAC,MAAM,CAAC,CAAC;QACnD,IAAI,YAAY,KAAK,IAAI,EAAE,CAAC;YAC1B,OAAO,iBAAiB,CAAC,MAAM,EAAE,YAAY,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC;QACnE,CAAC;IACH,CAAC;IAED,IAAI,WAAW,GAAG,KAAK,CAAC;IACxB,IAAI,YAAY,GAAG,CAAC,CAAC;IACrB,IAAI,OAAO,GAAG,KAAK,CAAC;IACpB,KAAK,MAAM,IAAI,IAAI,IAAI,EAAE,CAAC;QACxB,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC;YACf,WAAW,GAAG,IAAI,CAAC;YACnB,SAAS;QACX,CAAC;QACD,IAAI,yBAAyB,CAAC,IAAI,CAAC,EAAE,CAAC;YACpC,YAAY,IAAI,CAAC,CAAC;QACpB,CAAC;aAAM,IAAI,IAAI,IAAI,IAAI,EAAE,CAAC;YACxB,OAAO,GAAG,IAAI,CAAC;QACjB,CAAC;IACH,CAAC;IAED,MAAM,iBAAiB,GAAG,IAAI,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,YAAY,GAAG,IAAI,CAAC,MAAM,CAAC;IAC7E,MAAM,QAAQ,GAAG,WAAW,IAAI,iBAAiB,GAAG,SAAS,CAAC;IAE9D,MAAM,QAAQ,GAAsB,GAAG,CAAC,MAAM;QAC5C,CAAC,CAAC,GAAG,CAAC,QAAQ;QACd,CAAC,CAAC,QAAQ;YACR,CAAC,CAAC,SAAS;YACX,CAAC,CAAC,OAAO;gBACP,CAAC,CAAC,MAAM;gBACR,CAAC,CAAC,OAAO,CAAC;IAEhB,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,iBAAiB,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC;AACpF,CAAC;AAaD;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAC5B,MAAkB,EAClB,UAA+B,EAC/B,UAAkC;IAElC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,sBAAsB,GAC1B,UAAU,EAAE,sBAAsB,IAAI,qBAAqB,CAAC,sBAAsB,CAAC;IACrF,MAAM,4BAA4B,GAChC,UAAU,EAAE,4BAA4B,IAAI,qBAAqB,CAAC,0BAA0B,CAAC;IAE/F,IAAI,UAAU,GAAG,CAAC,CAAC;IACnB,IAAI,WAAW,GAAG,CAAC,CAAC;IACpB,IAAI,WAAW,GAAG,CAAC,CAAC;IACpB,KAAK,MAAM,IAAI,IAAI,MAAM,EAAE,CAAC;QAC1B,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;YAClB,UAAU,IAAI,CAAC,CAAC;YAChB,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC;YACjD,WAAW,GAAG,CAAC,CAAC;QAClB,CAAC;aAAM,CAAC;YACN,WAAW,IAAI,CAAC,CAAC;QACnB,CAAC;IACH,CAAC;IACD,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC;IAEjD,MAAM,mBAAmB,GAAG,MAAM,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC;IACpE,8EAA8E;IAC9E,6EAA6E;IAC7E,yEAAyE;IACzE,sCAAsC;IACtC,OAAO,eAAe,CACpB,EAAE,aAAa,EAAE,WAAW,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,EACtE,EAAE,UAAU,EAAE,UAAU,IAAI,IAAI,EAAE,EAClC,EAAE,sBAAsB,EAAE,0BAA0B,EAAE,4BAA4B,EAAE,CACrF,CAAC;AACJ,CAAC"}
@@ -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