From 838885821e4b2239e33497ba67723f07db247758 Mon Sep 17 00:00:00 2001 From: boyuliu <22675027+byliu-labs@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:53:56 +0800 Subject: [PATCH 1/3] Preserve scan coverage before comparing history and forecasting growth --- src/api/openapi.ts | 8 +- src/models/types.ts | 2 + src/services/boundaryReport.ts | 10 +-- src/services/budgetGauges.ts | 10 ++- src/services/forecast.ts | 5 +- src/services/journal.ts | 4 +- src/services/scheduler.ts | 5 +- src/services/snapshots.ts | 37 ++++++++- tests/budgetGauges.test.ts | 6 +- tests/forecast.test.ts | 1 + tests/journal.test.ts | 2 + tests/scanBoundaryEngines.test.ts | 3 +- tests/snapshotCoverage.test.ts | 123 ++++++++++++++++++++++++++++++ tests/whatsNew.test.ts | 1 + 14 files changed, 191 insertions(+), 26 deletions(-) create mode 100644 tests/snapshotCoverage.test.ts diff --git a/src/api/openapi.ts b/src/api/openapi.ts index ace5fed2..041a08cf 100644 --- a/src/api/openapi.ts +++ b/src/api/openapi.ts @@ -1487,7 +1487,7 @@ export const ENDPOINTS: EndpointDescriptor[] = [ responses: { '200': jsonResponse('Diff', ref('CompareResult')), '400': errorResponse('Scans cover different roots'), - '409': errorResponse('BOUNDARY_CHANGED — the two scans stopped at different drives mounted inside the folder (one under "Stay on this drive" left a drive out that the other did not, or one went into a drive the other did not find), so a diff would show a drive as files added or removed'), + '409': errorResponse('BOUNDARY_CHANGED — the scans counted different mounted drives; COVERAGE_INCOMPARABLE — either scan reported incomplete or unknown listing coverage, so the difference cannot prove file growth or shrinkage'), }, }, { @@ -1876,7 +1876,7 @@ export const ENDPOINTS: EndpointDescriptor[] = [ tag: 'history', destructive: false, parameters: [queryParam('path', 'Root to list snapshots for'), queryParam('all', "'true' for every snapshot, slim", bool())], - responses: { '200': jsonResponse('History', opaque('{roots[]} | {rootPath, snapshots[]} | {snapshots[]}. A snapshot carries boundary — { rule: "stay", notEntered[] } or { rule: "cross", entered[] } — only when a drive was mounted inside its folder (Track U); history never compares across two different boundaries')) }, + responses: { '200': jsonResponse('History', opaque('{roots[]} | {rootPath, snapshots[]} | {snapshots[]}. Snapshot coverage is complete, partial or unknown for reported listing errors; absent on legacy history means unknown. A snapshot carries boundary — { rule: "stay", notEntered[] } or { rule: "cross", entered[] } — only when a drive was mounted inside its folder (Track U); history never compares across different boundaries or incomplete/unknown coverage')) }, }, { method: 'get', @@ -1890,7 +1890,7 @@ export const ENDPOINTS: EndpointDescriptor[] = [ ], responses: { '200': jsonResponse( - 'Historical layout. prevTakenAt and every prevSize are absent (null) when no earlier snapshot exists, or when the one before stopped at different drives mounted inside the folder (Track U, U-24)', + 'Historical layout. prevTakenAt and every prevSize are absent (null) when no earlier snapshot exists, the scans counted different mounted drives, or either snapshot has incomplete or unknown listing coverage', opaque('snapshot, prevTakenAt, root, scanRootPath, maxDepth, minSize, nodes[], tree'), ), '404': errorResponse('No snapshot trees recorded'), @@ -1906,7 +1906,7 @@ export const ENDPOINTS: EndpointDescriptor[] = [ responses: { '200': jsonResponse('Diff', opaque('SnapshotDiff: a, b, rootPath, totalDelta, entries[]')), '404': errorResponse('Unknown snapshot id'), - '409': errorResponse('BOUNDARY_CHANGED — the two snapshots stopped at different drives mounted inside the folder, so a diff would show a drive as files added or removed'), + '409': errorResponse('BOUNDARY_CHANGED — the snapshots counted different mounted drives; COVERAGE_INCOMPARABLE — either snapshot has incomplete or unknown listing coverage (including history recorded before coverage was persisted)'), }, }, { diff --git a/src/models/types.ts b/src/models/types.ts index 185f23a7..9a54eb05 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -663,6 +663,8 @@ export interface Snapshot { topEntries: SnapshotTopEntry[]; /** True when a deeper tree was stored for the time-slider treemap. */ hasTree?: boolean; + /** Reported listing coverage at scan time. Absent on old history means unknown. */ + coverage?: 'complete' | 'partial' | 'unknown'; /** * Present only when a drive was mounted inside the scanned folder (Track U, * U-24): under `stay` the drives the scan did not enter, under `cross` the diff --git a/src/services/boundaryReport.ts b/src/services/boundaryReport.ts index 0035c582..082b231b 100644 --- a/src/services/boundaryReport.ts +++ b/src/services/boundaryReport.ts @@ -12,7 +12,7 @@ import { } from '../utils/mountBoundaries'; import { storeOf } from './scanStore'; import { storageModeOf } from './storageMode'; -import { boundaryKeyOf, scanBoundaryKey, snapshotBoundaryOf } from './snapshots'; +import { assertComparableCoverage, boundaryKeyOf, scanBoundaryKey, scanComparisonEvidence, snapshotBoundaryOf } from './snapshots'; import { AppError } from '../middleware/errorHandler'; import { rootLivesAt } from '../utils/pathSanitizer'; @@ -117,8 +117,8 @@ export function boundaryReport(scan: ScanResult): BoundaryReport { * alike, with the reason. `scanBoundaryKey` is the same key history uses. */ export function assertSameBoundary(scanA: ScanResult, scanB: ScanResult): void { - if (scanBoundaryKey(scanA) === scanBoundaryKey(scanB)) return; - throw boundaryChanged('scans', scanA.rootPath, snapshotBoundaryOf(scanA), snapshotBoundaryOf(scanB)); + if (scanBoundaryKey(scanA) !== scanBoundaryKey(scanB)) throw boundaryChanged('scans', scanA.rootPath, snapshotBoundaryOf(scanA), snapshotBoundaryOf(scanB)); + assertComparableCoverage(scanComparisonEvidence(scanA), scanComparisonEvidence(scanB)); } /** @@ -127,8 +127,8 @@ export function assertSameBoundary(scanA: ScanResult, scanB: ScanResult): void { * "freed"). */ export function assertSameSnapshotBoundary(a: Snapshot, b: Snapshot): void { - if (boundaryKeyOf(a.boundary) === boundaryKeyOf(b.boundary)) return; - throw boundaryChanged('snapshots', a.rootPath, a.boundary, b.boundary); + if (boundaryKeyOf(a.boundary) !== boundaryKeyOf(b.boundary)) throw boundaryChanged('snapshots', a.rootPath, a.boundary, b.boundary); + assertComparableCoverage(a, b); } function boundaryChanged(what: string, rootPath: string, a: Snapshot['boundary'], b: Snapshot['boundary']): AppError { diff --git a/src/services/budgetGauges.ts b/src/services/budgetGauges.ts index 5e4af919..680ec966 100644 --- a/src/services/budgetGauges.ts +++ b/src/services/budgetGauges.ts @@ -34,6 +34,7 @@ export interface BudgetPoint { t: number; v: number; boundary?: SnapshotBoundary; + coverage?: Snapshot['coverage']; } export interface BudgetHistorySeries { @@ -119,8 +120,8 @@ function boundaryWithin(boundary: SnapshotBoundary | undefined, folder: string): return entered.length > 0 ? { rule: 'cross', entered } : undefined; } -function pointOf(t: number, v: number, boundary: SnapshotBoundary | undefined): BudgetPoint { - return boundary ? { t, v, boundary } : { t, v }; +function pointOf(t: number, v: number, boundary: SnapshotBoundary | undefined, coverage: Snapshot['coverage']): BudgetPoint { + return { t, v, coverage: coverage ?? 'unknown', ...(boundary ? { boundary } : {}) }; } /** @@ -132,7 +133,7 @@ function pointOf(t: number, v: number, boundary: SnapshotBoundary | undefined): export async function budgetHistorySeries(budgetPath: string): Promise { const own = await listSnapshots(budgetPath); if (own.length > 0) { - return { points: own.map((s) => pointOf(s.takenAt, s.totalSize, boundaryWithin(s.boundary, budgetPath))), source: 'own-scans' }; + return { points: own.map((s) => pointOf(s.takenAt, s.totalSize, boundaryWithin(s.boundary, budgetPath), s.coverage)), source: 'own-scans' }; } // No direct history — look for scanned roots the budget folder sits under @@ -153,7 +154,7 @@ export async function budgetHistorySeries(budgetPath: string): Promise s.coverage !== 'complete') + ? 'had incomplete or unknown listing coverage, or stopped at different drives mounted inside this folder' + : 'stopped at different drives mounted inside this folder'; return { ...base, status: 'insufficient', - reason: `Not enough history yet — ${count} scan${count === 1 ? '' : 's'} over ${spanDays < 0.05 ? 'less than an hour' : `${round2(spanDays)} day${spanDays === 1 ? '' : 's'}`}. Forecasting needs at least ${MIN_SNAPSHOTS} scans spanning ${MIN_SPAN_DAYS}+ days.${earlier > 0 ? ` ${earlier} earlier scan${earlier === 1 ? '' : 's'} stopped at different drives mounted inside this folder, so ${earlier === 1 ? 'it is' : 'they are'} not compared with these.` : ''}`, + reason: `Not enough history yet — ${count} scan${count === 1 ? '' : 's'} over ${spanDays < 0.05 ? 'less than an hour' : `${round2(spanDays)} day${spanDays === 1 ? '' : 's'}`}. Forecasting needs at least ${MIN_SNAPSHOTS} fully listed scans spanning ${MIN_SPAN_DAYS}+ days.${earlier > 0 ? ` ${earlier} earlier scan${earlier === 1 ? '' : 's'} ${exclusion}, so ${earlier === 1 ? 'it is' : 'they are'} not compared with these.` : ''}`, }; } diff --git a/src/services/journal.ts b/src/services/journal.ts index 36e065d8..81712326 100644 --- a/src/services/journal.ts +++ b/src/services/journal.ts @@ -6,7 +6,7 @@ import { appDataDir } from './storage'; import { isEphemeral } from './portableMode'; import { getAppAttribution } from './appAttribution'; import { readAudit } from './audit'; -import { boundaryKeyOf, buildSnapshotTree, getSnapshotTreeAt, scanBoundaryKey } from './snapshots'; +import { buildSnapshotTree, getSnapshotTreeAt, scanComparisonEvidence, snapshotsComparable } from './snapshots'; import { storeOf } from './scanStore'; import { isInside } from '../utils/pathSanitizer'; import { separatorOf } from '../utils/pathSeparator'; @@ -302,7 +302,7 @@ export async function recordScanJournal(scan: ScanResult, prev: Snapshot): Promi // The two scans stopped at different drives mounted inside the folder // (Track U, U-24): the difference is a drive, not something a process did, // and the journal names no culprit for it. The next scan is the baseline. - if (scanBoundaryKey(scan) !== boundaryKeyOf(prev.boundary)) return 0; + if (!snapshotsComparable(prev, scanComparisonEvidence(scan))) return 0; const store = storeOf(scan); const curr = buildSnapshotTree(store); diff --git a/src/services/scheduler.ts b/src/services/scheduler.ts index 023a7642..8ccba379 100644 --- a/src/services/scheduler.ts +++ b/src/services/scheduler.ts @@ -3,7 +3,7 @@ import { GrowthNotification, ScanResult, ScheduleConfig, Snapshot } from '../mod import { startScan, getScan, scanRootPathOf } from './diskScanner'; import { getSettings, patchSchedule } from './settings'; import { runDuePolicies } from './autopilot'; -import { boundaryKeyOf, listSnapshots, scanBoundaryKey } from './snapshots'; +import { listSnapshots, scanComparisonEvidence, snapshotsComparable } from './snapshots'; import { getForecast } from './forecast'; import { recordScanJournal } from './journal'; import { sanitizePath } from '../utils/pathSanitizer'; @@ -177,8 +177,7 @@ export function growthAlertFor(sched: ScheduleConfig, target: string, prev: Snap if (!done.store) return null; // Another folder's snapshot is no previous size of this one: a link the // scheduler resolved and the scan then followed elsewhere (QA D78). - if (prev.rootPath !== done.rootPath) return null; - if (scanBoundaryKey(done) !== boundaryKeyOf(prev.boundary)) return null; + if (!snapshotsComparable(prev, scanComparisonEvidence(done))) return null; const newSize = done.store.size(done.store.rootId); const delta = newSize - prev.totalSize; const pct = prev.totalSize > 0 ? (delta / prev.totalSize) * 100 : 0; diff --git a/src/services/snapshots.ts b/src/services/snapshots.ts index d4844a63..c7603325 100644 --- a/src/services/snapshots.ts +++ b/src/services/snapshots.ts @@ -7,6 +7,7 @@ import { isEphemeral } from './portableMode'; import { ScanStore, TreeSource, asStore, storeOf } from './scanStore'; import { mountOutcome } from '../utils/mountBoundaries'; import { separatorOf } from '../utils/pathSeparator'; +import { AppError } from '../middleware/errorHandler'; /** * Snapshots — lightweight scan history persisted to snapshots.json in the @@ -267,7 +268,7 @@ export async function getSnapshotTreeAt(rootPath: string, at: number): Promise<{ if (!snapshot) return null; const before = withTrees.filter((s) => s.takenAt < snapshot.takenAt); const last = before.length ? before[before.length - 1] : null; - const prevSnap = last && boundaryKeyOf(last.boundary) === boundaryKeyOf(snapshot.boundary) ? last : null; + const prevSnap = last && snapshotsComparable(last, snapshot) ? last : null; return { snapshot, tree: trees[snapshot.id], @@ -315,13 +316,41 @@ export function scanBoundaryKey(scan: ScanResult): string { return boundaryKeyOf(snapshotBoundaryOf(scan)); } +/** A settled scan can still report failed listings. This cannot detect errors an engine never reports. */ +export function scanCoverage(scan: ScanResult): NonNullable { + if ([scan.deniedDirs, scan.unreadableDirs, scan.deniedEntries, scan.unreadableEntries, scan.vanishedDirs].some((n) => (n ?? 0) > 0)) return 'partial'; + if (scan.status !== 'complete') return 'unknown'; + return 'complete'; +} + +type ComparisonEvidence = Pick; + +/** Current scan evidence, in the same shape persisted history uses. */ +export function scanComparisonEvidence(scan: ScanResult): ComparisonEvidence { + return { rootPath: scan.rootPath, coverage: scanCoverage(scan), boundary: snapshotBoundaryOf(scan) }; +} + +/** Unknown or partial listings cannot prove files appeared, disappeared, grew or shrank. */ +export function snapshotsComparable(a: ComparisonEvidence, b: ComparisonEvidence): boolean { + return a.coverage === 'complete' && b.coverage === 'complete' && a.rootPath === b.rootPath && + boundaryKeyOf(a.boundary) === boundaryKeyOf(b.boundary); +} + +export function assertComparableCoverage(a: ComparisonEvidence, b: ComparisonEvidence): void { + if (a.coverage === 'complete' && b.coverage === 'complete') return; + throw new AppError(409, 'COVERAGE_INCOMPARABLE', + `These measurements have ${a.coverage ?? 'unknown'} and ${b.coverage ?? 'unknown'} listing coverage. ` + + 'Incomplete or unknown coverage cannot prove file growth or shrinkage; compare two fully listed scans.'); +} + /** The snapshots since the boundary last changed: the latest one's run of equal keys, oldest first. */ export function currentBaseline(snaps: readonly Snapshot[]): Snapshot[] { const sorted = [...snaps].sort((a, b) => a.takenAt - b.takenAt); if (sorted.length === 0) return sorted; - const key = boundaryKeyOf(sorted[sorted.length - 1].boundary); + const latest = sorted[sorted.length - 1]; + if (latest.coverage !== 'complete') return []; let start = sorted.length - 1; - while (start > 0 && boundaryKeyOf(sorted[start - 1].boundary) === key) start--; + while (start > 0 && snapshotsComparable(sorted[start - 1], latest)) start--; return sorted.slice(start); } @@ -360,6 +389,7 @@ async function writeSnapshot(scan: ScanResult): Promise { dirCount: scan.dirCount, topEntries, hasTree: true, + coverage: scanCoverage(scan), }; const boundary = snapshotBoundaryOf(scan); if (boundary) snapshot.boundary = boundary; @@ -446,6 +476,7 @@ export async function getSnapshot(id: string): Promise { /** Size deltas between two snapshots (top-level entries matched by path). */ export function diffSnapshots(a: Snapshot, b: Snapshot): SnapshotDiff { + assertComparableCoverage(a, b); const byPath = new Map(); for (const e of a.topEntries) { byPath.set(e.path, { name: e.name, path: e.path, type: e.type, sizeA: e.size, sizeB: null, delta: -e.size }); diff --git a/tests/budgetGauges.test.ts b/tests/budgetGauges.test.ts index 3dea5df0..2d8ebab2 100644 --- a/tests/budgetGauges.test.ts +++ b/tests/budgetGauges.test.ts @@ -24,12 +24,12 @@ const GB = 1024 ** 3; const NOW = 1_800_000_000_000; // fixed "now" so every projection is deterministic /** A series point `daysAgo` days back from NOW at `v` bytes. */ -function pt(daysAgo: number, v: number): { t: number; v: number } { - return { t: NOW - daysAgo * DAY, v }; +function pt(daysAgo: number, v: number): { t: number; v: number; coverage: 'complete' } { + return { t: NOW - daysAgo * DAY, v, coverage: 'complete' }; } function snap(rootPath: string, id: string, daysAgo: number, totalSize: number): Snapshot { - return { id, rootPath, takenAt: NOW - daysAgo * DAY, totalSize, fileCount: 1, dirCount: 1, topEntries: [] }; + return { id, rootPath, takenAt: NOW - daysAgo * DAY, totalSize, fileCount: 1, dirCount: 1, topEntries: [], coverage: 'complete' }; } /** Overwrite the isolated snapshot store with exactly these snapshots. */ diff --git a/tests/forecast.test.ts b/tests/forecast.test.ts index 04d91ccb..92a8c92e 100644 --- a/tests/forecast.test.ts +++ b/tests/forecast.test.ts @@ -21,6 +21,7 @@ function snap(daysAgo: number, totalSize: number, tops: { name: string; size: nu fileCount: 1, dirCount: 1, topEntries: tops.map((t) => ({ name: t.name, path: `/r/${t.name}`, size: t.size, type: 'dir' as const })), + coverage: 'complete', }; } diff --git a/tests/journal.test.ts b/tests/journal.test.ts index 52313823..e0573178 100644 --- a/tests/journal.test.ts +++ b/tests/journal.test.ts @@ -399,6 +399,7 @@ test('recordScanJournal turns a scan-vs-snapshot delta into an attributed entry' // journal must write is a removal it cannot attribute to any app. const prev: Snapshot = { id: 'prev-snapshot', + coverage: 'complete', rootPath: fixture, takenAt: Date.now() - 60_000, totalSize: currentTotal + 300 * MB, @@ -432,6 +433,7 @@ test('a scan that changed nothing significant writes nothing', async () => { const size = store.size(store.rootId); const prev: Snapshot = { id: 'prev-still', rootPath: fixture, takenAt: Date.now() - 60_000, + coverage: 'complete', totalSize: size, fileCount: 1, dirCount: 1, topEntries: [{ name: 'small.txt', path: path.join(fixture, 'small.txt'), size, type: 'file' }], }; diff --git a/tests/scanBoundaryEngines.test.ts b/tests/scanBoundaryEngines.test.ts index 46dba4d3..5fdc2f71 100644 --- a/tests/scanBoundaryEngines.test.ts +++ b/tests/scanBoundaryEngines.test.ts @@ -434,6 +434,7 @@ function snapshotFor(root: string, takenAt: number, totalSize: number, boundary? dirCount: 3, topEntries: [{ name: 'other', path: path.join(root, 'other'), size: totalSize, type: 'dir' }], hasTree: false, + coverage: 'complete', ...(boundary ? { boundary } : {}), }; } @@ -857,7 +858,7 @@ test('the boundary key: a crossing scan names the drives it went into, and a sna assert.equal(boundaryKeyOf(crossX), boundaryKeyOf({ rule: 'cross', entered: [x] }), 'the same drive twice'); assert.equal(boundaryKeyOf({ rule: 'cross', entered: [] }), '', 'no drive went in: as a scan that met none'); assert.equal(boundaryKeyOf({ rule: 'stay', notEntered: [x, y] }), `${x}\n${y}`, 'a stay key is as it was'); - const snap = (takenAt: number, boundary?: Snapshot['boundary']): Snapshot => ({ id: String(takenAt), rootPath: '/r', takenAt, totalSize: takenAt, fileCount: 1, dirCount: 1, topEntries: [], ...(boundary ? { boundary } : {}) }); + const snap = (takenAt: number, boundary?: Snapshot['boundary']): Snapshot => ({ id: String(takenAt), rootPath: '/r', takenAt, totalSize: takenAt, fileCount: 1, dirCount: 1, topEntries: [], coverage: 'complete', ...(boundary ? { boundary } : {}) }); const ids = (list: Snapshot[]): string[] => list.map((s) => s.id); assert.deepEqual(ids(currentBaseline([snap(1), snap(2), snap(3)])), ['1', '2', '3'], 'history from before U-24 is one run, as it was'); assert.deepEqual(ids(currentBaseline([snap(1), snap(2, crossX), snap(3, crossX)])), ['2', '3'], 'the forecast starts where the drive went in'); diff --git a/tests/snapshotCoverage.test.ts b/tests/snapshotCoverage.test.ts new file mode 100644 index 00000000..fa9a1dc3 --- /dev/null +++ b/tests/snapshotCoverage.test.ts @@ -0,0 +1,123 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import http from 'node:http'; +import { fileTempDir, isolatedDataDir } from './fixtures/dataDir'; +const DATA = isolatedDataDir('treemap-coverage-data-'); + +import { startScan, getScan } from '../src/services/diskScanner'; +import { updateSettings } from '../src/services/settings'; +import { currentBaseline, listSnapshots, saveSnapshot, diffSnapshots, getSnapshotTreeAt } from '../src/services/snapshots'; +import { growthAlertFor } from '../src/services/scheduler'; +import { recordScanJournal, readJournal } from '../src/services/journal'; +import { computeForecast } from '../src/services/forecast'; +import { settled } from '../src/utils/backgroundWrites'; +import { createApp } from '../src/server'; +import { budgetHistorySeries, computeBudgetProjection } from '../src/services/budgetGauges'; +import type { ScanResult, Snapshot } from '../src/models/types'; + +async function scanned(root: string): Promise { + await updateSettings({ engine: 'walker' }); + const scan = await startScan(root); + const limit = Date.now() + 10_000; + while (scan.status === 'running' && Date.now() < limit) await new Promise((r) => setTimeout(r, 10)); + assert.equal(scan.status, 'complete', scan.error); + await settled(); + return getScan(scan.scanId)!; +} + +const NO_CHMOD = process.platform === 'win32' || process.getuid?.() === 0; + +test('denied folder then restored permissions is not file growth, including after restart', { skip: NO_CHMOD }, async (t) => { + const root = fileTempDir('treemap-coverage-tree-'); + const locked = path.join(root, 'locked'); + fs.mkdirSync(locked); + const file = path.join(locked, 'unchanged.txt'); + fs.writeFileSync(file, 'unchanged bytes'); + t.after(() => fs.chmodSync(locked, 0o700)); + const first = await scanned(root); + fs.chmodSync(locked, 0o000); + const denied = await scanned(root); + assert.equal(denied.deniedDirs, 1, 'real scanner encountered the permission gap'); + fs.chmodSync(locked, 0o700); + const restored = await scanned(root); + assert.equal(restored.store!.size(0), first.store!.size(0), 'the files did not change'); + const history = await listSnapshots(root); + const previous = history[1]; + const sched = { id: 'coverage', path: root, intervalHours: 1, thresholdBytes: 1, enabled: true }; + assert.equal(growthAlertFor(sched, root, previous, restored), null, 'permission recovery is not a growth alert'); + assert.deepEqual(currentBaseline(history).map((s) => s.id), [history[2].id], 'partial sample starts a new baseline'); + assert.equal((previous as Snapshot & { coverage?: string }).coverage, 'partial', 'coverage persists with the bytes'); + const child = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', + `import snapshots from './src/services/snapshots.ts'; const h = await snapshots.listSnapshots(${JSON.stringify(root)}); console.log(JSON.stringify({ coverage: h[1].coverage, ids: snapshots.currentBaseline(h).map(s=>s.id) }));`], + { cwd: path.join(__dirname, '..'), env: { ...process.env, TREEMAP_DATA_DIR: DATA }, encoding: 'utf8' }); + assert.equal(child.status, 0, child.stderr); + assert.deepEqual(JSON.parse(child.stdout), { coverage: 'partial', ids: [history[2].id] }); + const historical = await getSnapshotTreeAt(root, history[2].takenAt); + assert.equal(historical!.prev, null, 'time-slider does not call unread bytes added'); + assert.throws(() => diffSnapshots(previous, history[2]), /coverage|incomplete/i); + const server = http.createServer(createApp(path.join(__dirname, '..', 'public'))); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address() as { port: number }; + for (const route of [ + `/api/compare?scanIdA=${denied.scanId}&scanIdB=${restored.scanId}`, + `/api/snapshots/compare?a=${previous.id}&b=${history[2].id}`, + ]) { + const response = await fetch(`http://127.0.0.1:${address.port}${route}`); + assert.equal(response.status, 409); + assert.equal((await response.json() as { code: string }).code, 'COVERAGE_INCOMPARABLE'); + } + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +}); + +test('legacy coverage stays unknown when converted into a folder budget series', async () => { + const root = fileTempDir('treemap-coverage-engine-'); + fs.writeFileSync(path.join(root, 'a.txt'), 'x'); + const scan = await scanned(root); + const current = (await listSnapshots(root))[0]; + const legacy = Array.from({ length: 8 }, (_, i) => ({ + ...current, id: `legacy-${i}`, coverage: undefined, takenAt: i * 86_400_000, + })); + fs.writeFileSync(path.join(DATA, 'snapshots.json'), JSON.stringify({ snapshots: [...legacy, current] })); + const history = await listSnapshots(root); + assert.equal(history.at(-1)!.coverage, 'complete', 'history remains sorted by observation time'); + assert.equal(history.filter((s) => s.coverage === undefined).length, 8); + assert.equal(currentBaseline(history).length, 1, 'unknown evidence is not bridged'); + const series = await budgetHistorySeries(root); + const projection = computeBudgetProjection({ path: root, maxBytes: 10e9 }, 1, series.points); + assert.equal(projection.status, 'insufficient', 'budget conversion preserves unknown coverage'); +}); + +test('legacy snapshots have unknown coverage and cannot establish growth forecasts', () => { + const day = 86_400_000; + const snapshots: Snapshot[] = Array.from({ length: 8 }, (_, i) => ({ + id: String(i), rootPath: '/r', takenAt: i * day, totalSize: (100 + i) * 1e9, + fileCount: 1, dirCount: 1, topEntries: [], + })); + assert.equal(computeForecast(snapshots, 50e9, 9 * day).status, 'insufficient'); + assert.deepEqual(currentBaseline(snapshots), []); + assert.throws(() => diffSnapshots(snapshots[0], snapshots[1]), /coverage|unknown/i); +}); + +test('known incomplete entry or folder samples never feed alerts, journal or forecasts', async () => { + const root = fileTempDir('treemap-coverage-counters-'); + fs.writeFileSync(path.join(root, 'a.txt'), 'x'); + const scan = await scanned(root); + const previous = (await listSnapshots(root))[0]; + const sched = { id: 'coverage', path: root, intervalHours: 1, thresholdBytes: 1, enabled: true }; + for (const field of ['deniedDirs', 'unreadableDirs', 'deniedEntries', 'unreadableEntries', 'vanishedDirs'] as const) { + const partial = { ...scan, [field]: 1 }; + const saved = await saveSnapshot(partial); + assert.equal((saved as Snapshot & { coverage?: string }).coverage, 'partial', field); + assert.equal(growthAlertFor(sched, root, { ...previous, totalSize: -1e9 }, partial), null, field); + const before = (await readJournal(100)).length; + assert.equal(await recordScanJournal(partial, { ...previous, totalSize: 1e9 }), 0, field); + assert.equal((await readJournal(100)).length, before, field); + assert.deepEqual(currentBaseline([previous, saved!]), [], field); + } +}); diff --git a/tests/whatsNew.test.ts b/tests/whatsNew.test.ts index 62b33de3..800d73bd 100644 --- a/tests/whatsNew.test.ts +++ b/tests/whatsNew.test.ts @@ -25,6 +25,7 @@ function snap(over: Partial): Snapshot { fileCount: 0, dirCount: 0, topEntries: [], + coverage: 'complete', ...over, }; } From 206fbeb6cece4f3a432960e2917693d0c409fe8f Mon Sep 17 00:00:00 2001 From: boyuliu <22675027+byliu-labs@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:09:39 +0800 Subject: [PATCH 2/3] test: use shared hang guard for coverage scans --- tests/snapshotCoverage.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/snapshotCoverage.test.ts b/tests/snapshotCoverage.test.ts index fa9a1dc3..a66eaf2f 100644 --- a/tests/snapshotCoverage.test.ts +++ b/tests/snapshotCoverage.test.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { spawnSync } from 'node:child_process'; import http from 'node:http'; import { fileTempDir, isolatedDataDir } from './fixtures/dataDir'; +import { waitFor } from './fixtures/waitFor'; const DATA = isolatedDataDir('treemap-coverage-data-'); import { startScan, getScan } from '../src/services/diskScanner'; @@ -21,8 +22,7 @@ import type { ScanResult, Snapshot } from '../src/models/types'; async function scanned(root: string): Promise { await updateSettings({ engine: 'walker' }); const scan = await startScan(root); - const limit = Date.now() + 10_000; - while (scan.status === 'running' && Date.now() < limit) await new Promise((r) => setTimeout(r, 10)); + await waitFor(() => scan.status !== 'running', 'the listing-coverage scan settling'); assert.equal(scan.status, 'complete', scan.error); await settled(); return getScan(scan.scanId)!; From 30191604faa4d9ce9d531eb8cc4d8bbbf700fb8e Mon Sep 17 00:00:00 2001 From: boyuliu <22675027+byliu-labs@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:37:40 +0800 Subject: [PATCH 3/3] test: canonicalize identity stand-in paths on Windows --- tests/knownSizeOfCost.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/knownSizeOfCost.test.ts b/tests/knownSizeOfCost.test.ts index 803c10a8..dbbef29d 100644 --- a/tests/knownSizeOfCost.test.ts +++ b/tests/knownSizeOfCost.test.ts @@ -11,6 +11,7 @@ process.env.TREEMAP_NO_GDU = '1'; import { startScan } from '../src/services/diskScanner'; import { updateSettings } from '../src/services/settings'; import { knownSizeOf } from '../src/services/policy'; +import { canonicalDirOf } from '../src/utils/pathSanitizer'; import type { ScanResult } from '../src/models/types'; /** @@ -214,6 +215,9 @@ test('a name found by its fold counts only on the volume\'s own word that both n const file = path.join(APART, 'y1'); // made after the scan, beside Y1, whose name folds the same const sibling = path.join(APART, 'Y1'); fs.writeFileSync(file, Buffer.alloc(13, 1)); + // The policy asks at the native parent spelling (Windows temp roots can use an 8.3 alias). + const canonicalFile = canonicalDirOf(file); + const canonicalSibling = canonicalDirOf(sibling); /** * What the stand-in answers when the identity of `file`, then of `sibling`, is asked (bigint, links * not followed). 'gone' is what the volume says of a name nothing is at any more — a scanned sibling @@ -234,7 +238,7 @@ test('a name found by its fold counts only on the volume\'s own word that both n try { for (const [what, [first, second]] of Object.entries(answers)) { host.lstatSync = (p, options) => { - const answer = p === file ? first : p === sibling ? second : null; + const answer = p === canonicalFile ? first : p === canonicalSibling ? second : null; if (answer === null || options?.bigint !== true) return real(p, options); if (answer === 'throws') throw Object.assign(new Error(`EIO: i/o error, lstat '${String(p)}'`), { code: 'EIO' }); if (answer === 'gone') {