|
| 1 | +import { statSync } from "node:fs"; |
| 2 | +import { readGlobalManifest } from "./cache.mjs"; |
| 3 | +import { readManifest } from "./manifest.mjs"; |
| 4 | +import { readMisses } from "./misses.mjs"; |
| 5 | + |
| 6 | +const TOP_MISSES = 5; |
| 7 | + |
| 8 | +function emptyReport() { |
| 9 | + return { |
| 10 | + total_resolves: 0, |
| 11 | + by_type: {}, |
| 12 | + by_source: {}, |
| 13 | + by_provider: {}, |
| 14 | + by_via: {}, |
| 15 | + misses: 0, |
| 16 | + hit_rate: null, |
| 17 | + top_missed_intents: {}, |
| 18 | + global_cache_assets: 0, |
| 19 | + global_cache_disk_bytes: 0, |
| 20 | + cross_project_reuse: 0, |
| 21 | + }; |
| 22 | +} |
| 23 | + |
| 24 | +function increment(map, key) { |
| 25 | + if (!key) return; |
| 26 | + map[key] = (map[key] || 0) + 1; |
| 27 | +} |
| 28 | + |
| 29 | +function timestampOf(record) { |
| 30 | + return record?.ts || record?.timestamp || record?.created_at || record?.createdAt || null; |
| 31 | +} |
| 32 | + |
| 33 | +function inWindow(record, cutoff) { |
| 34 | + if (!cutoff) return true; |
| 35 | + const ts = timestampOf(record); |
| 36 | + // Older manifest records may not carry a timestamp; keep them in the report |
| 37 | + // because --days can only window records/misses that carry a ts/timestamp. |
| 38 | + if (!ts) return true; |
| 39 | + const time = Date.parse(ts); |
| 40 | + return Number.isNaN(time) ? true : time >= cutoff; |
| 41 | +} |
| 42 | + |
| 43 | +function sourceOf(record) { |
| 44 | + return record?._source || record?.source || record?.provenance?.source || "unknown"; |
| 45 | +} |
| 46 | + |
| 47 | +function normalizeIntent(intent) { |
| 48 | + return String(intent ?? "") |
| 49 | + .trim() |
| 50 | + .toLowerCase() |
| 51 | + .replace(/\s+/g, " "); |
| 52 | +} |
| 53 | + |
| 54 | +function topMissedIntents(misses) { |
| 55 | + const grouped = {}; |
| 56 | + for (const miss of misses) { |
| 57 | + const type = miss?.type || "unknown"; |
| 58 | + const intent = normalizeIntent(miss?.intent); |
| 59 | + if (!intent) continue; |
| 60 | + grouped[type] ||= {}; |
| 61 | + grouped[type][intent] = (grouped[type][intent] || 0) + 1; |
| 62 | + } |
| 63 | + const out = {}; |
| 64 | + for (const [type, intents] of Object.entries(grouped)) { |
| 65 | + out[type] = Object.entries(intents) |
| 66 | + .map(([intent, count]) => ({ intent, count })) |
| 67 | + .sort((a, b) => b.count - a.count || a.intent.localeCompare(b.intent)) |
| 68 | + .slice(0, TOP_MISSES); |
| 69 | + } |
| 70 | + return out; |
| 71 | +} |
| 72 | + |
| 73 | +function diskBytes(records) { |
| 74 | + let total = 0; |
| 75 | + for (const record of records) { |
| 76 | + const p = record?.cached_path || record?.path; |
| 77 | + if (!p) continue; |
| 78 | + try { |
| 79 | + total += statSync(p).size; |
| 80 | + } catch { |
| 81 | + // cache entries can outlive files; stats skips missing files |
| 82 | + } |
| 83 | + } |
| 84 | + return total; |
| 85 | +} |
| 86 | + |
| 87 | +export function buildStats({ projectDir, days, now = Date.now() } = {}) { |
| 88 | + // Only a positive finite --days windows the report; null / NaN / <= 0 mean |
| 89 | + // "all time" rather than silently excluding everything (a negative cutoff |
| 90 | + // would land in the future and drop every record). The reads below are each |
| 91 | + // best-effort (they return [] / skip on IO errors), so there is no top-level |
| 92 | + // catch masking a real logic bug as an all-zero "no usage" report. |
| 93 | + const n = Number(days); |
| 94 | + const cutoff = Number.isFinite(n) && n > 0 ? Number(now) - n * 24 * 60 * 60 * 1000 : null; |
| 95 | + const records = (projectDir ? readManifest(projectDir) : []).filter((r) => inWindow(r, cutoff)); |
| 96 | + const misses = readMisses().filter((miss) => inWindow(miss, cutoff)); |
| 97 | + const globalRecords = readGlobalManifest(); |
| 98 | + const report = emptyReport(); |
| 99 | + |
| 100 | + report.total_resolves = records.length; |
| 101 | + report.misses = misses.length; |
| 102 | + for (const record of records) { |
| 103 | + increment(report.by_type, record?.type || "unknown"); |
| 104 | + increment(report.by_source, sourceOf(record)); |
| 105 | + increment(report.by_provider, record?.provenance?.provider); |
| 106 | + increment(report.by_via, record?.provenance?.via); |
| 107 | + } |
| 108 | + |
| 109 | + const attempts = report.total_resolves + report.misses; |
| 110 | + report.hit_rate = attempts === 0 ? null : report.total_resolves / attempts; |
| 111 | + report.top_missed_intents = topMissedIntents(misses); |
| 112 | + report.global_cache_assets = globalRecords.length; |
| 113 | + report.global_cache_disk_bytes = diskBytes(globalRecords); |
| 114 | + report.cross_project_reuse = globalRecords.filter((r) => r?.provenance?.reused_by).length; |
| 115 | + |
| 116 | + return report; |
| 117 | +} |
0 commit comments