@git.zone/tsdisk 1.2.1 → 1.4.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.
@@ -0,0 +1,199 @@
1
+ import * as plugins from './tsdisk.plugins.js';
2
+
3
+ export interface IUsageEntry {
4
+ path: string;
5
+ bytes: number;
6
+ complete: boolean;
7
+ }
8
+
9
+ export interface IUsageReport {
10
+ roots: string[];
11
+ measuredBytes: number;
12
+ entries: IUsageEntry[];
13
+ records: number;
14
+ complete: boolean;
15
+ code: number;
16
+ stderr: string;
17
+ }
18
+
19
+ export function containsPath(parent: string, child: string): boolean {
20
+ const relative = plugins.path.relative(parent, child);
21
+ return relative === '' || (!relative.startsWith(`..${plugins.path.sep}`) && relative !== '..' && !plugins.path.isAbsolute(relative));
22
+ }
23
+
24
+ /** Preserve explicitly selected nested mounts; subsume overlapping roots on the same device. */
25
+ export async function normalizeScanRoots(roots: string[]): Promise<string[]> {
26
+ const unique = [...new Set(roots.map((root) => plugins.path.resolve(root)))].sort((a, b) => a.length - b.length || a.localeCompare(b));
27
+ const accepted: Array<{ path: string; dev?: number }> = [];
28
+ for (const path of unique) {
29
+ const stat = await plugins.fs.lstat(path).catch(() => undefined);
30
+ const real = await plugins.fs.realpath(path).catch(() => undefined);
31
+ const dev = stat?.isDirectory() && real === path ? stat.dev : undefined;
32
+ if (dev !== undefined && accepted.some((parent) => parent.dev === dev && containsPath(parent.path, path))) continue;
33
+ accepted.push({ path, dev });
34
+ }
35
+ return accepted.map((root) => root.path);
36
+ }
37
+
38
+ /** GNU du emits files immediately and inclusive directory totals on completion.
39
+ * Reconcile each completed directory with its observed children, rather than
40
+ * adding inclusive totals twice. The active ancestor chain survives a timeout.
41
+ */
42
+ export class UsageAccumulator {
43
+ private pending = '';
44
+ private active = new Map<string, number>();
45
+ private largest = new Map<string, IUsageEntry>();
46
+ private tracked = new Map<string, IUsageEntry>();
47
+ private minimumLargestBytes = 0;
48
+ private operandBytes = new Map<string, number>();
49
+ private accountingRoots: string[];
50
+ private currentRoot?: string;
51
+ public measuredBytes = 0;
52
+ public records = 0;
53
+ public currentPath = '';
54
+
55
+ constructor(private roots: string[], private trackedPaths: Set<string> = new Set(), private priorityPaths: string[] = []) {
56
+ this.roots = [...roots].sort((a, b) => b.length - a.length);
57
+ if (roots.some((root) => plugins.path.resolve(root) !== root) || priorityPaths.some((path) => !roots.some((root) => root !== path && containsPath(root, path)))) {
58
+ throw new Error('Usage accounting requires normalized roots and strict descendant priority paths.');
59
+ }
60
+ this.accountingRoots = [...priorityPaths, ...roots].sort((a, b) => b.length - a.length);
61
+ this.trackedPaths = new Set([...trackedPaths, ...roots, ...priorityPaths]);
62
+ }
63
+
64
+ public consume(chunk: string) {
65
+ this.pending += chunk;
66
+ let start = 0;
67
+ for (;;) {
68
+ const end = this.pending.indexOf('\0', start);
69
+ if (end < 0) break;
70
+ this.record(this.pending.slice(start, end));
71
+ start = end + 1;
72
+ }
73
+ this.pending = this.pending.slice(start);
74
+ if (this.pending.length > 1024 * 1024) throw new Error('Invalid du record exceeds 1 MiB.');
75
+ }
76
+
77
+ private record(record: string) {
78
+ const separator = record.indexOf('\t');
79
+ const bytes = Number(record.slice(0, separator));
80
+ const path = record.slice(separator + 1);
81
+ const matches = (candidate: string) => path === candidate || path.startsWith(candidate === '/' ? '/' : `${candidate}/`);
82
+ const root = this.currentRoot && matches(this.currentRoot) ? this.currentRoot : this.accountingRoots.find(matches);
83
+ if (separator < 1 || !Number.isSafeInteger(bytes) || bytes < 0 || !plugins.path.isAbsolute(path) || !root) throw new Error('Invalid du output.');
84
+ this.records++;
85
+ this.currentPath = path;
86
+ this.currentRoot = path === root ? undefined : root;
87
+ const delta = bytes - (this.active.get(path) ?? 0);
88
+ this.active.delete(path);
89
+ this.measuredBytes += delta;
90
+ this.operandBytes.set(root, (this.operandBytes.get(root) ?? 0) + delta);
91
+ if (path !== root) {
92
+ for (let parent = plugins.path.dirname(path); ; parent = plugins.path.dirname(parent)) {
93
+ this.active.set(parent, (this.active.get(parent) ?? 0) + delta);
94
+ if (parent === root) break;
95
+ }
96
+ }
97
+ const entry = { path, bytes, complete: true };
98
+ if (this.trackedPaths.has(path)) this.tracked.set(path, entry);
99
+ // Retain only the largest completed paths, not millions of file records.
100
+ if (this.largest.size < 100 || bytes > this.minimumLargestBytes) {
101
+ this.largest.set(path, entry);
102
+ if (this.largest.size > 100) {
103
+ const sorted = [...this.largest.values()].sort((a, b) => b.bytes - a.bytes);
104
+ this.largest = new Map(sorted.slice(0, 100).map((item) => [item.path, item]));
105
+ }
106
+ this.minimumLargestBytes = Math.min(...[...this.largest.values()].map((item) => item.bytes));
107
+ }
108
+ }
109
+
110
+ public snapshot(): IUsageEntry[] {
111
+ const entries = new Map([...this.largest, ...this.tracked].map(([path, entry]) => [path, { ...entry }]));
112
+ for (const [path, bytes] of this.active) entries.set(path, { path, bytes, complete: false });
113
+ // du charges an overlapping subtree to its first operand and omits it from
114
+ // later ancestor totals. Restore those inclusive totals for display only;
115
+ // measuredBytes remains the native, deduplicated sum of operand contributions.
116
+ for (const priority of this.priorityPaths) {
117
+ const bytes = this.operandBytes.get(priority);
118
+ const root = this.roots.find((candidate) => containsPath(candidate, priority));
119
+ if (bytes === undefined || !root) continue;
120
+ for (let parent = plugins.path.dirname(priority); ; parent = plugins.path.dirname(parent)) {
121
+ const entry = entries.get(parent) ?? { path: parent, bytes: 0, complete: false };
122
+ entry.bytes += bytes;
123
+ entries.set(parent, entry);
124
+ if (parent === root) break;
125
+ }
126
+ }
127
+ return [...entries.values()].sort((a, b) => b.bytes - a.bytes);
128
+ }
129
+
130
+ public get endedOnRecordBoundary() { return this.pending.length === 0; }
131
+ }
132
+
133
+ export interface IUsageOptions {
134
+ timeoutSeconds: number;
135
+ trackedPaths?: string[];
136
+ priorityPaths?: string[];
137
+ onProgress?: (usage: UsageAccumulator) => void;
138
+ }
139
+
140
+ /** One streaming native pass; no output export, no symlink traversal, no raw file content reads. */
141
+ export async function measureDiskUsage(roots: string[], options: IUsageOptions): Promise<IUsageReport> {
142
+ if (!Number.isFinite(options.timeoutSeconds) || options.timeoutSeconds <= 0) throw new Error('Invalid usage timeout.');
143
+ const valid: string[] = [];
144
+ const devices = new Map<string, number>();
145
+ const errors: string[] = [];
146
+ for (const root of await normalizeScanRoots(roots)) {
147
+ try {
148
+ const stat = await plugins.fs.lstat(root);
149
+ if (!stat.isDirectory() || await plugins.fs.realpath(root) !== root) throw new Error('Usage roots must be real directories without symlink ancestors.');
150
+ valid.push(root);
151
+ devices.set(root, stat.dev);
152
+ } catch (error) { errors.push(`${root}: ${error instanceof Error ? error.message : String(error)}`); }
153
+ }
154
+ const priorities: string[] = [];
155
+ for (const path of await normalizeScanRoots(options.priorityPaths ?? [])) {
156
+ const root = [...valid].reverse().find((root) => root !== path && containsPath(root, path));
157
+ if (!root) continue;
158
+ const real = await plugins.fs.realpath(path).catch(() => undefined);
159
+ const stat = await plugins.fs.lstat(path).catch(() => undefined);
160
+ if (real === path && stat?.isDirectory() && stat.dev === devices.get(root)) priorities.push(path);
161
+ }
162
+ // Measure retained project artifacts before broad dependency/source trees.
163
+ // This changes order only: remaining paths are visited by the final roots.
164
+ priorities.sort((a, b) => b.split(plugins.path.sep).length - a.split(plugins.path.sep).length || a.localeCompare(b));
165
+ let argumentBytes = valid.reduce((sum, path) => sum + Buffer.byteLength(path) + 1, 0);
166
+ const argumentLimit = priorities.findIndex((path) => (argumentBytes += Buffer.byteLength(path) + 1) > 128 * 1024);
167
+ if (argumentLimit >= 0) priorities.splice(argumentLimit);
168
+ const usage = new UsageAccumulator(valid, new Set(options.trackedPaths), priorities);
169
+ let stderr = errors.join('\n');
170
+ let malformed = false;
171
+ const code = valid.length ? await new Promise<number>((resolve) => {
172
+ const child = plugins.spawn('timeout', ['--kill-after=5s', `${options.timeoutSeconds}s`, 'du', '-a', '-0', '-x', '-B1', '--', ...priorities, ...valid], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, LC_ALL: 'C' } });
173
+ child.stdout.setEncoding('utf8');
174
+ let lastNotification = 0;
175
+ child.stdout.on('data', (chunk: string) => {
176
+ if (malformed) return;
177
+ try {
178
+ usage.consume(chunk);
179
+ if (Date.now() - lastNotification >= 250) {
180
+ options.onProgress?.(usage);
181
+ lastNotification = Date.now();
182
+ }
183
+ } catch (error) {
184
+ malformed = true;
185
+ stderr += `\n${error instanceof Error ? error.message : String(error)}`;
186
+ // Signal timeout, which forwards termination to its managed process group.
187
+ child.kill('SIGTERM');
188
+ }
189
+ });
190
+ child.stderr.setEncoding('utf8');
191
+ child.stderr.on('data', (chunk: string) => { if (stderr.length < 65536) stderr += chunk.slice(0, 65536 - stderr.length); });
192
+ child.on('error', (error) => { stderr += `\n${error.message}`; });
193
+ child.on('close', (exitCode) => resolve(exitCode ?? 1));
194
+ }) : 1;
195
+ options.onProgress?.(usage);
196
+ const complete = code === 0 && !stderr.trim() && !malformed && usage.endedOnRecordBoundary;
197
+ const entries = usage.snapshot().map((entry) => ({ ...entry, complete: entry.complete && complete }));
198
+ return { roots: valid, measuredBytes: usage.measuredBytes, entries, records: usage.records, complete, code, stderr };
199
+ }