@git.zone/tsdisk 1.1.0 → 1.3.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,151 @@
1
+ import * as plugins from './tsdisk.plugins.js';
2
+ import { classifyToolCachePath, parseToolCacheMarker, toolCacheMarkerFile, type IToolCacheFinding } from './tsdisk.toolcaches.js';
3
+
4
+ export interface IWorkspaceScanOptions {
5
+ maxDepth: number;
6
+ timeoutMs: number;
7
+ maxEntries: number;
8
+ onProgress?: (result: IWorkspaceScanResult) => void;
9
+ }
10
+
11
+ export interface IScanIssue {
12
+ path: string;
13
+ reason: 'read-error' | 'invalid-marker' | 'depth-limit' | 'entry-limit' | 'timeout' | 'mount-boundary' | 'symlink' | 'size-unavailable';
14
+ detail: string;
15
+ }
16
+
17
+ export interface IWorkspaceScanResult {
18
+ root: string;
19
+ findings: IToolCacheFinding[];
20
+ issues: IScanIssue[];
21
+ complete: boolean;
22
+ visitedEntries: number;
23
+ issueCounts: Partial<Record<IScanIssue['reason'], number>>;
24
+ usagePriorityPaths: string[];
25
+ }
26
+
27
+ const excludedDirectories = new Set(['.git', 'node_modules', 'dist_ts', 'dist_ts_web', 'coverage', '.pnpm-store']);
28
+
29
+ export async function scanWorkspace(root: string, options: IWorkspaceScanOptions): Promise<IWorkspaceScanResult> {
30
+ for (const name of ['maxDepth', 'timeoutMs', 'maxEntries'] as const) {
31
+ const value = options[name];
32
+ if (!Number.isSafeInteger(value) || value < (name === 'maxDepth' ? 0 : 1)) {
33
+ throw new Error(`Invalid scan option ${name}: ${value}`);
34
+ }
35
+ }
36
+ const result: IWorkspaceScanResult = { root: plugins.path.resolve(root), findings: [], issues: [], issueCounts: {}, usagePriorityPaths: [], complete: true, visitedEntries: 0 };
37
+ const deadline = Date.now() + options.timeoutMs;
38
+ let stopped = false;
39
+ const issue = (path: string, reason: IScanIssue['reason'], detail: string) => {
40
+ result.complete = false;
41
+ result.issueCounts[reason] = (result.issueCounts[reason] ?? 0) + 1;
42
+ if (result.issues.length < 200) result.issues.push({ path, reason, detail });
43
+ };
44
+ const limited = (path: string) => {
45
+ if (stopped) return true;
46
+ if (Date.now() >= deadline || result.visitedEntries >= options.maxEntries) {
47
+ issue(path, Date.now() >= deadline ? 'timeout' : 'entry-limit', 'Scan stopped before visiting all entries.');
48
+ stopped = true;
49
+ }
50
+ return stopped;
51
+ };
52
+ let rootDev: number;
53
+ try {
54
+ const stat = await plugins.fs.lstat(result.root);
55
+ if (stat.isSymbolicLink() || await plugins.fs.realpath(result.root) !== result.root) {
56
+ issue(result.root, 'symlink', 'Scan roots must not contain symbolic links.');
57
+ return result;
58
+ }
59
+ if (!stat.isDirectory()) throw new Error('Scan root is not a directory.');
60
+ rootDev = stat.dev;
61
+ } catch (error) {
62
+ issue(result.root, 'read-error', error instanceof Error ? error.message : String(error));
63
+ return result;
64
+ }
65
+ const queue: Array<{ directory: string; depth: number }> = [{ directory: result.root, depth: 0 }];
66
+ let queueLimited = false;
67
+ const visit = async (directory: string, depth: number): Promise<void> => {
68
+ if (limited(directory)) return;
69
+ result.visitedEntries++;
70
+ try {
71
+ const stat = await plugins.fs.lstat(directory);
72
+ if (stat.isSymbolicLink()) {
73
+ issue(directory, 'symlink', 'Symbolic link skipped.');
74
+ return;
75
+ }
76
+ if (stat.dev !== rootDev) {
77
+ issue(directory, 'mount-boundary', 'Different filesystem skipped.');
78
+ return;
79
+ }
80
+ if (plugins.path.basename(directory) === '.nogit') result.usagePriorityPaths.push(directory);
81
+ let marker;
82
+ const markerPath = plugins.path.join(directory, toolCacheMarkerFile);
83
+ const markerStat = await plugins.fs.lstat(markerPath).catch((error: NodeJS.ErrnoException) => {
84
+ if (error.code === 'ENOENT') return undefined;
85
+ throw error;
86
+ });
87
+ if (markerStat) {
88
+ if (!markerStat.isFile() || markerStat.size > 65536) {
89
+ issue(markerPath, 'invalid-marker', 'Marker must be a regular file of at most 64 KiB.');
90
+ } else {
91
+ marker = parseToolCacheMarker(await plugins.fs.readFile(markerPath, 'utf8'));
92
+ if (!marker) issue(markerPath, 'invalid-marker', 'Invalid cache ownership marker.');
93
+ }
94
+ }
95
+ const heuristic = classifyToolCachePath(directory);
96
+ const classification = classifyToolCachePath(directory, marker);
97
+ if (classification) result.findings.push({ path: directory, ...classification });
98
+ // Cache contents are not workspaces. Registry session directories can carry their own markers.
99
+ if (classification && heuristic?.kind !== 'docker-registry-cache') return;
100
+ let enumerated = 0;
101
+ const children = await plugins.fs.opendir(directory);
102
+ for await (const child of children) {
103
+ // A huge flat directory must not consume the budget of sibling projects.
104
+ if (++enumerated > 4096) {
105
+ issue(directory, 'entry-limit', 'Directory listing stopped at 4096 entries; sibling directories are still scanned.');
106
+ break;
107
+ }
108
+ if (excludedDirectories.has(child.name)) continue;
109
+ if (!child.isDirectory() && !child.isSymbolicLink()) continue;
110
+ if (depth >= options.maxDepth) {
111
+ issue(directory, 'depth-limit', `Maximum scan depth ${options.maxDepth} reached.`);
112
+ break;
113
+ }
114
+ const childPath = plugins.path.join(directory, child.name);
115
+ if (limited(childPath)) return;
116
+ if (child.isSymbolicLink()) {
117
+ result.visitedEntries++;
118
+ issue(childPath, 'symlink', 'Symbolic link skipped.');
119
+ continue;
120
+ }
121
+ if (heuristic?.kind === 'docker-registry-cache') {
122
+ // Only direct sessions, never registry blob trees.
123
+ const sessionMarker = await plugins.fs.lstat(plugins.path.join(childPath, toolCacheMarkerFile)).catch((error: NodeJS.ErrnoException) => {
124
+ if (error.code === 'ENOENT') return undefined;
125
+ throw error;
126
+ });
127
+ if (!sessionMarker) { result.visitedEntries++; continue; }
128
+ }
129
+ if (queue.length >= options.maxEntries) {
130
+ if (!queueLimited) issue(directory, 'entry-limit', 'Directory queue budget exhausted; already queued siblings are still scanned.');
131
+ queueLimited = true;
132
+ continue;
133
+ }
134
+ queue.push({ directory: childPath, depth: depth + 1 });
135
+ }
136
+ } catch (error) {
137
+ issue(directory, 'read-error', error instanceof Error ? error.message : String(error));
138
+ }
139
+ };
140
+ // Breadth first: canonical projects precede deeply nested retained worktrees.
141
+ let lastProgress = 0;
142
+ for (let index = 0; index < queue.length && !stopped; index++) {
143
+ await visit(queue[index].directory, queue[index].depth);
144
+ if (Date.now() - lastProgress >= 250) {
145
+ options.onProgress?.(result);
146
+ lastProgress = Date.now();
147
+ }
148
+ }
149
+ options.onProgress?.(result);
150
+ return result;
151
+ }
@@ -1,4 +1,4 @@
1
- import * as path from 'node:path';
1
+ import { path } from './tsdisk.plugins.js';
2
2
 
3
3
  export const toolCacheMarkerFile = '.gitzone-tool-cache.json';
4
4
 
@@ -29,10 +29,10 @@ export function parseToolCacheMarker(rawArg: string): IToolCacheMarker | undefin
29
29
  try {
30
30
  const marker = JSON.parse(rawArg) as Partial<IToolCacheMarker>;
31
31
  if (
32
- typeof marker.owner === 'string' &&
33
- typeof marker.kind === 'string' &&
34
- marker.safeToPrune === true &&
35
- typeof marker.createdAt === 'string' &&
32
+ typeof marker.owner === 'string' && marker.owner.length > 0 &&
33
+ typeof marker.kind === 'string' && marker.kind.length > 0 &&
34
+ typeof marker.safeToPrune === 'boolean' &&
35
+ typeof marker.createdAt === 'string' && Number.isFinite(Date.parse(marker.createdAt)) &&
36
36
  marker.schemaVersion === 1
37
37
  ) {
38
38
  return marker as IToolCacheMarker;
@@ -79,8 +79,10 @@ export function classifyToolCachePath(pathArg: string, markerArg?: IToolCacheMar
79
79
  return {
80
80
  ownerGuess: markerArg.owner,
81
81
  kind: markerArg.kind,
82
- risk: markerArg.safeToPrune ? 'safe-marked' : 'review',
83
- suggestedCleanupCommand: `rm -rf ${quotePath(pathArg)}`,
82
+ risk: 'review',
83
+ suggestedCleanupCommand: markerArg.safeToPrune
84
+ ? `report-only: ownership marker is not proof of inactivity; use the owning tool to review ${quotePath(pathArg)}`
85
+ : `protected: owner explicitly disallows pruning ${quotePath(pathArg)}`,
84
86
  marker: markerArg,
85
87
  };
86
88
  }
@@ -103,7 +105,7 @@ export function classifyToolCachePath(pathArg: string, markerArg?: IToolCacheMar
103
105
  };
104
106
  }
105
107
 
106
- if (normalized.endsWith('/.nogit/tsbundle-temp')) {
108
+ if (/\/\.nogit\/tsbundle-temp(?:-[^/]+)?$/.test(normalized)) {
107
109
  return {
108
110
  ownerGuess: '@git.zone/tsbundle',
109
111
  kind: 'bundle-temp-workspace',
@@ -112,6 +114,24 @@ export function classifyToolCachePath(pathArg: string, markerArg?: IToolCacheMar
112
114
  };
113
115
  }
114
116
 
117
+ if (/\/\.nogit\/tsrust-matrix(?:-archive|-probes)?$/.test(normalized)) {
118
+ return {
119
+ ownerGuess: '@git.zone/tsrust',
120
+ kind: 'rust-matrix-artifacts',
121
+ risk: 'report-only',
122
+ suggestedCleanupCommand: `report-only: retained matrix runs may be needed for recovery; inspect ${quotePath(pathArg)}`,
123
+ };
124
+ }
125
+
126
+ if (/\/\.nogit\/(?:docker-registry\.[^/]+|docker-registry-v1-cache|dockerimagestore)$/.test(normalized)) {
127
+ return {
128
+ ownerGuess: '@git.zone/tsdocker',
129
+ kind: 'legacy-docker-registry-cache',
130
+ risk: 'report-only',
131
+ suggestedCleanupCommand: `report-only: verify release recovery requirements before removing ${quotePath(pathArg)}`,
132
+ };
133
+ }
134
+
115
135
  if (normalized.endsWith('/.nogit/docker-registry') || normalized.includes('/.nogit/docker-registry/')) {
116
136
  return {
117
137
  ownerGuess: '@git.zone/tsdocker',
@@ -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
+ }