@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
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursive discovery (spec.md §6.1; architecture.md §6.4, §23.1).
|
|
3
|
+
*
|
|
4
|
+
* Walks the repository tree through `FileSystemPort` only (never
|
|
5
|
+
* `node:fs`), applies the ignore engine, resolves and guards symlinks, and
|
|
6
|
+
* hands each surviving path to bounded-concurrency classification/hashing.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
classifyTextFormat,
|
|
10
|
+
assertWithinWorkspace,
|
|
11
|
+
isUntangleError,
|
|
12
|
+
type FileStatV1,
|
|
13
|
+
type FileSystemPort,
|
|
14
|
+
} from "@untangle-code/domain";
|
|
15
|
+
|
|
16
|
+
import { classifyBinarySample, detectMinified } from "./binary-classifier.js";
|
|
17
|
+
import { mapConcurrent } from "./concurrency.js";
|
|
18
|
+
import { createPortContentHasher } from "./hashing.js";
|
|
19
|
+
import type { IgnoreEngine } from "./ignore-engine.js";
|
|
20
|
+
import { createIgnoreEngine } from "./ignore-engine.js";
|
|
21
|
+
import { detectLanguage } from "./language-classifier.js";
|
|
22
|
+
import { portOptions } from "./port-options.js";
|
|
23
|
+
import type {
|
|
24
|
+
ContentHasher,
|
|
25
|
+
ScannedFileV1,
|
|
26
|
+
ScanOptions,
|
|
27
|
+
ScanSkipV1,
|
|
28
|
+
TextEncodingGuess,
|
|
29
|
+
} from "./types.js";
|
|
30
|
+
|
|
31
|
+
interface WalkDir {
|
|
32
|
+
relativePath: string;
|
|
33
|
+
absolutePath: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface FileCandidate {
|
|
37
|
+
relativePath: string;
|
|
38
|
+
absolutePath: string;
|
|
39
|
+
/** Already-known stat (symlink targets are stat'd during resolution); avoids a redundant call. */
|
|
40
|
+
stat?: FileStatV1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface WalkContext {
|
|
44
|
+
fs: FileSystemPort;
|
|
45
|
+
ignoreEngine: IgnoreEngine;
|
|
46
|
+
roots: readonly string[];
|
|
47
|
+
visitedRealDirs: Set<string>;
|
|
48
|
+
gitIgnoreFileName: string;
|
|
49
|
+
untangleIgnoreFileName: string;
|
|
50
|
+
onSkip?: ((skip: ScanSkipV1) => void) | undefined;
|
|
51
|
+
signal?: AbortSignal | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function skip(
|
|
55
|
+
context: WalkContext,
|
|
56
|
+
relativePath: string,
|
|
57
|
+
reason: ScanSkipV1["reason"],
|
|
58
|
+
detail?: string,
|
|
59
|
+
): void {
|
|
60
|
+
context.onSkip?.(
|
|
61
|
+
detail === undefined ? { relativePath, reason } : { relativePath, reason, detail },
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function* walkDirectory(context: WalkContext, dir: WalkDir): AsyncGenerator<FileCandidate> {
|
|
66
|
+
context.signal?.throwIfAborted();
|
|
67
|
+
const entries = await context.fs.readDir(dir.absolutePath, portOptions(context.signal));
|
|
68
|
+
|
|
69
|
+
let pushedGitignore = false;
|
|
70
|
+
let pushedUntangleIgnore = false;
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (entry.kind === "file" && entry.name === context.gitIgnoreFileName) {
|
|
73
|
+
const content = await context.fs.readTextFile(entry.path, portOptions(context.signal));
|
|
74
|
+
context.ignoreEngine.pushGitignoreLayer(dir.relativePath, content);
|
|
75
|
+
pushedGitignore = true;
|
|
76
|
+
} else if (entry.kind === "file" && entry.name === context.untangleIgnoreFileName) {
|
|
77
|
+
const content = await context.fs.readTextFile(entry.path, portOptions(context.signal));
|
|
78
|
+
context.ignoreEngine.pushUntangleIgnoreLayer(dir.relativePath, content);
|
|
79
|
+
pushedUntangleIgnore = true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
context.signal?.throwIfAborted();
|
|
86
|
+
const childRelative =
|
|
87
|
+
dir.relativePath === "" ? entry.name : `${dir.relativePath}/${entry.name}`;
|
|
88
|
+
|
|
89
|
+
if (entry.kind === "directory") {
|
|
90
|
+
const decision = context.ignoreEngine.decide(childRelative, true);
|
|
91
|
+
if (decision.ignored) {
|
|
92
|
+
skip(context, childRelative, decision.reason ?? "default-exclude");
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (context.visitedRealDirs.has(entry.path)) {
|
|
96
|
+
skip(context, childRelative, "symlink-cycle", "directory real path already visited");
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
context.visitedRealDirs.add(entry.path);
|
|
100
|
+
yield* walkDirectory(context, { relativePath: childRelative, absolutePath: entry.path });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (entry.kind === "symlink") {
|
|
105
|
+
yield* handleSymlink(context, entry.path, childRelative);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (entry.kind === "file") {
|
|
110
|
+
const decision = context.ignoreEngine.decide(childRelative, false);
|
|
111
|
+
if (decision.ignored) {
|
|
112
|
+
skip(context, childRelative, decision.reason ?? "default-exclude");
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
yield { relativePath: childRelative, absolutePath: entry.path };
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
skip(
|
|
120
|
+
context,
|
|
121
|
+
childRelative,
|
|
122
|
+
"unreadable",
|
|
123
|
+
`unsupported directory entry kind "${entry.kind}"`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
if (pushedUntangleIgnore) {
|
|
128
|
+
context.ignoreEngine.popUntangleIgnoreLayer();
|
|
129
|
+
}
|
|
130
|
+
if (pushedGitignore) {
|
|
131
|
+
context.ignoreEngine.popGitignoreLayer();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function* handleSymlink(
|
|
137
|
+
context: WalkContext,
|
|
138
|
+
entryAbsolutePath: string,
|
|
139
|
+
childRelative: string,
|
|
140
|
+
): AsyncGenerator<FileCandidate> {
|
|
141
|
+
let real: string;
|
|
142
|
+
try {
|
|
143
|
+
real = await context.fs.realPath(entryAbsolutePath, portOptions(context.signal));
|
|
144
|
+
} catch {
|
|
145
|
+
skip(context, childRelative, "unreadable", "symlink target could not be resolved");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
assertWithinWorkspace(real, context.roots);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (isUntangleError(error) && error.code === "PATH_OUTSIDE_WORKSPACE") {
|
|
153
|
+
skip(context, childRelative, "symlink-escape", real);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const targetStat = await context.fs.stat(real, portOptions(context.signal));
|
|
160
|
+
if (targetStat === null) {
|
|
161
|
+
skip(context, childRelative, "unreadable", "dangling symlink");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (targetStat.kind === "directory") {
|
|
166
|
+
const decision = context.ignoreEngine.decide(childRelative, true);
|
|
167
|
+
if (decision.ignored) {
|
|
168
|
+
skip(context, childRelative, decision.reason ?? "default-exclude");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (context.visitedRealDirs.has(real)) {
|
|
172
|
+
skip(context, childRelative, "symlink-cycle", real);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
context.visitedRealDirs.add(real);
|
|
176
|
+
yield* walkDirectory(context, { relativePath: childRelative, absolutePath: real });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (targetStat.kind === "file") {
|
|
181
|
+
const decision = context.ignoreEngine.decide(childRelative, false);
|
|
182
|
+
if (decision.ignored) {
|
|
183
|
+
skip(context, childRelative, decision.reason ?? "default-exclude");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
yield { relativePath: childRelative, absolutePath: real, stat: targetStat };
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
skip(
|
|
191
|
+
context,
|
|
192
|
+
childRelative,
|
|
193
|
+
"unreadable",
|
|
194
|
+
`symlink target has unsupported kind "${targetStat.kind}"`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Decodes a content sample as text using the *detected* encoding
|
|
200
|
+
* (WP-64 fix, spec.md E4: silent data loss is worse than a slightly slower
|
|
201
|
+
* decode). Before this fix, the sample was always decoded as UTF-8
|
|
202
|
+
* regardless of `classifyBinarySample`'s detected `encoding`, which produced
|
|
203
|
+
* mojibake for BOM-less UTF-16 files (their bytes are ~50% NUL under a
|
|
204
|
+
* UTF-8 reading) and, worse, fed that mojibake into `detectLanguage`'s
|
|
205
|
+
* shebang sniff for extensionless scripts — silently misclassifying a
|
|
206
|
+
* legitimate `#!/usr/bin/env python3` UTF-16 script as unknown/no language.
|
|
207
|
+
*
|
|
208
|
+
* UTF-16 (LE/BE, whether BOM-confirmed or detected BOM-less by
|
|
209
|
+
* `detectUtf16WithoutBom`) decodes under its own `TextDecoder` label so the
|
|
210
|
+
* BOM — if present — is consumed correctly. Every other guess (`utf8`,
|
|
211
|
+
* `ascii`, `unknown`) falls back to UTF-8 non-fatal decoding, which is a
|
|
212
|
+
* safe superset of ASCII and the correct default per spec.md.
|
|
213
|
+
*/
|
|
214
|
+
function decodeSampleText(sample: Uint8Array, encoding: TextEncodingGuess): string {
|
|
215
|
+
try {
|
|
216
|
+
if (encoding === "utf16le" || encoding === "utf16be") {
|
|
217
|
+
return new TextDecoder(encoding === "utf16le" ? "utf-16le" : "utf-16be", {
|
|
218
|
+
fatal: false,
|
|
219
|
+
}).decode(sample);
|
|
220
|
+
}
|
|
221
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(sample);
|
|
222
|
+
} catch {
|
|
223
|
+
return "";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
interface ProcessConfig {
|
|
228
|
+
fs: FileSystemPort;
|
|
229
|
+
maxFileSizeBytes: number;
|
|
230
|
+
sampleBytes: number;
|
|
231
|
+
nonPrintableThreshold: number;
|
|
232
|
+
hasher: ContentHasher;
|
|
233
|
+
onSkip?: ((skip: ScanSkipV1) => void) | undefined;
|
|
234
|
+
signal?: AbortSignal | undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function processCandidate(
|
|
238
|
+
config: ProcessConfig,
|
|
239
|
+
candidate: FileCandidate,
|
|
240
|
+
): Promise<ScannedFileV1 | null> {
|
|
241
|
+
const stat =
|
|
242
|
+
candidate.stat ?? (await config.fs.stat(candidate.absolutePath, portOptions(config.signal)));
|
|
243
|
+
if (stat === null) {
|
|
244
|
+
config.onSkip?.({
|
|
245
|
+
relativePath: candidate.relativePath,
|
|
246
|
+
reason: "unreadable",
|
|
247
|
+
detail: "vanished before stat",
|
|
248
|
+
});
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
if (stat.sizeBytes > config.maxFileSizeBytes) {
|
|
252
|
+
config.onSkip?.({ relativePath: candidate.relativePath, reason: "size-limit" });
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const bytes = await config.fs.readFile(candidate.absolutePath, portOptions(config.signal));
|
|
257
|
+
const sample = bytes.subarray(0, Math.min(bytes.length, config.sampleBytes));
|
|
258
|
+
const binary = classifyBinarySample(sample, {
|
|
259
|
+
nonPrintableThreshold: config.nonPrintableThreshold,
|
|
260
|
+
});
|
|
261
|
+
if (binary.isBinary) {
|
|
262
|
+
config.onSkip?.({ relativePath: candidate.relativePath, reason: "binary" });
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const sampleText = decodeSampleText(sample, binary.encoding);
|
|
267
|
+
const language = detectLanguage(candidate.relativePath, sampleText);
|
|
268
|
+
// Die Textklasse steht eine Zeile weiter oben schon fest und kostet nichts:
|
|
269
|
+
// sie ist rein pfadbasiert. Ohne sie entschied die Einzelzeilenregel auch
|
|
270
|
+
// ueber Prosa — und ob sie zuschlug, haing daran, ob die erste lange Zeile
|
|
271
|
+
// zufaellig in die 8-KB-Stichprobe fiel.
|
|
272
|
+
const isMinified = detectMinified(sample, undefined, classifyTextFormat(candidate.relativePath));
|
|
273
|
+
const contentHash = await config.hasher.hashFile(
|
|
274
|
+
candidate.absolutePath,
|
|
275
|
+
stat.sizeBytes,
|
|
276
|
+
portOptions(config.signal),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
relativePath: candidate.relativePath,
|
|
281
|
+
absolutePath: candidate.absolutePath,
|
|
282
|
+
sizeBytes: stat.sizeBytes,
|
|
283
|
+
mtimeEpochMs: stat.mtimeEpochMs,
|
|
284
|
+
contentHash,
|
|
285
|
+
languageId: language.languageId,
|
|
286
|
+
languageDetectedBy: language.detectedBy,
|
|
287
|
+
isMinified,
|
|
288
|
+
encoding: binary.encoding,
|
|
289
|
+
hasBom: binary.hasBom,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const DEFAULT_MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
|
294
|
+
const DEFAULT_SAMPLE_BYTES = 8192;
|
|
295
|
+
const DEFAULT_CONCURRENCY = 8;
|
|
296
|
+
const DEFAULT_NON_PRINTABLE_THRESHOLD = 0.3;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Discovers files under `options.repoRoot`, applying ignore rules and
|
|
300
|
+
* symlink safety, and yields a `ScannedFileV1` for every file accepted for
|
|
301
|
+
* downstream analysis. Excluded/skipped paths are reported via `onSkip`,
|
|
302
|
+
* never yielded. Respects `options.signal` throughout.
|
|
303
|
+
*/
|
|
304
|
+
export async function* discoverFiles(
|
|
305
|
+
options: ScanOptions,
|
|
306
|
+
): AsyncGenerator<ScannedFileV1, void, void> {
|
|
307
|
+
const maxFileSizeBytes = options.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE_BYTES;
|
|
308
|
+
const sampleBytes = options.sampleBytes ?? DEFAULT_SAMPLE_BYTES;
|
|
309
|
+
const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;
|
|
310
|
+
const nonPrintableThreshold = options.nonPrintableThreshold ?? DEFAULT_NON_PRINTABLE_THRESHOLD;
|
|
311
|
+
const gitIgnoreFileName = options.ignore?.gitIgnoreFileName ?? ".gitignore";
|
|
312
|
+
const untangleIgnoreFileName = options.ignore?.untangleIgnoreFileName ?? ".untangleignore";
|
|
313
|
+
|
|
314
|
+
options.signal?.throwIfAborted();
|
|
315
|
+
// Step 2 (architecture.md §23.1): lexical check before any I/O.
|
|
316
|
+
assertWithinWorkspace(options.repoRoot, options.roots);
|
|
317
|
+
|
|
318
|
+
// Step 1+3: canonically resolve the roots themselves too, not just the
|
|
319
|
+
// repo root and symlink targets encountered later — a root path can
|
|
320
|
+
// itself contain an unresolved symlink component (e.g. macOS's
|
|
321
|
+
// `/var` -> `/private/var`), and comparing a realpath-resolved candidate
|
|
322
|
+
// against a *literal* root would then spuriously look like an escape.
|
|
323
|
+
const resolvedRoots = await Promise.all(
|
|
324
|
+
options.roots.map((root) => options.fs.realPath(root, portOptions(options.signal))),
|
|
325
|
+
);
|
|
326
|
+
const rootReal = await options.fs.realPath(options.repoRoot, portOptions(options.signal));
|
|
327
|
+
assertWithinWorkspace(rootReal, resolvedRoots);
|
|
328
|
+
|
|
329
|
+
const context: WalkContext = {
|
|
330
|
+
fs: options.fs,
|
|
331
|
+
ignoreEngine: createIgnoreEngine(options.ignore),
|
|
332
|
+
roots: resolvedRoots,
|
|
333
|
+
visitedRealDirs: new Set<string>([rootReal]),
|
|
334
|
+
gitIgnoreFileName,
|
|
335
|
+
untangleIgnoreFileName,
|
|
336
|
+
onSkip: options.onSkip,
|
|
337
|
+
signal: options.signal,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const candidates = walkDirectory(context, { relativePath: "", absolutePath: rootReal });
|
|
341
|
+
|
|
342
|
+
const processConfig: ProcessConfig = {
|
|
343
|
+
fs: options.fs,
|
|
344
|
+
maxFileSizeBytes,
|
|
345
|
+
sampleBytes,
|
|
346
|
+
nonPrintableThreshold,
|
|
347
|
+
hasher: options.hasher ?? createPortContentHasher(options.fs),
|
|
348
|
+
onSkip: options.onSkip,
|
|
349
|
+
signal: options.signal,
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
for await (const result of mapConcurrent(
|
|
353
|
+
candidates,
|
|
354
|
+
concurrency,
|
|
355
|
+
(candidate) => processCandidate(processConfig, candidate),
|
|
356
|
+
options.signal,
|
|
357
|
+
)) {
|
|
358
|
+
if (result !== null) {
|
|
359
|
+
yield result;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, self-contained gitignore-pattern compiler (spec.md §6.2:
|
|
3
|
+
* "Gitignore-Semantik selbst implementieren oder ueber die ignore-Bibliothek").
|
|
4
|
+
* Supports negation (`!pattern`), directory-only patterns (`pattern/`),
|
|
5
|
+
* root anchors (`/pattern`), `**`, `*`, `?` and `[...]` character classes.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately self-implemented (no dependency) to keep full control over
|
|
8
|
+
* how multiple layers compose (see `ignore-engine.ts`), which the spec's
|
|
9
|
+
* multi-source priority model (§6.2) needs more of than a single
|
|
10
|
+
* `.gitignore` parser typically exposes.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface CompiledPattern {
|
|
14
|
+
regex: RegExp;
|
|
15
|
+
negate: boolean;
|
|
16
|
+
dirOnly: boolean;
|
|
17
|
+
raw: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function escapeRegexLiteral(char: string): string {
|
|
21
|
+
return /[.+^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function escapeBaseDir(baseDir: string): string {
|
|
25
|
+
return baseDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Translates one gitignore glob (no leading `/`, no trailing `/`) into a regex source string. */
|
|
29
|
+
function globToRegexSource(pattern: string): string {
|
|
30
|
+
let out = "";
|
|
31
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
32
|
+
const ch = pattern[i];
|
|
33
|
+
if (ch === "*") {
|
|
34
|
+
if (pattern[i + 1] === "*") {
|
|
35
|
+
if (pattern[i + 2] === "/") {
|
|
36
|
+
out += "(?:.*/)?";
|
|
37
|
+
i += 2;
|
|
38
|
+
} else if (i + 2 >= pattern.length) {
|
|
39
|
+
out += ".*";
|
|
40
|
+
i += 1;
|
|
41
|
+
} else {
|
|
42
|
+
// "**" not followed by "/" and not at the end: treat as a plain "*".
|
|
43
|
+
out += "[^/]*";
|
|
44
|
+
i += 1;
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
out += "[^/]*";
|
|
48
|
+
}
|
|
49
|
+
} else if (ch === "?") {
|
|
50
|
+
out += "[^/]";
|
|
51
|
+
} else if (ch === "[") {
|
|
52
|
+
let j = i + 1;
|
|
53
|
+
let cls = "[";
|
|
54
|
+
if (pattern[j] === "!" || pattern[j] === "^") {
|
|
55
|
+
cls += "^";
|
|
56
|
+
j += 1;
|
|
57
|
+
}
|
|
58
|
+
while (j < pattern.length && pattern[j] !== "]") {
|
|
59
|
+
const c = pattern[j];
|
|
60
|
+
cls += c === "\\" ? "\\\\" : c;
|
|
61
|
+
j += 1;
|
|
62
|
+
}
|
|
63
|
+
cls += "]";
|
|
64
|
+
out += cls;
|
|
65
|
+
i = j;
|
|
66
|
+
} else if (ch === "\\" && i + 1 < pattern.length) {
|
|
67
|
+
out += escapeRegexLiteral(pattern[i + 1] as string);
|
|
68
|
+
i += 1;
|
|
69
|
+
} else {
|
|
70
|
+
out += escapeRegexLiteral(ch as string);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compiles one gitignore-style line, scoped to `baseDir` (the "/"-relative
|
|
78
|
+
* directory the source `.gitignore`/`.untangleignore` file lives in, `""`
|
|
79
|
+
* for the workspace/repo root). Returns `null` for blank lines and comments.
|
|
80
|
+
*/
|
|
81
|
+
export function compilePattern(rawLine: string, baseDir: string): CompiledPattern | null {
|
|
82
|
+
const trimmed = rawLine.replace(/\s+$/, "");
|
|
83
|
+
if (trimmed.length === 0) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (trimmed.startsWith("#")) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let pattern = trimmed;
|
|
91
|
+
let negate = false;
|
|
92
|
+
if (pattern.startsWith("!")) {
|
|
93
|
+
negate = true;
|
|
94
|
+
pattern = pattern.slice(1);
|
|
95
|
+
} else if (pattern.startsWith("\\!") || pattern.startsWith("\\#")) {
|
|
96
|
+
pattern = pattern.slice(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let dirOnly = false;
|
|
100
|
+
if (pattern.endsWith("/")) {
|
|
101
|
+
dirOnly = true;
|
|
102
|
+
pattern = pattern.slice(0, -1);
|
|
103
|
+
}
|
|
104
|
+
if (pattern.length === 0) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let anchored = pattern.includes("/");
|
|
109
|
+
if (pattern.startsWith("/")) {
|
|
110
|
+
pattern = pattern.slice(1);
|
|
111
|
+
anchored = true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const base = escapeBaseDir(baseDir);
|
|
115
|
+
const basePrefix = baseDir.length > 0 ? `${base}/` : "";
|
|
116
|
+
const body = globToRegexSource(pattern);
|
|
117
|
+
|
|
118
|
+
const source = anchored
|
|
119
|
+
? `^${basePrefix}${body}(?:/.*)?$`
|
|
120
|
+
: `^${basePrefix}(?:.*/)?${body}(?:/.*)?$`;
|
|
121
|
+
|
|
122
|
+
return { regex: new RegExp(source), negate, dirOnly, raw: rawLine };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Compiles every line of a `.gitignore`/`.untangleignore`-style file. */
|
|
126
|
+
export function compilePatterns(content: string, baseDir: string): CompiledPattern[] {
|
|
127
|
+
const patterns: CompiledPattern[] = [];
|
|
128
|
+
for (const line of content.split(/\r\n|\r|\n/)) {
|
|
129
|
+
const compiled = compilePattern(line, baseDir);
|
|
130
|
+
if (compiled !== null) {
|
|
131
|
+
patterns.push(compiled);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return patterns;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Compiles a flat list of plain glob strings (e.g. config include/exclude), all rooted at "". */
|
|
138
|
+
export function compileGlobList(patterns: readonly string[]): CompiledPattern[] {
|
|
139
|
+
const compiled: CompiledPattern[] = [];
|
|
140
|
+
for (const pattern of patterns) {
|
|
141
|
+
const result = compilePattern(pattern, "");
|
|
142
|
+
if (result !== null) {
|
|
143
|
+
compiled.push(result);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return compiled;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Evaluates one layer's patterns against a path (standard gitignore
|
|
151
|
+
* semantics: later patterns override earlier ones within the same layer).
|
|
152
|
+
* Returns `undefined` when no pattern in the layer applies at all — that is
|
|
153
|
+
* the signal for callers to defer to a lower-priority layer's decision.
|
|
154
|
+
*/
|
|
155
|
+
export function evaluateLayer(
|
|
156
|
+
patterns: readonly CompiledPattern[],
|
|
157
|
+
relativePath: string,
|
|
158
|
+
isDirectory: boolean,
|
|
159
|
+
): boolean | undefined {
|
|
160
|
+
let result: boolean | undefined;
|
|
161
|
+
for (const pattern of patterns) {
|
|
162
|
+
if (pattern.dirOnly && !isDirectory) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (pattern.regex.test(relativePath)) {
|
|
166
|
+
result = !pattern.negate;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
170
|
+
}
|
package/src/hashing.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-hash pipeline (spec.md §12.1): SHA-256 of the raw file bytes is
|
|
3
|
+
* the sole source of truth for "unchanged" decisions; path/size/mtime are
|
|
4
|
+
* fast hints only.
|
|
5
|
+
*
|
|
6
|
+
* `node:crypto` is used directly here — the "no direct node I/O in the
|
|
7
|
+
* core" rule (WP-05 order) is about the *file system* (`node:fs`), so the
|
|
8
|
+
* scanner reads bytes exclusively through `FileSystemPort`. Hashing pure
|
|
9
|
+
* in-memory bytes with `node:crypto` is not I/O.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
|
|
13
|
+
import type { FileSystemPort, PortCallOptions } from "@untangle-code/domain";
|
|
14
|
+
|
|
15
|
+
import type { ContentHasher } from "./types.js";
|
|
16
|
+
|
|
17
|
+
/** SHA-256 hex digest of raw bytes already held in memory. */
|
|
18
|
+
export function hashBytes(bytes: Uint8Array): string {
|
|
19
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Default `ContentHasher`: reads the whole file through `FileSystemPort`
|
|
24
|
+
* and hashes the resulting buffer. This is the only option available
|
|
25
|
+
* against the abstract port (it exposes no range/stream read), so it is
|
|
26
|
+
* what every non-Node adapter (in-memory test doubles included) gets by
|
|
27
|
+
* default. Real streaming is available from `./node-fs` for the Node
|
|
28
|
+
* adapter (see README, "Entscheidungen").
|
|
29
|
+
*/
|
|
30
|
+
export function createPortContentHasher(fs: FileSystemPort): ContentHasher {
|
|
31
|
+
return {
|
|
32
|
+
async hashFile(absolutePath: string, _sizeBytes: number, options?: PortCallOptions) {
|
|
33
|
+
const bytes = await fs.readFile(absolutePath, options);
|
|
34
|
+
return hashBytes(bytes);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|