@untangle-code/scanner 0.0.0-stage → 0.1.1
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
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
|
-
#
|
|
1
|
+
# @untangle-code/scanner
|
|
2
2
|
|
|
3
|
-
|
|
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"}
|