@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,157 @@
1
+ /**
2
+ * Ignore engine implementing the priority order of spec.md §6.2:
3
+ *
4
+ * 1. security/system excludes — never overridable
5
+ * 2. explicit config excludes — `RepositoryConfig.exclude`
6
+ * 3. `.untangleignore`
7
+ * 4. `.gitignore`
8
+ * 5. default excludes — spec.md §6.3
9
+ * 6. config includes override (5) only, never (1)-(4)
10
+ *
11
+ * Implementation model: layers are applied from lowest to highest priority
12
+ * (5 → 6 → 4 → 3 → 2 → 1); a layer only changes the running decision when
13
+ * it actually has an opinion (some pattern in it matched), otherwise the
14
+ * decision made by a lower-priority layer is kept. This makes "higher
15
+ * priority" mean exactly "wins whenever it has an opinion", matching the
16
+ * spec text without requiring gitignore-style whole-tree renegotiation.
17
+ *
18
+ * See README, "Entscheidungen" for the two points spec.md leaves implicit
19
+ * and that this engine fixes: (a) which of the §6.3 default-exclude names
20
+ * are promoted to the non-overridable "security/system" tier, and (b) that
21
+ * config includes are read as overriding *only* the default-exclude layer,
22
+ * per the literal wording of §6.2 item 6.
23
+ */
24
+ import {
25
+ compileGlobList,
26
+ compilePatterns,
27
+ evaluateLayer,
28
+ type CompiledPattern,
29
+ } from "./gitignore-pattern.js";
30
+ import type { ExclusionReason, IgnoreConfig, IgnoreDecision } from "./types.js";
31
+
32
+ /**
33
+ * Non-overridable system/VCS directories (spec.md §6.2 item 1). A subset of
34
+ * §6.3's list is promoted here rather than left in the overridable default
35
+ * tier — see README, "Entscheidungen" (decision 1).
36
+ */
37
+ const SECURITY_EXCLUDE_NAMES = [".git", ".svn", ".hg"];
38
+
39
+ /** Overridable default excludes (spec.md §6.3), minus the security/system tier above. */
40
+ const DEFAULT_EXCLUDE_NAMES = [
41
+ "node_modules",
42
+ "vendor",
43
+ "dist",
44
+ "build",
45
+ "out",
46
+ "coverage",
47
+ ".cache",
48
+ ".next",
49
+ ".nuxt",
50
+ "target",
51
+ ".gradle",
52
+ ".venv",
53
+ "venv",
54
+ "__pycache__",
55
+ ];
56
+
57
+ /** "generated assets" (spec.md §6.3) — common generated/minified/output file patterns. */
58
+ const DEFAULT_GENERATED_ASSET_PATTERNS = ["**/*.min.js", "**/*.min.css", "**/*.map"];
59
+
60
+ interface LayerFrame {
61
+ size: number;
62
+ }
63
+
64
+ /**
65
+ * Stateful ignore evaluator. `pushGitignoreLayer`/`popGitignoreLayer` (and
66
+ * the `untangleignore` equivalents) let a directory walker maintain, as a
67
+ * stack, exactly the nested-ignore-file patterns that apply along the
68
+ * current root-to-here path — deeper files are pushed later and therefore
69
+ * win ties within their own layer, matching git's "nearer file wins"
70
+ * behavior.
71
+ */
72
+ export class IgnoreEngine {
73
+ private readonly securityPatterns: CompiledPattern[];
74
+ private readonly defaultPatterns: CompiledPattern[];
75
+ private readonly configIncludePatterns: CompiledPattern[];
76
+ private readonly configExcludePatterns: CompiledPattern[];
77
+
78
+ private readonly gitignoreStack: CompiledPattern[] = [];
79
+ private readonly gitignoreFrames: LayerFrame[] = [];
80
+ private readonly untangleignoreStack: CompiledPattern[] = [];
81
+ private readonly untangleignoreFrames: LayerFrame[] = [];
82
+
83
+ public constructor(config?: IgnoreConfig) {
84
+ this.securityPatterns = compileGlobList(SECURITY_EXCLUDE_NAMES);
85
+ this.defaultPatterns = compileGlobList([
86
+ ...DEFAULT_EXCLUDE_NAMES,
87
+ ...DEFAULT_GENERATED_ASSET_PATTERNS,
88
+ ...(config?.additionalDefaultExcludes ?? []),
89
+ ]);
90
+ this.configIncludePatterns = compileGlobList(config?.configInclude ?? []);
91
+ this.configExcludePatterns = compileGlobList(config?.configExclude ?? []);
92
+ }
93
+
94
+ public pushGitignoreLayer(baseDir: string, content: string): void {
95
+ const patterns = compilePatterns(content, baseDir);
96
+ this.gitignoreFrames.push({ size: patterns.length });
97
+ this.gitignoreStack.push(...patterns);
98
+ }
99
+
100
+ public popGitignoreLayer(): void {
101
+ const frame = this.gitignoreFrames.pop();
102
+ if (frame !== undefined) {
103
+ this.gitignoreStack.length -= frame.size;
104
+ }
105
+ }
106
+
107
+ public pushUntangleIgnoreLayer(baseDir: string, content: string): void {
108
+ const patterns = compilePatterns(content, baseDir);
109
+ this.untangleignoreFrames.push({ size: patterns.length });
110
+ this.untangleignoreStack.push(...patterns);
111
+ }
112
+
113
+ public popUntangleIgnoreLayer(): void {
114
+ const frame = this.untangleignoreFrames.pop();
115
+ if (frame !== undefined) {
116
+ this.untangleignoreStack.length -= frame.size;
117
+ }
118
+ }
119
+
120
+ public decide(relativePath: string, isDirectory: boolean): IgnoreDecision {
121
+ let ignored = evaluateLayer(this.defaultPatterns, relativePath, isDirectory) ?? false;
122
+ let reason: ExclusionReason | undefined = ignored ? "default-exclude" : undefined;
123
+
124
+ if (evaluateLayer(this.configIncludePatterns, relativePath, isDirectory) === true) {
125
+ ignored = false;
126
+ reason = undefined;
127
+ }
128
+
129
+ const gitDecision = evaluateLayer(this.gitignoreStack, relativePath, isDirectory);
130
+ if (gitDecision !== undefined) {
131
+ ignored = gitDecision;
132
+ reason = gitDecision ? "gitignore" : undefined;
133
+ }
134
+
135
+ const untangleDecision = evaluateLayer(this.untangleignoreStack, relativePath, isDirectory);
136
+ if (untangleDecision !== undefined) {
137
+ ignored = untangleDecision;
138
+ reason = untangleDecision ? "untangleignore" : undefined;
139
+ }
140
+
141
+ if (evaluateLayer(this.configExcludePatterns, relativePath, isDirectory) === true) {
142
+ ignored = true;
143
+ reason = "config-exclude";
144
+ }
145
+
146
+ if (evaluateLayer(this.securityPatterns, relativePath, isDirectory) === true) {
147
+ ignored = true;
148
+ reason = "security";
149
+ }
150
+
151
+ return reason === undefined ? { ignored } : { ignored, reason };
152
+ }
153
+ }
154
+
155
+ export function createIgnoreEngine(config?: IgnoreConfig): IgnoreEngine {
156
+ return new IgnoreEngine(config);
157
+ }
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @untangle-code/scanner — recursive discovery, ignore rules, symlink
3
+ * safety, binary/language classification and the content-hash pipeline
4
+ * (spec.md §6.1–§6.3, §12.1; architecture.md §6.4, §23.1).
5
+ *
6
+ * Pure core: reads only through `FileSystemPort` (domain), never
7
+ * `node:fs`. A concrete Node adapter is published separately from
8
+ * `@untangle-code/scanner/node-fs` (see that module's docs) — importing it
9
+ * here would make this entry point Node-specific and untestable against
10
+ * arbitrary `FileSystemPort` implementations (e.g. an in-memory one).
11
+ */
12
+
13
+ // Scanner-local contracts (not domain contracts — see README, "Entscheidungen").
14
+ export * from "./types.js";
15
+
16
+ // Discovery entry point.
17
+ export { discoverFiles } from "./discovery.js";
18
+
19
+ // Ignore engine (spec.md §6.2 priority layering).
20
+ export { IgnoreEngine, createIgnoreEngine } from "./ignore-engine.js";
21
+ export {
22
+ compilePattern,
23
+ compilePatterns,
24
+ compileGlobList,
25
+ evaluateLayer,
26
+ type CompiledPattern,
27
+ } from "./gitignore-pattern.js";
28
+
29
+ // Content classification, usable standalone (e.g. for re-classifying a
30
+ // buffer already in memory, without going through discovery).
31
+ export {
32
+ classifyBinarySample,
33
+ detectMinified,
34
+ type MinifiedHeuristics,
35
+ } from "./binary-classifier.js";
36
+ export {
37
+ detectLanguage,
38
+ detectLanguageByExtension,
39
+ detectLanguageFromContent,
40
+ type LanguageDetectionResult,
41
+ } from "./language-classifier.js";
42
+
43
+ // Hash pipeline (spec.md §12.1).
44
+ export { hashBytes, createPortContentHasher } from "./hashing.js";
45
+
46
+ // Bounded-concurrency primitives (architecture.md §13.1–§13.2).
47
+ export { Semaphore } from "./semaphore.js";
48
+ export { mapConcurrent } from "./concurrency.js";
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Coarse language classification (spec.md §6.1, §7.2): an extension map for
3
+ * the normal case, plus a shebang/marker content sniff for extensionless
4
+ * files. This is a fast pre-classification for the scanner's own output —
5
+ * the authoritative language/adapter dispatch belongs to parser-registry
6
+ * (architecture.md §6.5), which is free to disagree.
7
+ */
8
+ import type { LanguageDetectionSource } from "./types.js";
9
+
10
+ // MVP + Public Beta languages (spec.md §7.2) plus common structural/text
11
+ // formats the scanner needs to tell apart from opaque binaries.
12
+ const EXTENSION_LANGUAGE_MAP: Readonly<Record<string, string>> = {
13
+ ts: "typescript",
14
+ mts: "typescript",
15
+ cts: "typescript",
16
+ tsx: "tsx",
17
+ js: "javascript",
18
+ mjs: "javascript",
19
+ cjs: "javascript",
20
+ jsx: "jsx",
21
+ py: "python",
22
+ pyi: "python",
23
+ pyw: "python",
24
+ go: "go",
25
+ java: "java",
26
+ kt: "kotlin",
27
+ kts: "kotlin",
28
+ cs: "csharp",
29
+ rs: "rust",
30
+ php: "php",
31
+ phtml: "php",
32
+ rb: "ruby",
33
+ // WP-86: die Parser-Registry fuehrt diese Endungen als Ruby beziehungsweise
34
+ // PHP und Swift. Fehlten sie hier, meldete der Scanner "unknown" fuer eine
35
+ // Datei, die der Adapter danach doch tief analysiert — zwei Aussagen ueber
36
+ // dieselbe Datei, von denen eine falsch ist.
37
+ rake: "ruby",
38
+ gemspec: "ruby",
39
+ swift: "swift",
40
+ c: "c",
41
+ h: "c",
42
+ cpp: "cpp",
43
+ cc: "cpp",
44
+ cxx: "cpp",
45
+ hpp: "cpp",
46
+ hh: "cpp",
47
+ hxx: "cpp",
48
+ json: "json",
49
+ jsonc: "json",
50
+ yaml: "yaml",
51
+ yml: "yaml",
52
+ toml: "toml",
53
+ md: "markdown",
54
+ mdx: "markdown",
55
+ css: "css",
56
+ scss: "scss",
57
+ less: "less",
58
+ html: "html",
59
+ htm: "html",
60
+ vue: "vue",
61
+ svelte: "svelte",
62
+ sh: "shell",
63
+ bash: "shell",
64
+ zsh: "shell",
65
+ ksh: "shell",
66
+ sql: "sql",
67
+ graphql: "graphql",
68
+ gql: "graphql",
69
+ proto: "protobuf",
70
+ xml: "xml",
71
+ };
72
+
73
+ /** Extension-based language guess; `undefined` for extensionless or unknown extensions. */
74
+ export function detectLanguageByExtension(relativePath: string): string | undefined {
75
+ const base = relativePath.slice(relativePath.lastIndexOf("/") + 1);
76
+ const dot = base.lastIndexOf(".");
77
+ // dot <= 0 covers "no extension" and dotfiles like ".gitignore".
78
+ if (dot <= 0) {
79
+ return undefined;
80
+ }
81
+ const ext = base.slice(dot + 1).toLowerCase();
82
+ return EXTENSION_LANGUAGE_MAP[ext];
83
+ }
84
+
85
+ interface ContentMarker {
86
+ pattern: RegExp;
87
+ languageId: string;
88
+ }
89
+
90
+ const SHEBANG_MARKERS: readonly ContentMarker[] = [
91
+ { pattern: /^#!.*\bnode\b/, languageId: "javascript" },
92
+ { pattern: /^#!.*\bdeno\b/, languageId: "typescript" },
93
+ { pattern: /^#!.*\bpython[0-9.]*\b/, languageId: "python" },
94
+ { pattern: /^#!.*\b(?:bash|sh|zsh|dash|ksh)\b/, languageId: "shell" },
95
+ { pattern: /^#!.*\bruby\b/, languageId: "ruby" },
96
+ { pattern: /^#!.*\bperl\b/, languageId: "perl" },
97
+ ];
98
+
99
+ const CONTENT_MARKERS: readonly ContentMarker[] = [
100
+ { pattern: /^<\?php/, languageId: "php" },
101
+ { pattern: /^<\?xml/, languageId: "xml" },
102
+ { pattern: /^\s*<!DOCTYPE html/i, languageId: "html" },
103
+ ];
104
+
105
+ /** Content-based language guess for extensionless files (shebang or leading markers). */
106
+ export function detectLanguageFromContent(text: string): string | undefined {
107
+ const head = text.slice(0, 256);
108
+ for (const marker of SHEBANG_MARKERS) {
109
+ if (marker.pattern.test(head)) {
110
+ return marker.languageId;
111
+ }
112
+ }
113
+ for (const marker of CONTENT_MARKERS) {
114
+ if (marker.pattern.test(head)) {
115
+ return marker.languageId;
116
+ }
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ export interface LanguageDetectionResult {
122
+ languageId: string;
123
+ detectedBy: LanguageDetectionSource;
124
+ }
125
+
126
+ /** Combines extension and content sniffing; `languageId` is "unknown" if neither matches. */
127
+ export function detectLanguage(relativePath: string, sampleText: string): LanguageDetectionResult {
128
+ const byExtension = detectLanguageByExtension(relativePath);
129
+ if (byExtension !== undefined) {
130
+ return { languageId: byExtension, detectedBy: "extension" };
131
+ }
132
+ const byContent = detectLanguageFromContent(sampleText);
133
+ if (byContent !== undefined) {
134
+ return { languageId: byContent, detectedBy: "content" };
135
+ }
136
+ return { languageId: "unknown", detectedBy: "unknown" };
137
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `@untangle-code/scanner/node-fs` — the Node.js-specific half of the
3
+ * scanner: a `FileSystemPort` adapter and a genuinely streaming
4
+ * `ContentHasher`. Kept out of the package's main entry point so that
5
+ * entry point stays testable against any `FileSystemPort` (e.g. an
6
+ * in-memory one) without pulling in `node:fs` (see README, "Entscheidungen").
7
+ */
8
+ export { NodeFileSystem } from "./node-file-system.js";
9
+ export { createNodeStreamingHasher } from "./node-streaming-hasher.js";
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Node.js implementation of `FileSystemPort` (domain). This is the only
3
+ * file in the package allowed to import `node:fs` — the core (`../index.ts`)
4
+ * stays testable against any port implementation, this adapter is what
5
+ * production code actually wires up.
6
+ */
7
+ import * as nodeFs from "node:fs/promises";
8
+ import * as nodePath from "node:path";
9
+
10
+ import type {
11
+ DirEntryV1,
12
+ FileStatV1,
13
+ FileSystemPort,
14
+ PortCallOptions,
15
+ } from "@untangle-code/domain";
16
+
17
+ interface NodeStatsLike {
18
+ isDirectory(): boolean;
19
+ isFile(): boolean;
20
+ isSymbolicLink(): boolean;
21
+ size: number;
22
+ mtimeMs: number;
23
+ }
24
+
25
+ function kindOf(stats: NodeStatsLike): FileStatV1["kind"] {
26
+ if (stats.isSymbolicLink()) {
27
+ return "symlink";
28
+ }
29
+ if (stats.isDirectory()) {
30
+ return "directory";
31
+ }
32
+ if (stats.isFile()) {
33
+ return "file";
34
+ }
35
+ return "other";
36
+ }
37
+
38
+ function toFileStat(path: string, stats: NodeStatsLike): FileStatV1 {
39
+ return {
40
+ path,
41
+ sizeBytes: stats.size,
42
+ mtimeEpochMs: Math.trunc(stats.mtimeMs),
43
+ kind: kindOf(stats),
44
+ };
45
+ }
46
+
47
+ function isNotFoundError(error: unknown): boolean {
48
+ return (
49
+ typeof error === "object" &&
50
+ error !== null &&
51
+ "code" in error &&
52
+ (error as { code?: unknown }).code === "ENOENT"
53
+ );
54
+ }
55
+
56
+ /** Production `FileSystemPort` adapter backed by `node:fs/promises`. */
57
+ export class NodeFileSystem implements FileSystemPort {
58
+ public async stat(path: string, options?: PortCallOptions): Promise<FileStatV1 | null> {
59
+ options?.signal?.throwIfAborted();
60
+ try {
61
+ const stats = await nodeFs.stat(path);
62
+ return toFileStat(path, stats);
63
+ } catch (error) {
64
+ if (isNotFoundError(error)) {
65
+ return null;
66
+ }
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ public async lstat(path: string, options?: PortCallOptions): Promise<FileStatV1 | null> {
72
+ options?.signal?.throwIfAborted();
73
+ try {
74
+ const stats = await nodeFs.lstat(path);
75
+ return toFileStat(path, stats);
76
+ } catch (error) {
77
+ if (isNotFoundError(error)) {
78
+ return null;
79
+ }
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ public async readFile(path: string, options?: PortCallOptions): Promise<Uint8Array> {
85
+ const buffer = await nodeFs.readFile(path, { signal: options?.signal });
86
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
87
+ }
88
+
89
+ public async readTextFile(path: string, options?: PortCallOptions): Promise<string> {
90
+ return await nodeFs.readFile(path, { encoding: "utf8", signal: options?.signal });
91
+ }
92
+
93
+ public async readDir(path: string, options?: PortCallOptions): Promise<DirEntryV1[]> {
94
+ options?.signal?.throwIfAborted();
95
+ const entries = await nodeFs.readdir(path, { withFileTypes: true });
96
+ return entries.map((entry) => {
97
+ const kind: DirEntryV1["kind"] = entry.isSymbolicLink()
98
+ ? "symlink"
99
+ : entry.isDirectory()
100
+ ? "directory"
101
+ : entry.isFile()
102
+ ? "file"
103
+ : "other";
104
+ return { name: entry.name, path: nodePath.join(path, entry.name), kind };
105
+ });
106
+ }
107
+
108
+ public async realPath(path: string, options?: PortCallOptions): Promise<string> {
109
+ options?.signal?.throwIfAborted();
110
+ return await nodeFs.realpath(path);
111
+ }
112
+
113
+ public async writeFile(path: string, data: Uint8Array, options?: PortCallOptions): Promise<void> {
114
+ await nodeFs.writeFile(path, data, { signal: options?.signal });
115
+ }
116
+
117
+ public async mkdir(
118
+ path: string,
119
+ options?: PortCallOptions & { recursive?: boolean },
120
+ ): Promise<void> {
121
+ options?.signal?.throwIfAborted();
122
+ await nodeFs.mkdir(path, { recursive: options?.recursive ?? false });
123
+ }
124
+
125
+ public async rename(from: string, to: string, options?: PortCallOptions): Promise<void> {
126
+ options?.signal?.throwIfAborted();
127
+ await nodeFs.rename(from, to);
128
+ }
129
+
130
+ public async remove(
131
+ path: string,
132
+ options?: PortCallOptions & { recursive?: boolean },
133
+ ): Promise<void> {
134
+ options?.signal?.throwIfAborted();
135
+ await nodeFs.rm(path, { recursive: options?.recursive ?? false, force: true });
136
+ }
137
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Streaming SHA-256 content hasher for the Node adapter.
3
+ *
4
+ * `FileSystemPort.readFile` (domain) only exposes whole-buffer reads, so
5
+ * the port-based default hasher (`createPortContentHasher`, in the pure
6
+ * core) must materialize a file's bytes to hash them. This implementation
7
+ * hashes straight off `fs.createReadStream` instead, without ever holding
8
+ * the whole file in memory — the genuine "streaming hashing for large
9
+ * files" the WP-05 order asks for (see README, "Entscheidungen").
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { createReadStream } from "node:fs";
13
+
14
+ import type { ContentHasher } from "../types.js";
15
+
16
+ /** `ContentHasher` that streams the file instead of buffering it whole. */
17
+ export function createNodeStreamingHasher(): ContentHasher {
18
+ return {
19
+ hashFile(absolutePath, _sizeBytes, options) {
20
+ return new Promise<string>((resolve, reject) => {
21
+ const hash = createHash("sha256");
22
+ const stream = createReadStream(absolutePath, { signal: options?.signal });
23
+ stream.on("data", (chunk: string | Buffer) => {
24
+ hash.update(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
25
+ });
26
+ stream.on("error", (error) => {
27
+ reject(error);
28
+ });
29
+ stream.on("end", () => {
30
+ resolve(hash.digest("hex"));
31
+ });
32
+ });
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Builds a `PortCallOptions` from an optional `AbortSignal` without ever
3
+ * setting `signal: undefined` explicitly — required because domain's
4
+ * `PortCallOptions` is checked under `exactOptionalPropertyTypes`.
5
+ */
6
+ import type { PortCallOptions } from "@untangle-code/domain";
7
+
8
+ export function portOptions(signal: AbortSignal | undefined): PortCallOptions | undefined {
9
+ return signal === undefined ? undefined : { signal };
10
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A minimal counting semaphore used to bound I/O parallelism during
3
+ * discovery (spec.md §6.1; architecture.md §13.1 "bounded queues").
4
+ *
5
+ * Deliberately hand-rolled per WP-05 order ("ein einfacher, eigenen
6
+ * Semaphor/Pool") instead of pulling in a scheduling dependency.
7
+ */
8
+ export class Semaphore {
9
+ private available: number;
10
+ private readonly waiters: Array<() => void> = [];
11
+
12
+ public constructor(concurrency: number) {
13
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
14
+ throw new RangeError(`Semaphore concurrency must be a positive integer, got ${concurrency}`);
15
+ }
16
+ this.available = concurrency;
17
+ }
18
+
19
+ /** Resolves once a slot is free; returns the matching release function. */
20
+ public acquire(): Promise<() => void> {
21
+ if (this.available > 0) {
22
+ this.available -= 1;
23
+ return Promise.resolve(() => this.release());
24
+ }
25
+ return new Promise<() => void>((resolve) => {
26
+ this.waiters.push(() => {
27
+ this.available -= 1;
28
+ resolve(() => this.release());
29
+ });
30
+ });
31
+ }
32
+
33
+ private release(): void {
34
+ this.available += 1;
35
+ const next = this.waiters.shift();
36
+ if (next !== undefined) {
37
+ next();
38
+ }
39
+ }
40
+
41
+ /** Number of currently free slots — exposed for tests only. */
42
+ public get availableCount(): number {
43
+ return this.available;
44
+ }
45
+ }