fix(daily-cache): keep prototype-named project keys; make filter tests date-deterministic

Two issues on top of the --project/--exclude durable-headline fix:

- sanitizeProjects dropped any project whose key is an Object.prototype member
  name (constructor, valueOf, __proto__, ...). A project key is a directory
  basename, so such a name is legitimate, and dropping it left the day's
  per-project split summing to less than the day cost — so the sliced,
  project-filtered headline silently lost that project's spend with no footnote.
  The keys are written via setOwn (defineProperty), so keeping them is
  pollution-safe; only the redundant `name in Object.prototype` guard is removed.
  Regression test added (mutation-checked: fails without the guard removed).

- The new project-filter tests seeded a carried day 10 days ago but ranged over
  the calendar month, so within the first 10 days of a month that day fell out
  of range and the tests went red. Replaced with a fixed 20-day window that
  always spans the seeded day.
This commit is contained in:
AgentSeal 2026-08-02 01:16:35 +02:00
parent 4bfcc020f6
commit a769b5008a
2 changed files with 51 additions and 14 deletions

View file

@ -257,7 +257,13 @@ function sanitizeProjects(raw: unknown): { projects?: DailyEntry['projects'] } {
if (!isRecord(raw)) return {} if (!isRecord(raw)) return {}
const out: NonNullable<DailyEntry['projects']> = {} const out: NonNullable<DailyEntry['projects']> = {}
for (const [name, p] of Object.entries(raw)) { for (const [name, p] of Object.entries(raw)) {
if (name in Object.prototype || !isRecord(p)) continue // A project key is a directory basename, so it can legitimately be a
// prototype-member name ("constructor", "valueOf", ...). `setOwn` writes it
// as an own property via defineProperty, so keeping it is pollution-safe —
// and dropping it would silently subtract that project's cost from a
// --project/--exclude total (the day's split would no longer sum to its own
// cost, which the filtered headline relies on).
if (!isRecord(p)) continue
setOwn(out, name, { setOwn(out, name, {
cost: num(p.cost), cost: num(p.cost),
calls: num(p.calls), calls: num(p.calls),

View file

@ -5,7 +5,6 @@ import { tmpdir } from 'os'
import { join } from 'path' import { join } from 'path'
import { DAILY_CACHE_VERSION, currentTzKey, type DailyCache, type DailyEntry } from '../src/daily-cache.js' import { DAILY_CACHE_VERSION, currentTzKey, type DailyCache, type DailyEntry } from '../src/daily-cache.js'
import { getDateRange } from '../src/cli-date.js'
import { loadPricing } from '../src/models.js' import { loadPricing } from '../src/models.js'
import { buildDurablePeriod, buildMenubarPayloadForRange, buildPeriodData, getDailyCacheConfigHash } from '../src/usage-aggregator.js' import { buildDurablePeriod, buildMenubarPayloadForRange, buildPeriodData, getDailyCacheConfigHash } from '../src/usage-aggregator.js'
import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js' import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js'
@ -148,13 +147,20 @@ afterEach(async () => {
if (existsSync(ROOT)) await rm(ROOT, { recursive: true, force: true }) if (existsSync(ROOT)) await rm(ROOT, { recursive: true, force: true })
}) })
const monthRange = (): DateRange => getDateRange('month').range // A fixed 20-day window back from now: it always spans the carried day (10 days
// ago) and today, so the period never depends on where "now" falls in the
// calendar. A real `month` range drops the 10-days-ago day whenever today is
// within 10 days of the 1st, which made these tests flake early in each month.
const coveringRange = (): DateRange => ({
start: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000),
end: new Date(),
})
describe('durable headline honours --project / --exclude on carried days', () => { describe('durable headline honours --project / --exclude on carried days', () => {
it('drops an excluded project from the headline so it reconciles with the By Project panel', async () => { it('drops an excluded project from the headline so it reconciles with the By Project panel', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10))) await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession() await seedLiveTodaySession()
const range = monthRange() const range = coveringRange()
const live = await liveOnly(range, [], ['drop-me']) const live = await liveOnly(range, [], ['drop-me'])
clearSessionCache() clearSessionCache()
@ -172,7 +178,7 @@ describe('durable headline honours --project / --exclude on carried days', () =>
it('keeps only the named project when --project is given', async () => { it('keeps only the named project when --project is given', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10))) await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession() await seedLiveTodaySession()
const range = monthRange() const range = coveringRange()
const live = await liveOnly(range, ['keep-me'], []) const live = await liveOnly(range, ['keep-me'], [])
clearSessionCache() clearSessionCache()
@ -185,7 +191,7 @@ describe('durable headline honours --project / --exclude on carried days', () =>
it('matches a filter against the project path as well as its name', async () => { it('matches a filter against the project path as well as its name', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10))) await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession() await seedLiveTodaySession()
const range = monthRange() const range = coveringRange()
const live = await liveOnly(range, [], ['/Users/gone/drop-me']) const live = await liveOnly(range, [], ['/Users/gone/drop-me'])
clearSessionCache() clearSessionCache()
@ -194,10 +200,35 @@ describe('durable headline honours --project / --exclude on carried days', () =>
expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6) expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
}) })
it('keeps a project whose name is a prototype member (constructor) in the sliced total', async () => {
// A project directory can legitimately be named "constructor"/"valueOf"/etc.
// The day cache must carry it as an own key: if the load path drops it, the
// day's per-project split no longer sums to the day cost, and the sliced
// headline silently loses that project's spend.
const day = carriedDayWithProjects(daysAgoStr(10))
const protoName = 'constructor'
Object.defineProperty(day.projects!, protoName, {
value: { cost: 25, calls: 5, savingsUSD: 0, sessions: 1, path: '/Users/gone/constructor' },
enumerable: true, writable: true, configurable: true,
})
day.cost += 25
day.calls += 5
await seedCache(day)
await seedLiveTodaySession()
const range = coveringRange()
// Keep everything (exclude a non-matching term): the constructor project's
// $25 must be present alongside keep-me and drop-me.
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['zzz-nomatch'] })
const live = await liveOnly(range, [], ['zzz-nomatch'])
expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST + 25, 6)
expect(durable.unattributedCostUSD).toBe(0)
})
it('contributes nothing from a carried day whose every project is excluded', async () => { it('contributes nothing from a carried day whose every project is excluded', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10))) await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession() await seedLiveTodaySession()
const range = monthRange() const range = coveringRange()
const live = await liveOnly(range, [], ['keep-me', 'drop-me']) const live = await liveOnly(range, [], ['keep-me', 'drop-me'])
clearSessionCache() clearSessionCache()
@ -210,7 +241,7 @@ describe('durable headline honours --project / --exclude on carried days', () =>
it('applies the project filter underneath a provider filter', async () => { it('applies the project filter underneath a provider filter', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10))) await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession() await seedLiveTodaySession()
const range = monthRange() const range = coveringRange()
// Stated as the delta the filter must produce, so the assertion holds // Stated as the delta the filter must produce, so the assertion holds
// whatever the provider-scoped live parse contributes. // whatever the provider-scoped live parse contributes.
@ -226,7 +257,7 @@ describe('durable headline honours --project / --exclude on carried days', () =>
it('keeps the menubar payload in step with the report under a project filter', async () => { it('keeps the menubar payload in step with the report under a project filter', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10))) await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession() await seedLiveTodaySession()
const range = monthRange() const range = coveringRange()
clearSessionCache() clearSessionCache()
const menubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'], optimize: false, timeline: false }) const menubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'], optimize: false, timeline: false })
@ -243,7 +274,7 @@ describe('durable headline honours --project / --exclude on carried days', () =>
it('leaves the unfiltered headline exactly as it was', async () => { it('leaves the unfiltered headline exactly as it was', async () => {
await seedCache(carriedDayWithProjects(daysAgoStr(10))) await seedCache(carriedDayWithProjects(daysAgoStr(10)))
await seedLiveTodaySession() await seedLiveTodaySession()
const range = monthRange() const range = coveringRange()
const live = await liveOnly(range, [], []) const live = await liveOnly(range, [], [])
clearSessionCache() clearSessionCache()
@ -259,7 +290,7 @@ describe('carried days with no per-project split (pre-v15)', () => {
it('sets the unfilterable day aside instead of leaking it into a filtered headline', async () => { it('sets the unfilterable day aside instead of leaking it into a filtered headline', async () => {
await seedCache(carriedDayWithoutProjects(daysAgoStr(10))) await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
await seedLiveTodaySession() await seedLiveTodaySession()
const range = monthRange() const range = coveringRange()
const live = await liveOnly(range, [], ['drop-me']) const live = await liveOnly(range, [], ['drop-me'])
clearSessionCache() clearSessionCache()
@ -279,7 +310,7 @@ describe('carried days with no per-project split (pre-v15)', () => {
delete day.providers['claude']!.projects delete day.providers['claude']!.projects
await seedCache(day) await seedCache(day)
await seedLiveTodaySession() await seedLiveTodaySession()
const range = monthRange() const range = coveringRange()
clearSessionCache() clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude', exclude: ['drop-me'] }) const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude', exclude: ['drop-me'] })
@ -291,7 +322,7 @@ describe('carried days with no per-project split (pre-v15)', () => {
it('says so in the overview instead of just showing a short total', async () => { it('says so in the overview instead of just showing a short total', async () => {
await seedCache(carriedDayWithoutProjects(daysAgoStr(10))) await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
await seedLiveTodaySession() await seedLiveTodaySession()
const range = monthRange() const range = coveringRange()
clearSessionCache() clearSessionCache()
const durable = await buildDurablePeriod({ range, label: 'This month' }, { provider: 'all', exclude: ['drop-me'] }) const durable = await buildDurablePeriod({ range, label: 'This month' }, { provider: 'all', exclude: ['drop-me'] })
@ -320,7 +351,7 @@ describe('carried days with no per-project split (pre-v15)', () => {
it('still counts the day in full when no project filter is active', async () => { it('still counts the day in full when no project filter is active', async () => {
await seedCache(carriedDayWithoutProjects(daysAgoStr(10))) await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
await seedLiveTodaySession() await seedLiveTodaySession()
const range = monthRange() const range = coveringRange()
const live = await liveOnly(range, [], []) const live = await liveOnly(range, [], [])
clearSessionCache() clearSessionCache()