@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
package/NOTICE ADDED
@@ -0,0 +1,90 @@
1
+ # NOTICE
2
+
3
+ Copyright (C) 2026 Luke Bölling and contributors.
4
+
5
+ This attribution covers original Untangle work. Contributors retain copyright in
6
+ their contributions. Third-party packages, grammars and other materials retain
7
+ their own copyright holders, licenses and notices; this line does not replace them.
8
+
9
+ ## License — binding decision
10
+
11
+ Untangle is licensed under the **GNU Affero General Public License, Version 3
12
+ (AGPL-3.0-only)**. See [`LICENSE`](./LICENSE).
13
+
14
+ This decision is **final, not provisional**, and was explicitly instructed by
15
+ the project owner. No contributor, agent or tool may change it without a new
16
+ explicit instruction.
17
+
18
+ The repository-wide license covers Untangle's first-party source code, scripts,
19
+ tests, fixtures, configuration, documentation, website and brand artwork, including
20
+ the logo and 3D yarn illustrations. Every workspace `package.json` declares
21
+ `"license": "AGPL-3.0-only"`. There is no separately licensed enterprise component
22
+ or separate permissive license for documentation or brand assets.
23
+
24
+ Third-party code, embedded grammars, dependency assets and their notices retain
25
+ their original licenses. Their inclusion does not relicense those works. The
26
+ repository license does not grant trademark rights or change the license of a
27
+ repository analyzed with Untangle. See the repository's `docs/LICENSING.md` for
28
+ scope and verification details.
29
+
30
+ This resolves the license question left open in `vision.md` §14 and completes
31
+ `product.md` §16, item 2.
32
+
33
+ ## Rationale
34
+
35
+ - **Network copyleft fits the intended product model.** AGPL §13 sets source
36
+ availability requirements for modified versions used through a computer network.
37
+ The current local CLI/stdio-MCP entry and future shared-server modes share the
38
+ same license.
39
+ - **Local-first analysis has a clear boundary.** Running Untangle against a codebase
40
+ does not replace that codebase's own license with Untangle's license.
41
+ - **First-party components share one license.** Implemented components and future
42
+ official plugins use this repository's licensing policy. Planned integrations do
43
+ not establish implemented or published products.
44
+
45
+ ## Deliberately accepted consequence
46
+
47
+ The project deliberately chooses AGPL rather than a permissive embedding license.
48
+ Redistribution, modifications and combined works must be assessed against the
49
+ actual terms in `LICENSE`, including applicable Corresponding Source requirements.
50
+ No permissive exception, commercial dual license or special contract-only package
51
+ license is granted or promised by this notice.
52
+
53
+ ## Dependencies
54
+
55
+ Third-party runtime dependencies are checked against the allowlist in
56
+ `scripts/check-licenses.mjs`. Their licenses and copyright notices remain unchanged
57
+ and traceable through package `THIRD_PARTY_LICENSES.md` files, grammar provenance,
58
+ the lockfile and release SBOM. The allowlist includes MIT / Apache-2.0 / BSD / ISC
59
+ and other explicitly listed licenses; it is not a license grant for dependencies.
60
+
61
+ The allowlist in `scripts/check-licenses.mjs` applies to **dependencies**, not
62
+ workspace packages. Untangle's own packages carry AGPL-3.0-only and are excluded
63
+ from that check.
64
+
65
+ ## Clean room
66
+
67
+ Untangle is an independent implementation with its own data models, APIs, tool
68
+ names, tests and sources. Feature ideas may be derived from publicly observable
69
+ behavior and generally known techniques. Third-party source code is not copied,
70
+ including from AGPL projects even where the license would permit it.
71
+
72
+ ## Contribution terms
73
+
74
+ Pull requests are welcome under the repository's existing AGPL-3.0-only license.
75
+ No separate CLA, copyright assignment or DCO sign-off is required. Contributors
76
+ must have the right to submit their work under those terms. See CONTRIBUTING.md.
77
+
78
+ ## Release decisions
79
+
80
+ - [x] Copyright attribution: Luke Bölling, with contributors retaining their rights.
81
+ - [x] Contributions use AGPL-3.0-only without an additional CLA or DCO gate.
82
+
83
+ ## Open legal items before public beta
84
+
85
+ - [ ] Trademark search for the product name `Untangle`.
86
+ - [ ] Registration and legal review of the npm scope `@untangle-code`.
87
+ - [ ] Verify source availability and notices for each released network daemon/server
88
+ mode (WP-59). The current stdio MCP `untangle_about` already reports the license,
89
+ source repository and build commit when supplied; metadata alone does not prove
90
+ that a concrete deployed version's source is available.
package/README.md CHANGED
@@ -1,3 +1,304 @@
1
- # Temporary Holding Version
1
+ # @untangle-code/scanner
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Untangle's scanner (WP-05): recursive file discovery, ignore rules, safe symlink handling,
4
+ binary/minified/language classification, and the content-hash pipeline. An internal package
5
+ (`private: true`), consumed by the indexing pipeline (WP-13/WP-23), not directly by end users.
6
+
7
+ Normative sources: [`spec.md`](../../spec.md) §6.1–§6.3, §12.1 (takes precedence in a conflict),
8
+ [`architecture.md`](../../architecture.md) §6.4, §23.1,
9
+ [`docs/BUILD_PLAN.md`](../../docs/BUILD_PLAN.md) (WP-05).
10
+
11
+ Dependency: **only** `@untangle-code/domain` (`workspace:*`). No other Untangle packages or external
12
+ runtime dependencies (gitignore semantics are implemented locally; see decision 3).
13
+
14
+ <a id="architektur-zwei-entry-points"></a>
15
+
16
+ ## Architecture: two entry points
17
+
18
+ ```text
19
+ @untangle-code/scanner pure core: reads exclusively through FileSystemPort (domain),
20
+ never node:fs; testable against any port implementation
21
+ (e.g. MemoryFileSystem in its own tests)
22
+
23
+ @untangle-code/scanner/node-fs Node adapter: NodeFileSystem (FileSystemPort over node:fs/promises)
24
+ and createNodeStreamingHasher (actual streaming hashing)
25
+ ```
26
+
27
+ `src/index.ts` is the core's sole public export; `src/node-fs/index.ts` is a deliberately separate
28
+ second export (`package.json` `exports["./node-fs"]`). This is the exception allowed by the assignment
29
+ (“a NodeFileSystem adapter may live in this package and is exported separately”). The core therefore
30
+ remains testable without any `node:fs` dependency.
31
+
32
+ <a id="modulübersicht"></a>
33
+
34
+ ## Module overview
35
+
36
+ | Module | Contents | Source |
37
+ |---|---|---|
38
+ | `types.ts` | `ScannedFileV1`, `ScanSkipV1`, `ExclusionReason`, `ScanOptions`, `IgnoreConfig`, `IgnoreDecision`, `ContentHasher`, `BinaryClassification`: scanner-local contracts, see decision 1 | WP-05 decision |
39
+ | `discovery.ts` | `discoverFiles()`: recursive discovery as `AsyncGenerator<ScannedFileV1>`, symlink sandbox, connects ignore engine + classification + hashing | spec §6.1; arch §6.4, §23.1 |
40
+ | `ignore-engine.ts` | `IgnoreEngine`/`createIgnoreEngine()`: spec §6.2 priority cascade | spec §6.2, §6.3 |
41
+ | `gitignore-pattern.ts` | locally implemented gitignore glob compiler (`compilePattern`, `compilePatterns`, `evaluateLayer`) | spec §6.2 |
42
+ | `binary-classifier.ts` | `classifyBinarySample()` (NUL bytes/control-byte ratio/BOM), `detectMinified()` (line length/line-to-byte ratio) | spec §6.1 |
43
+ | `language-classifier.ts` | `detectLanguage()`: extension map + shebang/marker content sample for extensionless files | spec §6.1, §7.2 |
44
+ | `hashing.ts` | `hashBytes()`, `createPortContentHasher()`: raw-content SHA-256 as the sole source of unchanged detection | spec §12.1 |
45
+ | `semaphore.ts` | `Semaphore`: simple locally implemented counting semaphore | WP-05 assignment |
46
+ | `concurrency.ts` | `mapConcurrent()`: bounded parallel mapping over an (Async-)Iterable, with AbortSignal support | arch §13.1–§13.2 |
47
+ | `port-options.ts` | `portOptions()`: helper for `exactOptionalPropertyTypes` when forwarding `AbortSignal` | internal |
48
+ | `node-fs/node-file-system.ts` | `NodeFileSystem`: `FileSystemPort` adapter over `node:fs/promises` | arch §2.1 |
49
+ | `node-fs/node-streaming-hasher.ts` | `createNodeStreamingHasher()`: SHA-256 directly from `fs.createReadStream`, without a full read | spec §12.1 |
50
+ | `testing/memory-file-system.ts` | `MemoryFileSystem`: in-memory `FileSystemPort` for tests (not publicly exported) | test infrastructure |
51
+
52
+ <a id="verwendung"></a>
53
+
54
+ ## Usage
55
+
56
+ ```ts
57
+ import { discoverFiles } from "@untangle-code/scanner";
58
+ import { NodeFileSystem } from "@untangle-code/scanner/node-fs";
59
+
60
+ const fs = new NodeFileSystem();
61
+ for await (const file of discoverFiles({
62
+ fs,
63
+ roots: ["/abs/path/to/workspace"],
64
+ repoRoot: "/abs/path/to/workspace/repo",
65
+ ignore: { configInclude: repoConfig.include, configExclude: repoConfig.exclude },
66
+ })) {
67
+ // file: ScannedFileV1 { relativePath, absolutePath, sizeBytes, mtimeEpochMs,
68
+ // contentHash, languageId, languageDetectedBy, isMinified,
69
+ // encoding, hasBom }
70
+ }
71
+ ```
72
+
73
+ <a id="entscheidungen"></a>
74
+
75
+ ## Decisions
76
+
77
+ 1. **`ScannedFileV1` (formerly `FileSnapshotV1`) is fixed here, not a domain contract; its name
78
+ deliberately no longer says “FileSnapshot”.** Background: architecture.md §6.1 lists
79
+ `FileSnapshot` among domain entities. The scanner's former `FileSnapshotV1` name therefore
80
+ suggested that exact domain contract, while its own doc comment claimed otherwise: a
81
+ contradiction between the name and comment on one type. Resolution, checked against the actual
82
+ normative sources:
83
+ - The **contract register** in `docs/BUILD_PLAN.md` §3, whose heading identifies it as the
84
+ exhaustive list of what “MUST reside in `packages/domain`”, registers `FileManifestV1` for
85
+ file state under §3.3 “Core data contracts”, but **no** raw pre-analysis scan output.
86
+ `FileSnapshot`/`FileSnapshotV1` never appears there.
87
+ - architecture.md §10 (“Data model”) likewise defines **no** `FileSnapshot` interface, only
88
+ `FileManifestV1` (§10.3, identical to the existing `packages/domain/src/types/file.ts`,
89
+ including architecture.md §2.3 version fields `parserVersion`, `chunkerVersion`,
90
+ `generationId`). This suggests that §6.1 uses `FileSnapshot` as an informal/earlier name for
91
+ that concept, not a second independent domain contract alongside `FileManifestV1`.
92
+ - The type here is also semantically different from `FileManifestV1`: it is the **raw scan
93
+ candidate before parsing/chunking** (including `absolutePath`, `mtimeEpochMs` as a pure change
94
+ hint). It cannot meaningfully carry architecture.md §2.3 version fields:
95
+ `parserVersion`/`chunkerVersion`/`generationId` exist only after parsing and chunking.
96
+ Enforcing placeholder values here would be semantically wrong, not a genuine implementation
97
+ of §2.3.
98
+ - **Decision:** keep the type in `packages/scanner` (no move to domain: no missing domain
99
+ contract needs adding), but rename `FileSnapshotV1` to `ScannedFileV1`. The name must no longer
100
+ imply a domain contract that neither the register nor architecture.md §10 defines for this
101
+ shape. The shape remains an additively extensible WP-05 decision (see BUILD_PLAN R7) in
102
+ `src/types.ts`. Downstream packages (parser-registry, chunker) import it from
103
+ `@untangle-code/scanner`, not domain.
104
+
105
+ 2. **Split the spec §6.3 list across two priority levels.** spec §6.2 names “security and system
106
+ exclusions” (level 1, never overridable) and “default exclusions” (level 5, overridable) as
107
+ *separate* levels, while §6.3 supplies one shared list without assigning levels. Resolution:
108
+ `.git`, `.svn`, `.hg` (VCS *system* directories: raw data, potentially secrets in history, never
109
+ useful to index) form the non-overridable security/system level. The rest of §6.3 (`node_modules`,
110
+ `vendor`, `dist`, `build`, caches, virtual environments, …) forms the overridable default level.
111
+ §6.3 “generated assets” is concretized as `**/*.min.js`, `**/*.min.css`, `**/*.map` (the wording is
112
+ otherwise unspecified).
113
+
114
+ 3. **Gitignore semantics are implemented locally** (`gitignore-pattern.ts`), without the `ignore`
115
+ library. The multi-source priority cascade in spec §6.2 (5 sources with a fixed order, plus the
116
+ special rule that includes may override *only* default exclusions) needs more control over layer
117
+ composition than a single `.gitignore` parser provides. Supported: negation (`!pattern`),
118
+ directory patterns (`pattern/`), root anchors (`/pattern`), `**`, `*`, `?`, `[...]` character
119
+ classes; “last match wins” per layer, as in git.
120
+
121
+ 4. **Priority model: layers act only when they have an opinion.** Implemented as a cascade from
122
+ lowest to highest priority (Default → special Config-Include rule → `.gitignore` →
123
+ `.untangleignore` → Config-Exclude → Security). A level overrides the current decision only if
124
+ *some* pattern in that level actually matches; otherwise the lower-level decision stands. This
125
+ implements spec §6.2 exactly without reproducing git's tree-wide renegotiation. An important
126
+ consequence, also true for real `.gitignore`: an individual file within an excluded directory
127
+ cannot be re-included without reopening the directory itself through a directory pattern, e.g.
128
+ `"dist/"`. The special Config-Include rule from spec §6.2 item 6 additionally overrides
129
+ *exclusively* the default level, never `.gitignore`/`.untangleignore`/Config-Exclude, exactly as
130
+ the specification states.
131
+
132
+ 5. **Nested `.gitignore`/`.untangleignore` files are supported**, not just root files.
133
+ `IgnoreEngine.pushGitignoreLayer`/`popGitignoreLayer` (and the `untangleignore` counterparts)
134
+ form a stack maintained by `discovery.ts` while descending/ascending the directory tree.
135
+ Deeper files are pushed later, winning pattern conflicts (git's “closer file wins”). The
136
+ simplification relative to git: no separate tree-wide renegotiation when a parent directory is
137
+ already excluded (see decision 4). This is sufficient for the specification's practical cases
138
+ (ignore priority, negation, default exclusions) and covered by tests.
139
+
140
+ 6. **Symlink cycle detection uses globally visited real paths**, not just the active ancestor
141
+ chain, exactly as assigned (“cycle detection through visited real paths”). Every directory
142
+ reached through a symlink *or* normal traversal is added permanently to `visitedRealDirs` on
143
+ entry, never removed. A second symlink to the same already visited real path is skipped as
144
+ `symlink-cycle`. This guarantees termination with a single global `Set` check, without extra
145
+ `realpath` calls for ordinary non-symlink directories. Deliberate, documented, tested side
146
+ effect: two *independent* symlinks to one shared target directory are scanned only the first
147
+ time; the second is treated like a cycle (deduplication). This is acceptable for codebase
148
+ discovery, where duplicate indexed content is undesirable anyway.
149
+
150
+ 7. **`FileSystemPort.realPath` is also applied to `roots` themselves**, not only symlink targets
151
+ encountered during the walk. A workspace root may itself contain an unresolved symlink segment
152
+ (e.g. macOS `/var` → `/private/var`, `/tmp` → `/private/tmp`). Without resolution,
153
+ `assertWithinWorkspace` would compare a realpath-resolved candidate against an *unresolved*
154
+ root and falsely report an escape. An integration test on the real filesystem
155
+ (`node-fs/discovery.integration.test.ts`) covers precisely this case.
156
+
157
+ 8. **Streaming hashing is a Node adapter capability, not a core feature.** The domain
158
+ `FileSystemPort.readFile` supplies only full reads (`Uint8Array`), no range/stream API; true
159
+ streaming therefore cannot be built against the abstract port. The core hashes the buffer
160
+ already read for the binary/minified/language sample (`createPortContentHasher`, one read per
161
+ file, capped by the size limit). `createNodeStreamingHasher()` (from `./node-fs`) instead hashes
162
+ directly from `fs.createReadStream`, never holding the entire file in memory. This is the
163
+ assigned “streaming hashing for large files” capability, usable as an interchangeable
164
+ `ContentHasher` through `ScanOptions.hasher`.
165
+
166
+ 9. **The size limit applies strictly before a full read.** `processCandidate` calls `fs.stat()`
167
+ and compares `sizeBytes` to `maxFileSizeBytes` before any `fs.readFile()`. A test
168
+ (`discovery.test.ts`, “rejects an oversized file via stat, without ever calling readFile”)
169
+ proves this through a `FileSystemPort` that logs every `readFile` call.
170
+
171
+ 10. **Binary classification counts only actual control bytes as “non-printable”**, not the entire
172
+ `0x80`–`0xFF` byte band. Otherwise every UTF-8 non-ASCII source file (umlauts, CJK, emoji in
173
+ comments/strings) would falsely be classified as binary. Tab (`0x09`), LF (`0x0A`), and CR
174
+ (`0x0D`) are excluded from the control-byte count.
175
+
176
+ 11. **BOM-detected UTF-16 bodies are classified after decoding, not byte by byte.** Fixed defect:
177
+ `classifyBinarySample` detected a UTF-16 BOM (LE/BE) and reported the encoding correctly, then
178
+ still applied the raw “NUL byte ⇒ binary” rule to UTF-16 bytes. A `0x00` byte is normal for each
179
+ ASCII/BMP character (e.g. `[0xff,0xfe,0x61,0x00, 0x62,0x00]`, text file “ab”, was rejected as
180
+ binary, violating E4). Fix: `classifyUtf16Body` decodes with
181
+ `TextDecoder("utf-16le"|"utf-16be", {fatal:false})` and checks actual characters (code point
182
+ `=== 0` or actual control characters `< 0x100`) rather than raw bytes. A `0x00` half of an ASCII
183
+ encoding no longer counts as NUL; actual concentrations of NUL/control characters still do.
184
+ `binary-classifier.test.ts` covers both directions (legitimate UTF-16 text stays
185
+ `isBinary:false`; real embedded NUL or a high control-character ratio stays `isBinary:true`).
186
+
187
+ Correction (wave 2, see item 12): an adversarial reviewer disproved this item's original “no
188
+ fix needed” assessment for BOM-less UTF-16 detection. Real BOM-less UTF-16LE/UTF-16BE prose
189
+ triggered the same raw “NUL byte ⇒ binary” rule as the fixed BOM case above and silently
190
+ disappeared from the index (E4 violation). This false negative outweighs a heuristic's false
191
+ positive risk; see item 12 for the fix. CRLF and lone CR already worked before this fix
192
+ (Tab/LF/CR are explicitly excluded from control-byte counting; see item 10 and
193
+ `it("tolerates tabs, LF and CR …")`). UTF-32 deliberately remains outside this fix (see item 12
194
+ and “Known limits”). A latent consequence of missing UTF-32 support is documented in the code
195
+ (`BOM_SPECS` comment in `binary-classifier.ts`): a UTF-32LE BOM (`FF FE 00 00 …`) still matches
196
+ the 2-byte UTF-16LE prefix and is misinterpreted as `"utf16le"` with a 2-byte BOM length. The
197
+ resulting body reliably decodes to real NUL characters (each UTF-32 code unit contributes two
198
+ extra `0x00` bytes), so the sample still becomes `isBinary:true` (existing behavior, not a
199
+ regression from this fix).
200
+
201
+ 12. **BOM-less UTF-16 detection uses alternating NUL bytes plus printability validation.** Fixed
202
+ defect (wave 2): `classifyBinarySample` recognized UTF-16 text only with a BOM. Without one,
203
+ `detectBom` returned `hasBom:false`; code fell back to the raw byte loop, saw every second byte
204
+ as `0x00` (one half of every 16-bit Basic-Latin code unit), and falsely classified plain BOM-less
205
+ UTF-16LE/UTF-16BE prose as binary. An E4 violation (real source silently disappears from the
206
+ index), demonstrated by an adversarial reviewer with an independent reproduction against
207
+ `dist/binary-classifier.js` (`"Hello, world! This is a normal UTF-16 text file..."` in BOM-less
208
+ UTF-16LE and UTF-16BE, both `{ isBinary: true, encoding: "unknown" }` before the fix).
209
+
210
+ Fix: `detectUtf16WithoutBom` (new in `binary-classifier.ts`) recognizes BOM-less UTF-16
211
+ candidates through **two** gates, **both** required. It is deliberately conservative to avoid
212
+ the false positive risk named in this item's earlier version:
213
+ - **Gate 1 (pattern):** from a minimum sample size of **32 bytes** (`UTF16_HEURISTIC_MIN_
214
+ SAMPLE_BYTES`, 16 code units), the NUL-byte rate is calculated across all byte pairs for
215
+ each parity (even vs. odd positions). One side must regularly reach **≥ 60%**
216
+ (`UTF16_HEURISTIC_DOMINANT_NULL_RATIO`), the typical Basic-Latin signature in 16-bit units,
217
+ while the opposite parity must stay **≤ 10%** (`UTF16_HEURISTIC_OPPOSITE_NULL_RATIO`). Only
218
+ then is a candidate encoding set (LE when NULs dominate odd positions, BE at even positions).
219
+ - **Gate 2 (validation):** only after gate 1 supplies a candidate is the sample decoded using
220
+ that encoding via `TextDecoder(..., {fatal:false})`. **≥ 90%**
221
+ (`UTF16_HEURISTIC_MIN_PRINTABLE_RATIO`) of resulting code units must be printable (NUL,
222
+ actual control characters `< 0x100`, and decoding-error marker `U+FFFD` are not printable).
223
+ This gate rejects a genuine binary file whose bytes happen to satisfy gate 1's parity pattern.
224
+
225
+ Only when **both** gates pass is the sample treated as UTF-16 text. `classifyBinarySample` then
226
+ uses the same `classifyUtf16Body` classification as the BOM case (`hasBom:false`, `encoding`
227
+ set to `"utf16le"`/`"utf16be"` rather than `"unknown"`, allowing downstream stages to decode
228
+ correctly). An actual embedded NUL or high control-character ratio therefore still produces
229
+ `isBinary:true`, exactly as in item 11's BOM case. Below the 32-byte minimum sample size,
230
+ existing conservative behavior (binary when in doubt) remains unchanged: parity rates are not
231
+ statistically reliable for very short samples.
232
+
233
+ `binary-classifier.test.ts` covers the reviewer's exact case (BOM-less UTF-16LE and UTF-16BE,
234
+ `isBinary:false`, correct `encoding`), an actual embedded NUL in the BOM-less case (still
235
+ `isBinary:true`), three false-positive cases with realistic binary profiles (PNG header, ELF
236
+ header, high-entropy/quasi-random data representing compressed/encrypted content; none
237
+ misclassified as UTF-16), and a very short BOM-less UTF-16LE sample below the 32-byte threshold
238
+ (still `isBinary:true`, documented existing behavior).
239
+
240
+ Same error class, not fixed (see “Known limits”): **UTF-32** (LE/BE) has structurally the same
241
+ problem, with *three* of each Basic-Latin code unit's four bytes being `0x00`. The raw loop
242
+ would likewise falsely reject BOM-less UTF-32 text as binary. `TextEncodingGuess` has no
243
+ `"utf32le"`/`"utf32be"` values at all (see item 11 and the `BOM_SPECS` comment in
244
+ `binary-classifier.ts`). Supporting UTF-32 (with and without BOM) is a separate, unassigned
245
+ extension of the `TextEncodingGuess` contract, not an extension of the heuristic built here.
246
+
247
+ 13. **Two tsconfigs**, as in domain: `tsconfig.json` (composite, emit to `dist/`, no tests) for the
248
+ build; `tsconfig.typecheck.json` (noEmit, includes tests) for `pnpm run typecheck`. Composite
249
+ projects do not permit `noEmit`.
250
+
251
+ <a id="verifikation"></a>
252
+
253
+ ## Verification
254
+
255
+ ```bash
256
+ pnpm --filter @untangle-code/scanner run typecheck # tsc, 0 errors (core + node-fs + tests)
257
+ pnpm --filter @untangle-code/scanner run test # vitest, 109 passing tests
258
+ pnpm --filter @untangle-code/scanner run build # emit to dist/, 0 errors
259
+ ```
260
+
261
+ Actually executed (see `verification` in the task report): `npx tsc --noEmit -p
262
+ tsconfig.typecheck.json` (0 errors), `npx tsc -p tsconfig.json` (0 errors), `npx vitest run
263
+ --reporter=verbose` (109/109 passing, 11 test files, including 24 tests in
264
+ `binary-classifier.test.ts`), plus the reviewer's exact reproduction rerun against freshly built
265
+ `dist/binary-classifier.js`. Both formerly failing cases now return `isBinary:false` with correct
266
+ `encoding`.
267
+
268
+ <a id="bekannte-grenzen-siehe-openissues-im-task-report"></a>
269
+
270
+ ## Known limits (see `openIssues` in the task report)
271
+
272
+ - No per-file configuration of `.gitignore`/`.untangleignore` filenames is wired beyond
273
+ `RepositoryConfig`. `IgnoreConfig.gitIgnoreFileName`/`untangleIgnoreFileName` exist but are not yet
274
+ populated from `RepositoryConfig` (caller/WP-13 responsibility).
275
+ - `MemoryFileSystem` (test infrastructure only) does not resolve symlinks in *non-terminal* path
276
+ segments (e.g. `/repo/link/deep/file.ts`, where `link` is the symlink). This suffices for its tests,
277
+ where symlinks are always resolved directly through their own path; documented in the file header.
278
+ - UTF-32 is not detected (no `TextEncodingGuess` value or `BOM_SPECS` entry). spec.md/architecture.md
279
+ do not require it, so no new implementation without a concrete assignment. A UTF-32LE BOM still
280
+ matches the shorter UTF-16LE prefix and is misinterpreted as `"utf16le"`, but the decoded body
281
+ reliably contains actual NULs (two extra `0x00` bytes per UTF-32 code unit), so the sample still
282
+ correctly reports `isBinary:true`. Existing behavior, documented rather than hidden (see the
283
+ `BOM_SPECS` comment in `binary-classifier.ts` and decision 11). **The same error class as the
284
+ BOM-less UTF-16 defect fixed in decision 12 also affects BOM-less UTF-32 text** (3 of 4 bytes per
285
+ code unit would be `0x00`). Checked during decision 12 but deliberately not fixed alongside it:
286
+ `TextEncodingGuess` does not know UTF-32 at all; UTF-32 support would be a separate contract
287
+ extension, not an expansion of the existing heuristic.
288
+
289
+ <a id="lizenz"></a>
290
+
291
+ ## License
292
+
293
+
294
+ **AGPL-3.0-only.** This package includes the complete license in [`LICENSE`](./LICENSE), the
295
+ license-choice rationale in [`NOTICE`](./NOTICE), and licenses for all third-party production
296
+ dependencies in [`THIRD_PARTY_LICENSES.md`](./THIRD_PARTY_LICENSES.md). All three ship in the npm
297
+ tarball: AGPL-3.0-only §4 and §5 require recipients to receive the license; a README alone is not
298
+ a copy of the license.
299
+
300
+ Local use alone does not trigger an obligation. Copyleft obligations apply to redistribution and
301
+ providing a network service (AGPL §13).
302
+
303
+ Corresponding TypeScript source ships under `src/` in the same tarball; the complete repository
304
+ is available at <https://github.com/lboel/untangle>.
@@ -0,0 +1,24 @@
1
+ # Third-party licenses — @untangle-code/scanner
2
+
3
+ Generated by `pnpm third-party-licenses` from this package's installed
4
+ production dependency tree: transitive `dependencies`, across
5
+ workspace edges, with no `devDependencies` included. The generator reads
6
+ each resolved package's `package.json` in `node_modules`; nothing is maintained by hand
7
+ or estimated.
8
+
9
+ Do not edit manually: `pnpm third-party-licenses --check` fails on drift,
10
+ and `pnpm check-publishable` rejects packages without a current file.
11
+
12
+ This file exists because `docs/BUILD_PLAN.md`, risk R6, makes
13
+ per-package `THIRD_PARTY_LICENSES.md` a release gate for npm publication.
14
+
15
+ Untangle itself is **AGPL-3.0-only** (see `LICENSE` and `NOTICE` in
16
+ this package). The packages below are third-party works; their own
17
+ licenses and copyright notices continue to apply unchanged. Workspace
18
+ packages (`@untangle-code/*`) are not listed here; they belong to the same
19
+ work and use the same AGPL-3.0-only license.
20
+
21
+ ## Result: no third-party dependencies
22
+
23
+ This package has no production dependencies outside the workspace.
24
+ The list is empty because none exist, not because they were not checked.
@@ -0,0 +1,45 @@
1
+ import type { TextFormatKind } from "@untangle-code/domain";
2
+ import type { BinaryClassification } from "./types.js";
3
+ /**
4
+ * Classifies a content sample as binary or text.
5
+ *
6
+ * A sample is binary when it contains a NUL byte or when the ratio of
7
+ * non-printable control bytes exceeds `nonPrintableThreshold` (default
8
+ * 0.3). Bytes in the 0x80–0xFF range are NOT counted as non-printable —
9
+ * valid UTF-8 text uses them for non-ASCII characters, and flagging them
10
+ * would misclassify ordinary internationalized source/comments as binary.
11
+ *
12
+ * When the sample carries a UTF-16 BOM, the body is decoded per the
13
+ * confirmed encoding and classified over actual characters instead
14
+ * (`classifyUtf16Body`) — see that function's doc comment for why the raw
15
+ * byte-level NUL rule does not apply there.
16
+ *
17
+ * Absent a BOM, `detectUtf16WithoutBom` still checks for a conservative,
18
+ * two-gate BOM-less UTF-16 signature (regular alternating NUL bytes at one
19
+ * parity, validated by decoding and requiring the result be overwhelmingly
20
+ * printable) before falling back to the raw byte-level rule — see that
21
+ * function's doc comment for the documented thresholds and rationale
22
+ * (spec.md E4: a false negative here silently drops real source text from
23
+ * the index).
24
+ */
25
+ export declare function classifyBinarySample(sample: Uint8Array, options?: {
26
+ nonPrintableThreshold?: number;
27
+ }): BinaryClassification;
28
+ export interface MinifiedHeuristics {
29
+ /**
30
+ * A single line at or above this byte length signals minification. Default 500.
31
+ * Does NOT apply to prose (`textFormat === "prose"`): a long Markdown table
32
+ * row is ordinary prose, not minification.
33
+ */
34
+ maxLineLengthThreshold?: number;
35
+ /** Average bytes/line at or above this signals minification (low line-to-byte ratio). Default 300. */
36
+ averageBytesPerLineThreshold?: number;
37
+ }
38
+ /**
39
+ * Heuristic minified-file detection ("sehr lange Zeilen, geringes
40
+ * Zeilen-zu-Byte-Verhaeltnis") from a raw content sample. Operates on
41
+ * newline bytes (0x0A) directly rather than decoding, so it is safe to run
42
+ * on the same sample used for binary classification.
43
+ */
44
+ export declare function detectMinified(sample: Uint8Array, heuristics?: MinifiedHeuristics, textFormat?: TextFormatKind | null): boolean;
45
+ //# sourceMappingURL=binary-classifier.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"binary-classifier.d.ts","sourceRoot":"","sources":["../src/binary-classifier.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAE5D,OAAO,KAAK,EAAE,oBAAoB,EAAqB,MAAM,YAAY,CAAC;AAsO1E;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,UAAU,EAClB,OAAO,CAAC,EAAE;IAAE,qBAAqB,CAAC,EAAE,MAAM,CAAA;CAAE,GAC3C,oBAAoB,CA2CtB;AAED,MAAM,WAAW,kBAAkB;IACjC;;;;OAIG;IACH,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,sGAAsG;IACtG,4BAA4B,CAAC,EAAE,MAAM,CAAC;CACvC;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,UAAU,EAClB,UAAU,CAAC,EAAE,kBAAkB,EAC/B,UAAU,CAAC,EAAE,cAAc,GAAG,IAAI,GACjC,OAAO,CAiCT"}