diff --git a/src/parser.ts b/src/parser.ts index fc5471a0..27521e21 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -4413,6 +4413,26 @@ export function setCachePutMeta(meta: { startMs: number; endMs: number; sig: str putMeta = meta } +export function isRootedProjectPattern(pattern: string): boolean { + const raw = pattern.trim().replace(/\\/g, '/') + return raw.startsWith('/') || /^[a-zA-Z]:\//.test(raw) +} + +/// An absolute path names ONE project, so it anchors on a segment boundary (the +/// isProxiedPath rule): "/a/proj" takes "/a/proj/sub" but not "/a/proj-ui-kit". +/// Both sides key through normalizeAbsProjectPathKey (Windows casefolds, POSIX +/// does not, #1260), and rootedness alone picks the branch, so "/" names none. +export function matchesProjectPattern(project: { project: string; projectPath?: string }, pattern: string): boolean { + const projectPath = project.projectPath ?? '' + if (isRootedProjectPattern(pattern)) { + const anchor = normalizeAbsProjectPathKey(pattern) + const target = normalizeAbsProjectPathKey(projectPath) + return anchor !== null && target !== null && (target === anchor || target.startsWith(anchor + '/')) + } + const needle = pattern.toLowerCase() + return project.project.toLowerCase().includes(needle) || projectPath.toLowerCase().includes(needle) +} + export function filterProjectsByName( projects: ProjectSummary[], include?: string[], @@ -4420,20 +4440,10 @@ export function filterProjectsByName( ): ProjectSummary[] { let result = projects if (include && include.length > 0) { - const patterns = include.map(s => s.toLowerCase()) - result = result.filter(p => { - const name = p.project.toLowerCase() - const path = p.projectPath.toLowerCase() - return patterns.some(pat => name.includes(pat) || path.includes(pat)) - }) + result = result.filter(p => include.some(pattern => matchesProjectPattern(p, pattern))) } if (exclude && exclude.length > 0) { - const patterns = exclude.map(s => s.toLowerCase()) - result = result.filter(p => { - const name = p.project.toLowerCase() - const path = p.projectPath.toLowerCase() - return !patterns.some(pat => name.includes(pat) || path.includes(pat)) - }) + result = result.filter(p => !exclude.some(pattern => matchesProjectPattern(p, pattern))) } return result } diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index c3e80d69..68c93470 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -3,7 +3,7 @@ import { CATEGORY_LABELS, type ProjectSummary, type SessionSummary, type TaskCat import { isBehavioralCall } from './behavioral-weight.js' import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarPayload, type ClaudeConfigSelector, type HydrationState, buildMenubarPayload } from './menubar-json.js' import { type SessionCountBasis } from './session-count-label.js' -import { parseAllSessions, filterProjectsByName, filterProjectsByDays, filterProjectsByClaudeConfigSource, filterProjectsByDateRange, isSessionHydrationComplete, sessionHydrationSnapshot } from './parser.js' +import { parseAllSessions, filterProjectsByName, filterProjectsByDays, filterProjectsByClaudeConfigSource, filterProjectsByDateRange, isSessionHydrationComplete, matchesProjectPattern, sessionHydrationSnapshot } from './parser.js' import { findUnpricedModels, getFlatRateModelsConfigHash, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName, isExpectedFreeModel } from './models.js' import { getAllProviders, safeDiscoverSessions } from './providers/index.js' import { loadPlugins, pluginPayloadSections } from './plugins/loader.js' @@ -376,17 +376,17 @@ export function overlayProviderDaySlices( return mergeDayEntries(freshSliced, sliced, false, undefined, true) } -/// Does a cached day's project entry pass the active name filters? Mirrors -/// parser.filterProjectsByName exactly — case-insensitive substring match -/// against the project name OR its filesystem path, include first then exclude — -/// so a filter selects the same projects whether it is resolved against a fresh -/// parse or against the day cache. Patterns arrive pre-lowercased. `path` is +/// Does a cached day's project entry pass the active name filters? Delegates to +/// the one rule filterProjectsByName applies, so a filter selects the same +/// projects whether it is resolved against a fresh parse or against the day +/// cache — a second copy here put anchored live days and substring-matched +/// cached days inside one headline. Patterns arrive raw: the rule casefolds +/// identified Windows paths itself and keeps POSIX case as identity. `path` is /// absent on entries whose sessions were gone before it could be recorded; the /// name is then all there is to match on, as it is for the display layers. function dayProjectMatches(name: string, path: string | undefined, include: string[], exclude: string[]): boolean { - const n = name.toLowerCase() - const p = (path ?? '').toLowerCase() - const hit = (pattern: string): boolean => n.includes(pattern) || (p !== '' && p.includes(pattern)) + const entry = { project: name, projectPath: path ?? '' } + const hit = (pattern: string): boolean => matchesProjectPattern(entry, pattern) if (include.length > 0 && !include.some(hit)) return false if (exclude.length > 0 && exclude.some(hit)) return false return true @@ -532,8 +532,8 @@ export function buildDurableOverviewFromNormalizedIndex( opts: AggregateOpts = {}, ): IndexedDurableOverview { const pf = opts.provider ?? 'all' - const include = (opts.project ?? []).map(value => value.toLowerCase()) - const exclude = (opts.exclude ?? []).map(value => value.toLowerCase()) + const include = opts.project ?? [] + const exclude = opts.exclude ?? [] const hasProjectFilter = include.length > 0 || exclude.length > 0 const filteredProjects = filterProjectsByName(normalizedProjects, opts.project ?? [], opts.exclude ?? []) const scanProjects = filterProjectsByDateRange(filteredProjects, periodInfo.range) @@ -704,8 +704,8 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate // name-filtered above (`fp`), but the historical remainder comes straight out // of the day cache, so without this slice a --project/--exclude headline // counted every expired-source day whole while the detail panels did not. - const projectInclude = (opts.project ?? []).map(s => s.toLowerCase()) - const projectExclude = (opts.exclude ?? []).map(s => s.toLowerCase()) + const projectInclude = opts.project ?? [] + const projectExclude = opts.exclude ?? [] const hasProjectFilter = projectInclude.length > 0 || projectExclude.length > 0 // What a filtered total cannot claim, and therefore has to leave out: a cached // day with no project split at all, or — with a provider filter also active, diff --git a/tests/parser-filter.test.ts b/tests/parser-filter.test.ts index 4c347220..829522d4 100644 --- a/tests/parser-filter.test.ts +++ b/tests/parser-filter.test.ts @@ -52,6 +52,58 @@ describe('filterProjectsByName', () => { expect(result.map(p => p.project).sort()).toEqual(['AgentSeal', 'dashboard']) }) + it('an absolute-path pattern does not swallow a sibling sharing its prefix', () => { + const siblings = [ + makeProject('my-company', '/Users/alice/work/my-company'), + makeProject('my-company-kit', '/Users/alice/work/my-company-kit'), + makeProject('client', '/Users/alice/work/my-company/packages/client'), + ] + const result = filterProjectsByName(siblings, undefined, ['/Users/alice/work/my-company']) + expect(result.map(p => p.project)).toEqual(['my-company-kit']) + }) + + it('anchors an absolute-path include the same way', () => { + const siblings = [ + makeProject('my-company', '/Users/alice/work/my-company'), + makeProject('my-company-kit', '/Users/alice/work/my-company-kit'), + ] + expect(filterProjectsByName(siblings, ['/Users/alice/work/my-company']).map(p => p.project)).toEqual(['my-company']) + }) + + it('keeps a plain word loose, so it still spans siblings and worktrees', () => { + const siblings = [ + makeProject('my-company', '/Users/alice/work/my-company'), + makeProject('my-company-kit', '/Users/alice/work/my-company-kit'), + makeProject('other', '/Users/alice/work/other'), + ] + expect(filterProjectsByName(siblings, ['my-company']).map(p => p.project)).toEqual(['my-company', 'my-company-kit']) + }) + + it('is trailing-slash and backslash tolerant on an absolute pattern', () => { + const win = [makeProject('app', 'C:\\Users\\alice\\app'), makeProject('app-two', 'C:\\Users\\alice\\app-two')] + expect(filterProjectsByName(win, undefined, ['C:/Users/alice/app/']).map(p => p.project)).toEqual(['app-two']) + }) + + it('folds case on an identified Windows pattern and keeps POSIX case identity', () => { + const posix = [makeProject('Vault', '/a/Vault'), makeProject('vault', '/a/vault')] + expect(filterProjectsByName(posix, ['/a/Vault']).map(p => p.project)).toEqual(['Vault']) + const win = [makeProject('vault', 'C:\\Work\\Vault')] + expect(filterProjectsByName(win, ['c:/work/vault']).map(p => p.project)).toEqual(['vault']) + const unc = [makeProject('vault', '\\\\Server\\Share\\Vault')] + expect(filterProjectsByName(unc, ['//server/share']).map(p => p.project)).toEqual(['vault']) + }) + + it('anchors a POSIX pattern against a Codex-style stripped absolute path', () => { + const codex = [makeProject('vault', 'root/vault'), makeProject('vault-ui', 'root/vault-ui')] + expect(filterProjectsByName(codex, undefined, ['/root/vault']).map(p => p.project)).toEqual(['vault-ui']) + }) + + it('names no project for a rooted pattern that keys to nothing', () => { + expect(filterProjectsByName(projects, ['/'])).toEqual([]) + expect(filterProjectsByName(projects, undefined, ['/'])).toEqual(projects) + expect(filterProjectsByName(projects, undefined, ['//'])).toEqual(projects) + }) + it('exclude matches path substring', () => { const result = filterProjectsByName(projects, undefined, ['/tmp']) expect(result.map(p => p.project)).not.toContain('sandbox') diff --git a/tests/project-filter-durable-totals.test.ts b/tests/project-filter-durable-totals.test.ts index 9d8cffdb..f405afdd 100644 --- a/tests/project-filter-durable-totals.test.ts +++ b/tests/project-filter-durable-totals.test.ts @@ -112,6 +112,18 @@ function carriedDayTwoProviders(date: string): DailyEntry { return day } +/** The same carried day, with the two projects as path siblings sharing a prefix. */ +function carriedDaySiblingProjects(date: string): DailyEntry { + const day = carriedDayWithProjects(date) + const projects = { + app: { cost: KEEP.cost, calls: KEEP.calls, savingsUSD: 0, sessions: KEEP.sessions, path: '/Users/gone/app' }, + 'app-kit': { cost: DROP.cost, calls: DROP.calls, savingsUSD: 0, sessions: DROP.sessions, path: '/Users/gone/app-kit' }, + } + day.projects = projects + day.providers['claude']!.projects = projects + return day +} + async function seedCache(...days: DailyEntry[]): Promise { const cache: DailyCache = { version: DAILY_CACHE_VERSION, @@ -235,6 +247,35 @@ describe('durable headline honours --project / --exclude on carried days', () => expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6) }) + // A cached day and a live day must answer the same pattern the same way. The + // two below are the shapes where a substring copy of the rule diverges from + // the anchored one, inside a single headline total. + it('anchors an absolute pattern on a carried day, so the sibling survives', async () => { + await seedCache(carriedDaySiblingProjects(daysAgoStr(10))) + await seedLiveTodaySession() + const range = coveringRange() + + const live = await liveOnly(range, [], ['/Users/gone/app']) + clearSessionCache() + const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['/Users/gone/app'] }) + + expect(durable.data.cost).toBeCloseTo(live.cost + DROP.cost, 6) + expect(durable.data.calls).toBe(live.calls + DROP.calls) + }) + + it('keeps POSIX case identity on a carried day, so a wrong-case path selects nothing', async () => { + await seedCache(carriedDaySiblingProjects(daysAgoStr(10))) + await seedLiveTodaySession() + const range = coveringRange() + + const live = await liveOnly(range, [], ['/users/gone/app']) + clearSessionCache() + const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['/users/gone/app'] }) + + expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST, 6) + expect(durable.data.calls).toBe(live.calls + DAY_CALLS) + }) + 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