feat: 0.9.0 忽略规则、增量缓存跨会话持久化、快照基线、字符级预算自适应与性能基准E2E - 忽略规则(gitignore简化子集:目录整树剪枝不枚举不计数、扩展名通配与名字匹配、随偏好持久化)、扫描增量缓存落盘userData跨会话自动注入(LRU 8对、防御性解析、渲染端零改动)、快照基线(保存/加载JSON对照六类状态变迁统计与条目标注、对照随重扫刷新、报告附快照口径)、字符级变更组预算随输入规模自适应(下限与固定值一致小输入零变化、大输入按总组流半数放宽上限100万封顶)、大文件性能基准E2E(5万行<15s与300KB GBK<10s耗时断言)+新功能E2E、文档与版本号同步
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import type { ScanCacheEntry } from './folderScan'
|
||||
|
||||
/**
|
||||
* 文件夹扫描增量缓存的跨会话持久化纯数据模块(无 electron/fs 依赖,vitest 直接测试)。
|
||||
* 磁盘读写由主进程调用方完成(userData/scan-cache.json)。
|
||||
*
|
||||
* 结构:{ version, pairs: { [目录对键]: { cache, savedAt } } }
|
||||
* - 目录对键归一化:反斜杠→斜杠、去尾分隔符、Windows 盘符小写
|
||||
* (D:\Foo 与 d:/Foo 同键;大小写敏感文件系统的路径不强制小写,避免误撞键);
|
||||
* - LRU 上限 8 对,按 savedAt 淘汰最旧(正在写入的目录对永不淘汰);
|
||||
* - 解析全部防御性校验:JSON 非法 / 版本不符 → 整体回退空存储;
|
||||
* 单条缓存条目形态异常 → 丢弃该条(pair 内其余条目保留)。
|
||||
*/
|
||||
|
||||
/** 存储结构版本(不兼容变更时递增,旧文件整体作废) */
|
||||
export const SCAN_CACHE_VERSION = 1
|
||||
/** 保存的目录对数量上限(超出按 savedAt 淘汰最旧) */
|
||||
export const SCAN_CACHE_PAIRS_MAX = 8
|
||||
|
||||
export interface StoredScanCache {
|
||||
cache: ScanCacheEntry[]
|
||||
/** 写入时间戳(LRU 淘汰依据) */
|
||||
savedAt: number
|
||||
}
|
||||
|
||||
export interface ScanCacheStoreData {
|
||||
version: number
|
||||
pairs: Record<string, StoredScanCache>
|
||||
}
|
||||
|
||||
/** 空存储(解析失败/文件不存在时的回退) */
|
||||
export const emptyScanCacheStore = (): ScanCacheStoreData => ({
|
||||
version: SCAN_CACHE_VERSION,
|
||||
pairs: {}
|
||||
})
|
||||
|
||||
/** 单一路径归一化:反斜杠→斜杠、去尾分隔符、盘符小写(其余保留原大小写) */
|
||||
function normDir(p: string): string {
|
||||
let s = p.replace(/\\/g, '/')
|
||||
while (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1)
|
||||
if (s.length >= 2 && s[1] === ':') s = s[0].toLowerCase() + s.slice(1)
|
||||
return s
|
||||
}
|
||||
|
||||
/** 目录对键:两个归一化路径以 | 拼接(| 不出现在路径合法字符中) */
|
||||
export function pairKey(leftDir: string, rightDir: string): string {
|
||||
return `${normDir(leftDir)}|${normDir(rightDir)}`
|
||||
}
|
||||
|
||||
/** 缓存条目的合法状态值 */
|
||||
const CACHE_STATUSES = ['same', 'semantic-same', 'different', 'left-only', 'right-only']
|
||||
|
||||
function isFingerprint(v: unknown): v is { size: number; mtime: number } {
|
||||
if (typeof v !== 'object' || v === null) return false
|
||||
const f = v as Record<string, unknown>
|
||||
return (
|
||||
typeof f.size === 'number' &&
|
||||
Number.isFinite(f.size) &&
|
||||
typeof f.mtime === 'number' &&
|
||||
Number.isFinite(f.mtime)
|
||||
)
|
||||
}
|
||||
|
||||
/** 单条缓存条目形态校验(盘上数据不可信,逐字段防御) */
|
||||
function isCacheEntry(v: unknown): v is ScanCacheEntry {
|
||||
if (typeof v !== 'object' || v === null) return false
|
||||
const e = v as Record<string, unknown>
|
||||
if (typeof e.rel !== 'string' || e.rel === '') return false
|
||||
if (typeof e.status !== 'string' || !CACHE_STATUSES.includes(e.status)) return false
|
||||
if (e.leftFp !== null && !isFingerprint(e.leftFp)) return false
|
||||
if (e.rightFp !== null && !isFingerprint(e.rightFp)) return false
|
||||
if (e.semSame !== null && typeof e.semSame !== 'boolean') return false
|
||||
if (e.approximate !== undefined && typeof e.approximate !== 'boolean') return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析持久化文本为存储对象:JSON 非法 / 版本不符 / 顶层结构异常回退空存储;
|
||||
* 单个 pair 或单条 entry 异常仅丢弃该部分,其余保留。
|
||||
*/
|
||||
export function parseScanCacheStore(raw: string | null | undefined): ScanCacheStoreData {
|
||||
if (!raw) return emptyScanCacheStore()
|
||||
try {
|
||||
const obj = JSON.parse(raw) as Record<string, unknown>
|
||||
if (obj.version !== SCAN_CACHE_VERSION) return emptyScanCacheStore()
|
||||
if (typeof obj.pairs !== 'object' || obj.pairs === null || Array.isArray(obj.pairs)) {
|
||||
return emptyScanCacheStore()
|
||||
}
|
||||
const pairs: Record<string, StoredScanCache> = {}
|
||||
for (const [key, val] of Object.entries(obj.pairs as Record<string, unknown>)) {
|
||||
if (typeof val !== 'object' || val === null) continue
|
||||
const p = val as Record<string, unknown>
|
||||
if (!Array.isArray(p.cache) || typeof p.savedAt !== 'number' || !Number.isFinite(p.savedAt)) {
|
||||
continue
|
||||
}
|
||||
const cache = p.cache.filter(isCacheEntry)
|
||||
pairs[key] = { cache, savedAt: p.savedAt }
|
||||
}
|
||||
return { version: SCAN_CACHE_VERSION, pairs }
|
||||
} catch {
|
||||
return emptyScanCacheStore()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入/更新一对目录的缓存(LRU 上限淘汰):
|
||||
* 深拷贝条目(防调用方后续复用引用);超上限按 savedAt 升序淘汰最旧,
|
||||
* 当前写入的目录对(savedAt = now,最新)不在淘汰之列。
|
||||
*/
|
||||
export function putScanCache(
|
||||
data: ScanCacheStoreData,
|
||||
leftDir: string,
|
||||
rightDir: string,
|
||||
cache: ScanCacheEntry[],
|
||||
now: number
|
||||
): ScanCacheStoreData {
|
||||
const pairs: Record<string, StoredScanCache> = { ...data.pairs }
|
||||
const key = pairKey(leftDir, rightDir)
|
||||
pairs[key] = { cache: cache.map((c) => ({ ...c })), savedAt: now }
|
||||
const keys = Object.keys(pairs)
|
||||
if (keys.length > SCAN_CACHE_PAIRS_MAX) {
|
||||
const others = keys
|
||||
.filter((k) => k !== key)
|
||||
.sort((a, b) => pairs[a].savedAt - pairs[b].savedAt)
|
||||
const overflow = keys.length - SCAN_CACHE_PAIRS_MAX
|
||||
for (let i = 0; i < overflow && i < others.length; i++) delete pairs[others[i]]
|
||||
}
|
||||
return { version: SCAN_CACHE_VERSION, pairs }
|
||||
}
|
||||
|
||||
/** 读取一对目录的缓存(不存在返回 null,调用方按无缓存全量扫描) */
|
||||
export function getScanCache(
|
||||
data: ScanCacheStoreData,
|
||||
leftDir: string,
|
||||
rightDir: string
|
||||
): ScanCacheEntry[] | null {
|
||||
const hit = data.pairs[pairKey(leftDir, rightDir)]
|
||||
return hit !== undefined ? hit.cache : null
|
||||
}
|
||||
Reference in New Issue
Block a user