@git.zone/tsdisk 1.1.0 → 1.2.1

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/ts/tsdisk.cli.ts CHANGED
@@ -1,23 +1,25 @@
1
1
  import * as plugins from './tsdisk.plugins.js';
2
2
  import { commitinfo } from './00_commitinfo_data.js';
3
+ import { scanWorkspace, type IWorkspaceScanOptions, type IScanIssue } from './tsdisk.scan.js';
4
+ import { parseCliOptions, createCleanupPlan, type ICliOptions } from './tsdisk.cleanup.js';
5
+ export type { IWorkspaceScanOptions } from './tsdisk.scan.js';
3
6
  import {
4
7
  classifyToolCachePath,
5
- parseToolCacheMarker,
6
- toolCacheMarkerFile,
7
8
  type IToolCacheFinding,
8
- type IToolCacheMarker,
9
9
  } from './tsdisk.toolcaches.js';
10
10
 
11
11
  interface ICommandResult {
12
12
  code: number;
13
13
  stdout: string;
14
14
  stderr: string;
15
+ truncated?: boolean;
15
16
  }
16
17
 
17
18
  interface IRunOptions {
18
19
  asRoot?: boolean;
19
20
  asTargetUser?: boolean;
20
21
  timeoutSeconds?: number;
22
+ cwd?: string;
21
23
  }
22
24
 
23
25
  export interface IRuntimeContext {
@@ -51,11 +53,6 @@ interface INcduParseResult {
51
53
  insights: INcduInsight[];
52
54
  }
53
55
 
54
- export interface IWorkspaceScanOptions {
55
- maxDepth: number;
56
- timeoutMs: number;
57
- maxEntries: number;
58
- }
59
56
 
60
57
  const decoder = new TextDecoder();
61
58
  const maxCapturedOutputBytes = 64 * 1024 * 1024;
@@ -209,6 +206,7 @@ function commandResultFromCapture(code: number, stdout: ICaptureState, stderr: I
209
206
  truncationMessages.push(`stderr truncated after ${formatBytes(maxCapturedOutputBytes)}`);
210
207
  }
211
208
  if (truncationMessages.length) {
209
+ result.truncated = true;
212
210
  result.stderr = [result.stderr.trimEnd(), `[tsdisk] ${truncationMessages.join('; ')}.`]
213
211
  .filter(Boolean)
214
212
  .join('\n');
@@ -591,12 +589,13 @@ async function runCommand(
591
589
  }
592
590
 
593
591
  if (options.timeoutSeconds && options.timeoutSeconds > 0) {
594
- finalArgs = [`${options.timeoutSeconds}s`, executable, ...finalArgs];
592
+ finalArgs = ['--kill-after=5s', `${options.timeoutSeconds}s`, executable, ...finalArgs];
595
593
  executable = 'timeout';
596
594
  }
597
595
 
598
596
  return await new Promise<ICommandResult>((resolve) => {
599
597
  const child = plugins.spawn(executable, finalArgs, {
598
+ cwd: options.cwd,
600
599
  env: {
601
600
  ...process.env,
602
601
  ...env,
@@ -679,119 +678,8 @@ async function pathExists(path: string): Promise<boolean> {
679
678
  }
680
679
  }
681
680
 
682
- async function readMarkerInDirectory(pathArg: string): Promise<IToolCacheMarker | undefined> {
683
- try {
684
- const markerRaw = await plugins.fs.readFile(plugins.path.join(pathArg, toolCacheMarkerFile), 'utf8');
685
- return parseToolCacheMarker(markerRaw);
686
- } catch {
687
- return undefined;
688
- }
689
- }
690
-
691
- function shouldSkipWorkspaceDir(nameArg: string): boolean {
692
- return nameArg === '.git'
693
- || nameArg === 'node_modules'
694
- || nameArg === 'dist_ts'
695
- || nameArg === 'dist_ts_web'
696
- || nameArg === 'coverage'
697
- || nameArg === '.pnpm-store';
698
- }
699
-
700
- export async function collectToolCachePathsFromRoot(
701
- rootArg: string,
702
- optionsArg: IWorkspaceScanOptions,
703
- ): Promise<IToolCacheFinding[]> {
704
- const findings = new Map<string, IToolCacheFinding>();
705
- const startedAt = Date.now();
706
- let visitedEntries = 0;
707
- let rootDev: number | undefined;
708
-
709
- try {
710
- rootDev = (await plugins.fs.stat(rootArg)).dev;
711
- } catch {
712
- return [];
713
- }
714
-
715
- const visit = async (dirArg: string, depthArg: number): Promise<void> => {
716
- if (Date.now() - startedAt > optionsArg.timeoutMs || visitedEntries > optionsArg.maxEntries) {
717
- return;
718
- }
719
-
720
- let entries;
721
- try {
722
- entries = await plugins.fs.readdir(dirArg, { withFileTypes: true });
723
- } catch {
724
- return;
725
- }
726
-
727
- const marker = await readMarkerInDirectory(dirArg);
728
- const markerClassification = classifyToolCachePath(dirArg, marker);
729
- if (markerClassification) {
730
- findings.set(plugins.path.resolve(dirArg), {
731
- path: dirArg,
732
- ...markerClassification,
733
- });
734
- }
735
-
736
- const heuristicClassification = classifyToolCachePath(dirArg);
737
- if (heuristicClassification) {
738
- if (!markerClassification) {
739
- findings.set(plugins.path.resolve(dirArg), {
740
- path: dirArg,
741
- ...heuristicClassification,
742
- });
743
- }
744
- if (heuristicClassification.kind === 'docker-registry-cache') {
745
- for (const entry of entries) {
746
- if (Date.now() - startedAt > optionsArg.timeoutMs || visitedEntries > optionsArg.maxEntries) {
747
- return;
748
- }
749
- visitedEntries++;
750
- if (!entry.isDirectory()) {
751
- continue;
752
- }
753
- const entryPath = plugins.path.join(dirArg, entry.name);
754
- const entryMarker = await readMarkerInDirectory(entryPath);
755
- const entryMarkerClassification = classifyToolCachePath(entryPath, entryMarker);
756
- if (entryMarkerClassification) {
757
- findings.set(plugins.path.resolve(entryPath), {
758
- path: entryPath,
759
- ...entryMarkerClassification,
760
- });
761
- }
762
- }
763
- }
764
- return;
765
- }
766
-
767
- if (depthArg >= optionsArg.maxDepth) {
768
- return;
769
- }
770
-
771
- for (const entry of entries) {
772
- if (Date.now() - startedAt > optionsArg.timeoutMs || visitedEntries > optionsArg.maxEntries) {
773
- return;
774
- }
775
- visitedEntries++;
776
- if (!entry.isDirectory() || shouldSkipWorkspaceDir(entry.name)) {
777
- continue;
778
- }
779
- const entryPath = plugins.path.join(dirArg, entry.name);
780
- let stat;
781
- try {
782
- stat = await plugins.fs.stat(entryPath);
783
- } catch {
784
- continue;
785
- }
786
- if (rootDev !== undefined && stat.dev !== rootDev) {
787
- continue;
788
- }
789
- await visit(entryPath, depthArg + 1);
790
- }
791
- };
792
-
793
- await visit(rootArg, 0);
794
- return [...findings.values()];
681
+ export async function collectToolCachePathsFromRoot(root: string, options: IWorkspaceScanOptions): Promise<IToolCacheFinding[]> {
682
+ return (await scanWorkspace(root, options)).findings;
795
683
  }
796
684
 
797
685
  async function getWorkspaceRoots(): Promise<string[]> {
@@ -806,34 +694,34 @@ async function getWorkspaceRoots(): Promise<string[]> {
806
694
  return [...roots];
807
695
  }
808
696
 
809
- export async function collectProjectToolCacheDiagnostics(ctx: IRuntimeContext): Promise<IToolCacheFinding[]> {
810
- const roots = await getWorkspaceRoots();
697
+ export async function collectProjectToolCacheReport(ctx: IRuntimeContext, roots?: string[]) {
811
698
  const findings: IToolCacheFinding[] = [];
812
- for (const root of roots) {
813
- const rootFindings = await collectToolCachePathsFromRoot(root, {
814
- maxDepth: 8,
815
- timeoutMs: 45_000,
816
- maxEntries: 50_000,
817
- });
818
- findings.push(...rootFindings);
819
- }
820
-
821
- const uniqueFindings = [...new Map(findings.map((finding) => [plugins.path.resolve(finding.path), finding])).values()];
822
- const sizedFindings: IToolCacheFinding[] = [];
823
- const sizingStartedAt = Date.now();
824
- for (const [index, finding] of uniqueFindings.entries()) {
825
- if (index >= toolCacheSizingLimit || Date.now() - sizingStartedAt > toolCacheSizingTimeoutMs) {
826
- sizedFindings.push(finding);
699
+ const issues: IScanIssue[] = [];
700
+ const scanRoots = roots?.length ? roots : await getWorkspaceRoots();
701
+ for (const root of scanRoots) {
702
+ const result = await scanWorkspace(root, { maxDepth: 8, timeoutMs: 45_000, maxEntries: 50_000 });
703
+ findings.push(...result.findings);
704
+ issues.push(...result.issues);
705
+ }
706
+ const unique = [...new Map(findings.map((finding) => [plugins.path.resolve(finding.path), finding])).values()];
707
+ const deadline = Date.now() + toolCacheSizingTimeoutMs;
708
+ for (const [index, finding] of unique.entries()) {
709
+ if (index >= toolCacheSizingLimit || Date.now() >= deadline) {
710
+ issues.push({ path: finding.path, reason: 'size-unavailable', detail: 'Sizing budget exhausted.' });
827
711
  continue;
828
712
  }
829
- const size = await readDuTotal(ctx, finding.path, ctx.sudoAvailable);
830
- sizedFindings.push({
831
- ...finding,
832
- bytes: size?.bytes,
713
+ const result = await runCommand(ctx, 'du', ['-sx', '--block-size=1', '--', finding.path], {
714
+ asRoot: ctx.sudoAvailable, timeoutSeconds: Math.max(1, Math.min(ctx.duTimeoutSeconds, Math.floor((deadline - Date.now()) / 1000))),
833
715
  });
716
+ const size = result.code === 0 ? parseDuOutput(result.stdout)[0] : undefined;
717
+ if (size) finding.bytes = size.bytes;
718
+ else issues.push({ path: finding.path, reason: 'size-unavailable', detail: result.stderr.trim() || 'du failed or returned no size.' });
834
719
  }
720
+ return { roots: scanRoots, toolCaches: unique.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0)), issues, complete: issues.length === 0 };
721
+ }
835
722
 
836
- return sizedFindings.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0));
723
+ export async function collectProjectToolCacheDiagnostics(ctx: IRuntimeContext): Promise<IToolCacheFinding[]> {
724
+ return (await collectProjectToolCacheReport(ctx)).toolCaches;
837
725
  }
838
726
 
839
727
  async function printProjectToolCacheDiagnostics(ctx: IRuntimeContext) {
@@ -844,14 +732,16 @@ async function printProjectToolCacheDiagnostics(ctx: IRuntimeContext) {
844
732
  return;
845
733
  }
846
734
  progress(`Scanning workspace roots: ${roots.join(', ')}`);
847
- const findings = await collectProjectToolCacheDiagnostics(ctx);
735
+ const report = await collectProjectToolCacheReport(ctx);
736
+ for (const issue of report.issues) console.log(`Incomplete scan: ${issue.reason}: ${issue.path}: ${issue.detail}`);
737
+ const findings = report.toolCaches;
848
738
  if (findings.length === 0) {
849
739
  console.log('No known project-local tool caches found.');
850
740
  return;
851
741
  }
852
742
 
853
743
  for (const finding of findings) {
854
- printSizeLine(`${finding.ownerGuess} ${finding.kind} [${finding.risk}] ${finding.path}`, finding.bytes ?? 0);
744
+ console.log(`${finding.bytes === undefined ? "unknown" : formatBytes(finding.bytes)} ${finding.ownerGuess} ${finding.kind} [${finding.risk}] ${finding.path}`);
855
745
  console.log(` suggested: ${finding.suggestedCleanupCommand}`);
856
746
  }
857
747
  }
@@ -1604,7 +1494,15 @@ function printHelp() {
1604
1494
  console.log('');
1605
1495
  console.log('Usage:');
1606
1496
  console.log(' tsdisk Run the disk usage diagnostic scan');
1607
- console.log(' tsdisk --json Print project tool-cache diagnostics as JSON');
1497
+ console.log(' tsdisk scan [--root PATH] [--json] Bounded diagnostics; repeat --root for multiple workspaces');
1498
+ console.log(' tsdisk --json Full diagnostic report including toolCaches, sections, and scan issues');
1499
+ console.log(' tsdisk cleanup [--root PROJECT] [--only rust,docker,pnpm] [--days 14]');
1500
+ console.log(' Preview owning-tool cleanup; defaults to rust,docker in the current project');
1501
+ console.log(' tsdisk cleanup --apply [--yes] Execute after review; JSON/noninteractive execution requires --yes');
1502
+ console.log(' --dry-run Explicit cleanup preview; cannot be combined with --apply');
1503
+ console.log(' Cleanup uses project-installed tsrust/tsdocker and pnpm store prune, without sudo or raw cache deletion.');
1504
+ console.log(' --days applies only to Rust targets. Docker uses its owner checks; pnpm uses reference tracking.');
1505
+ console.log(' scan exit codes: 0 complete, 2 incomplete, 1 invalid input/failure.');
1608
1506
  console.log(' tsdisk --help Show this help');
1609
1507
  console.log(' tsdisk --version Show the version');
1610
1508
  console.log('');
@@ -1687,30 +1585,118 @@ export async function main() {
1687
1585
  printCleanupHints(ctx);
1688
1586
  }
1689
1587
 
1690
- export async function runCli(args = process.argv.slice(2)) {
1691
- if (args.includes('--help') || args.includes('-h')) {
1692
- printHelp();
1693
- return;
1588
+ export interface IDiagnosticSection {
1589
+ name: string;
1590
+ command: string;
1591
+ args: string[];
1592
+ result: ICommandResult;
1593
+ complete: boolean;
1594
+ }
1595
+
1596
+ export async function collectDiskReport(ctx: IRuntimeContext, roots?: string[]) {
1597
+ const caches = await collectProjectToolCacheReport(ctx, roots);
1598
+ const sections: IDiagnosticSection[] = [];
1599
+ const deadline = Date.now() + 120_000;
1600
+ const inspect = async (name: string, command: string, args: string[]) => {
1601
+ const result = Date.now() >= deadline
1602
+ ? { code: 124, stdout: '', stderr: 'Diagnostic command budget exhausted.' }
1603
+ : await runCommand(ctx, command, args, { timeoutSeconds: Math.max(1, Math.min(15, Math.floor((deadline - Date.now()) / 1000))) });
1604
+ const complete = !result.truncated && (result.code === 0 || (command === 'lsof' && result.code === 1 && !result.stderr.trim()));
1605
+ sections.push({ name, command, args, result, complete });
1606
+ return result;
1607
+ };
1608
+ await inspect('filesystems', 'df', ['-hT']);
1609
+ await inspect('mounts', 'findmnt', ['--json']);
1610
+ for (const root of caches.roots) await inspect(`workspace:${root}`, 'du', ['-x', '--max-depth=1', '--block-size=1', '--', root]);
1611
+ for (const path of ['/', ctx.targetHome, `${ctx.targetHome}/.cache`, `${ctx.targetHome}/.local/share`, '/var', '/var/lib']) {
1612
+ await inspect(`usage:${path}`, 'du', ['-x', '--max-depth=1', '--block-size=1', '--', path]);
1613
+ }
1614
+ await inspect('pnpm-store', 'pnpm', ['store', 'path']);
1615
+ await inspect('journal', 'journalctl', ['--disk-usage']);
1616
+ await inspect('deleted-open-files', 'lsof', ['-nP', '+L1']);
1617
+ const contexts = await inspect('docker-contexts', 'docker', ['context', 'ls', '--format', '{{.Name}}']);
1618
+ if (contexts.code === 0) {
1619
+ for (const context of contexts.stdout.trim().split(/\r?\n/).filter(Boolean)) {
1620
+ await inspect(`docker:${context}`, 'docker', ['--context', context, 'system', 'df', '-v']);
1621
+ }
1694
1622
  }
1623
+ return { schemaVersion: 1, generatedAt: new Date().toISOString(), ...caches, sections, complete: caches.complete && sections.every((section) => section.complete) };
1624
+ }
1695
1625
 
1696
- if (args.includes('--version') || args.includes('-v')) {
1697
- console.log(commitinfo.version);
1626
+ async function runCleanup(options: ICliOptions) {
1627
+ const plan = await createCleanupPlan(options);
1628
+ const ctx = await initContext();
1629
+ const previews: Array<{ action: number; result: ICommandResult }> = [];
1630
+ const results: Array<{ action: number; result: ICommandResult }> = [];
1631
+ const print = () => {
1632
+ if (options.json) console.log(JSON.stringify({ ...plan, previews, results }, null, 2));
1633
+ else {
1634
+ console.log(plan.dryRun ? 'Cleanup preview (no deletion)' : 'Cleanup plan');
1635
+ plan.actions.forEach((action, index) => {
1636
+ console.log(`[${index + 1}] ${action.category}: ${action.cwd}`);
1637
+ console.log(` ${action.scope}`);
1638
+ console.log(` command: ${JSON.stringify([action.command, ...action.applyArgs])}`);
1639
+ if (action.blocked) console.log(` blocked: ${action.blocked}`);
1640
+ });
1641
+ for (const item of [...previews, ...results]) {
1642
+ console.log(`[${item.action + 1}] exit ${item.result.code}`);
1643
+ if (item.result.stdout) console.log(item.result.stdout.trimEnd());
1644
+ if (item.result.stderr) console.error(item.result.stderr.trimEnd());
1645
+ }
1646
+ }
1647
+ };
1648
+ if (options.apply && options.json && !options.yes) throw new Error('JSON cleanup requires --apply --yes to execute.');
1649
+ // Complete all previews before any apply command. Missing tools and failed previews block the batch.
1650
+ for (const [index, action] of plan.actions.entries()) {
1651
+ if (action.blocked) continue;
1652
+ const result = await runCommand(ctx, action.command, action.previewArgs ?? ['store', 'path'], { cwd: action.cwd, timeoutSeconds: 120 });
1653
+ previews.push({ action: index, result });
1654
+ }
1655
+ if (plan.actions.some((action) => action.blocked) || previews.some((preview) => preview.result.code !== 0 || preview.result.truncated)) {
1656
+ print();
1657
+ process.exitCode = 1;
1698
1658
  return;
1699
1659
  }
1700
-
1701
- if (args.includes('--json')) {
1702
- try {
1703
- const ctx = await initContext();
1704
- const findings = await collectProjectToolCacheDiagnostics(ctx);
1705
- console.log(JSON.stringify({ toolCaches: findings }, null, 2));
1706
- } catch (error) {
1707
- console.error(error instanceof Error ? error.message : String(error));
1708
- process.exitCode = 1;
1660
+ if (!options.apply) { print(); return; }
1661
+ if (!options.json) print();
1662
+ if (!options.yes) {
1663
+ if (!process.stdin.isTTY) throw new Error('Noninteractive cleanup requires --apply --yes.');
1664
+ if (!await promptYesNo('Run the listed owning-tool cleanup commands?', false)) return;
1665
+ }
1666
+ for (const [index, action] of plan.actions.entries()) {
1667
+ const result = await runCommand(ctx, action.command, action.applyArgs, { cwd: action.cwd, timeoutSeconds: 240 });
1668
+ results.push({ action: index, result });
1669
+ if (result.code !== 0) { process.exitCode = 1; break; }
1670
+ }
1671
+ // JSON emits one complete document; text can show the plan before confirmation.
1672
+ if (options.json) print();
1673
+ else {
1674
+ for (const item of results) {
1675
+ console.log(`[${item.action + 1}] cleanup exit ${item.result.code}`);
1676
+ if (item.result.stdout) console.log(item.result.stdout.trimEnd());
1677
+ if (item.result.stderr) console.error(item.result.stderr.trimEnd());
1709
1678
  }
1710
- return;
1711
1679
  }
1680
+ }
1712
1681
 
1682
+ export async function runCli(args = process.argv.slice(2)) {
1713
1683
  try {
1684
+ const options = parseCliOptions(args);
1685
+ if (options.command === 'help') { printHelp(); return; }
1686
+ if (options.command === 'version') { console.log(commitinfo.version); return; }
1687
+ if (options.command === 'cleanup') { await runCleanup(options); return; }
1688
+ if (options.command === 'scan') {
1689
+ const report = await collectDiskReport(await initContext(), options.roots);
1690
+ if (options.json) console.log(JSON.stringify(report, null, 2));
1691
+ else {
1692
+ console.log(`Scan ${report.complete ? 'complete' : 'incomplete'}: ${report.toolCaches.length} tool caches`);
1693
+ for (const finding of report.toolCaches) console.log(`${finding.bytes === undefined ? 'unknown' : formatBytes(finding.bytes)} [${finding.risk}] ${finding.path}\n ${finding.suggestedCleanupCommand}`);
1694
+ for (const issue of report.issues) console.log(`${issue.reason}: ${issue.path}: ${issue.detail}`);
1695
+ for (const section of report.sections) console.log(`\n${section.name} [${section.complete ? 'complete' : 'incomplete'}]\n${section.result.stdout}${section.result.stderr}`);
1696
+ }
1697
+ if (!report.complete) process.exitCode = 2;
1698
+ return;
1699
+ }
1714
1700
  await main();
1715
1701
  } catch (error) {
1716
1702
  console.error(error instanceof Error ? error.message : String(error));
@@ -1,11 +1,12 @@
1
1
  // node native
2
2
  import { spawn } from 'node:child_process';
3
- import { createReadStream, type ReadStream } from 'node:fs';
3
+ import { constants, createReadStream, type ReadStream } from 'node:fs';
4
4
  import * as fs from 'node:fs/promises';
5
5
  import * as path from 'node:path';
6
6
  import { createInterface } from 'node:readline/promises';
7
7
 
8
8
  export {
9
+ constants,
9
10
  createInterface,
10
11
  createReadStream,
11
12
  fs,
@@ -0,0 +1,124 @@
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
+ }
9
+
10
+ export interface IScanIssue {
11
+ path: string;
12
+ reason: 'read-error' | 'invalid-marker' | 'depth-limit' | 'entry-limit' | 'timeout' | 'mount-boundary' | 'symlink' | 'size-unavailable';
13
+ detail: string;
14
+ }
15
+
16
+ export interface IWorkspaceScanResult {
17
+ root: string;
18
+ findings: IToolCacheFinding[];
19
+ issues: IScanIssue[];
20
+ complete: boolean;
21
+ visitedEntries: number;
22
+ }
23
+
24
+ const excludedDirectories = new Set(['.git', 'node_modules', 'dist_ts', 'dist_ts_web', 'coverage', '.pnpm-store']);
25
+
26
+ export async function scanWorkspace(root: string, options: IWorkspaceScanOptions): Promise<IWorkspaceScanResult> {
27
+ for (const name of ['maxDepth', 'timeoutMs', 'maxEntries'] as const) {
28
+ const value = options[name];
29
+ if (!Number.isSafeInteger(value) || value < (name === 'maxDepth' ? 0 : 1)) {
30
+ throw new Error(`Invalid scan option ${name}: ${value}`);
31
+ }
32
+ }
33
+ const result: IWorkspaceScanResult = { root: plugins.path.resolve(root), findings: [], issues: [], complete: true, visitedEntries: 0 };
34
+ const deadline = Date.now() + options.timeoutMs;
35
+ let stopped = false;
36
+ const issue = (path: string, reason: IScanIssue['reason'], detail: string) => {
37
+ result.complete = false;
38
+ result.issues.push({ path, reason, detail });
39
+ };
40
+ const limited = (path: string) => {
41
+ if (stopped) return true;
42
+ if (Date.now() >= deadline || result.visitedEntries >= options.maxEntries) {
43
+ issue(path, Date.now() >= deadline ? 'timeout' : 'entry-limit', 'Scan stopped before visiting all entries.');
44
+ stopped = true;
45
+ }
46
+ return stopped;
47
+ };
48
+ let rootDev: number;
49
+ try {
50
+ const stat = await plugins.fs.lstat(result.root);
51
+ if (stat.isSymbolicLink() || await plugins.fs.realpath(result.root) !== result.root) {
52
+ issue(result.root, 'symlink', 'Scan roots must not contain symbolic links.');
53
+ return result;
54
+ }
55
+ if (!stat.isDirectory()) throw new Error('Scan root is not a directory.');
56
+ rootDev = stat.dev;
57
+ } catch (error) {
58
+ issue(result.root, 'read-error', error instanceof Error ? error.message : String(error));
59
+ return result;
60
+ }
61
+ const visit = async (directory: string, depth: number): Promise<void> => {
62
+ if (limited(directory)) return;
63
+ result.visitedEntries++;
64
+ try {
65
+ const stat = await plugins.fs.lstat(directory);
66
+ if (stat.isSymbolicLink()) {
67
+ issue(directory, 'symlink', 'Symbolic link skipped.');
68
+ return;
69
+ }
70
+ if (stat.dev !== rootDev) {
71
+ issue(directory, 'mount-boundary', 'Different filesystem skipped.');
72
+ return;
73
+ }
74
+ const entries = await plugins.fs.readdir(directory, { withFileTypes: true });
75
+ let marker;
76
+ if (entries.some((entry) => entry.name === toolCacheMarkerFile)) {
77
+ const markerPath = plugins.path.join(directory, toolCacheMarkerFile);
78
+ const markerStat = await plugins.fs.lstat(markerPath);
79
+ if (!markerStat.isFile() || markerStat.size > 65536) {
80
+ issue(markerPath, 'invalid-marker', 'Marker must be a regular file of at most 64 KiB.');
81
+ } else {
82
+ marker = parseToolCacheMarker(await plugins.fs.readFile(markerPath, 'utf8'));
83
+ if (!marker) issue(markerPath, 'invalid-marker', 'Invalid cache ownership marker.');
84
+ }
85
+ }
86
+ const heuristic = classifyToolCachePath(directory);
87
+ const classification = classifyToolCachePath(directory, marker);
88
+ if (classification) result.findings.push({ path: directory, ...classification });
89
+ // Cache contents are not workspaces. Registry session directories can carry their own markers.
90
+ if (classification && heuristic?.kind !== 'docker-registry-cache') return;
91
+ const children = entries.filter((entry) => !excludedDirectories.has(entry.name));
92
+ if (depth >= options.maxDepth) {
93
+ if (children.some((entry) => entry.isDirectory() || entry.isSymbolicLink())) issue(directory, 'depth-limit', `Maximum scan depth ${options.maxDepth} reached.`);
94
+ return;
95
+ }
96
+ for (const child of children) {
97
+ const childPath = plugins.path.join(directory, child.name);
98
+ if (limited(childPath)) return;
99
+ if (!child.isDirectory() && !child.isSymbolicLink()) {
100
+ result.visitedEntries++;
101
+ continue;
102
+ }
103
+ if (child.isSymbolicLink()) {
104
+ result.visitedEntries++;
105
+ issue(childPath, 'symlink', 'Symbolic link skipped.');
106
+ continue;
107
+ }
108
+ if (heuristic?.kind === 'docker-registry-cache') {
109
+ // Only direct sessions, never registry blob trees.
110
+ const sessionMarker = await plugins.fs.lstat(plugins.path.join(childPath, toolCacheMarkerFile)).catch((error: NodeJS.ErrnoException) => {
111
+ if (error.code === 'ENOENT') return undefined;
112
+ throw error;
113
+ });
114
+ if (!sessionMarker) { result.visitedEntries++; continue; }
115
+ }
116
+ await visit(childPath, depth + 1);
117
+ }
118
+ } catch (error) {
119
+ issue(directory, 'read-error', error instanceof Error ? error.message : String(error));
120
+ }
121
+ };
122
+ await visit(result.root, 0);
123
+ return result;
124
+ }
@@ -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',