@untangle-code/scanner 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/NOTICE +90 -0
- package/README.md +303 -2
- package/THIRD_PARTY_LICENSES.md +24 -0
- package/dist/binary-classifier.d.ts +45 -0
- package/dist/binary-classifier.d.ts.map +1 -0
- package/dist/binary-classifier.js +296 -0
- package/dist/binary-classifier.js.map +1 -0
- package/dist/concurrency.d.ts +8 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +64 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/discovery.d.ts +9 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +254 -0
- package/dist/discovery.js.map +1 -0
- package/dist/gitignore-pattern.d.ts +35 -0
- package/dist/gitignore-pattern.d.ts.map +1 -0
- package/dist/gitignore-pattern.js +155 -0
- package/dist/gitignore-pattern.js.map +1 -0
- package/dist/hashing.d.ts +14 -0
- package/dist/hashing.d.ts.map +1 -0
- package/dist/hashing.js +32 -0
- package/dist/hashing.js.map +1 -0
- package/dist/ignore-engine.d.ts +27 -0
- package/dist/ignore-engine.d.ts.map +1 -0
- package/dist/ignore-engine.js +130 -0
- package/dist/ignore-engine.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/language-classifier.d.ts +19 -0
- package/dist/language-classifier.d.ts.map +1 -0
- package/dist/language-classifier.js +114 -0
- package/dist/language-classifier.js.map +1 -0
- package/dist/node-fs/index.d.ts +10 -0
- package/dist/node-fs/index.d.ts.map +1 -0
- package/dist/node-fs/index.js +10 -0
- package/dist/node-fs/index.js.map +1 -0
- package/dist/node-fs/node-file-system.d.ts +19 -0
- package/dist/node-fs/node-file-system.d.ts.map +1 -0
- package/dist/node-fs/node-file-system.js +104 -0
- package/dist/node-fs/node-file-system.js.map +1 -0
- package/dist/node-fs/node-streaming-hasher.d.ts +4 -0
- package/dist/node-fs/node-streaming-hasher.d.ts.map +1 -0
- package/dist/node-fs/node-streaming-hasher.js +33 -0
- package/dist/node-fs/node-streaming-hasher.js.map +1 -0
- package/dist/port-options.d.ts +8 -0
- package/dist/port-options.d.ts.map +1 -0
- package/dist/port-options.js +4 -0
- package/dist/port-options.js.map +1 -0
- package/dist/semaphore.d.ts +18 -0
- package/dist/semaphore.d.ts.map +1 -0
- package/dist/semaphore.js +42 -0
- package/dist/semaphore.js.map +1 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +63 -3
- package/src/binary-classifier.ts +365 -0
- package/src/concurrency.ts +78 -0
- package/src/discovery.ts +362 -0
- package/src/gitignore-pattern.ts +170 -0
- package/src/hashing.ts +37 -0
- package/src/ignore-engine.ts +157 -0
- package/src/index.ts +48 -0
- package/src/language-classifier.ts +137 -0
- package/src/node-fs/index.ts +9 -0
- package/src/node-fs/node-file-system.ts +137 -0
- package/src/node-fs/node-streaming-hasher.ts +35 -0
- package/src/port-options.ts +10 -0
- package/src/semaphore.ts +45 -0
- package/src/types.ts +121 -0
|
@@ -0,0 +1,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
|
+
}
|
package/src/semaphore.ts
ADDED
|
@@ -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
|
+
}
|