fix(cli): anchor an absolute-path project pattern to a segment boundary

filterProjectsByName matched every pattern as a raw substring, so
--exclude /a/my-company also removed the sibling /a/my-company-kit, and
no pattern the caller could type expressed "this project and not its
neighbours".

A rooted pattern (POSIX, Windows drive, UNC) now keys through
normalizeAbsProjectPathKey, the helper #1271 added for project identity,
and matches the way isProxiedPath matches proxyPaths: equal to the
project path, or a prefix of it on a path-segment boundary. Going
through that helper keeps one identity rule in the parser: identified
Windows paths casefold, POSIX case stays identity, and a Codex-style
stripped absolute path ("root/vault") keys the same as the pattern
"/root/vault" the user types. A pattern that is not rooted stays the
loose substring people type on the command line, so --project
my-company keeps covering my-company-kit and worktrees.

The rule lives in one closure (makeProjectFilter) applied to the live
parse and to the carry-forward day cache alike. The cache path had its
own copy, lowercasing and substring-matching, so a filtered headline
anchored today's live days while matching every cached day loosely: on a
carried day split across /Users/gone/app ($30) and /Users/gone/app-kit
($70), --exclude /Users/gone/app returned 0 instead of 70, and --exclude
/users/gone/app excluded both instead of neither. Both shapes are in
tests/project-filter-durable-totals.test.ts, the suite that exists to
pin that a filter selects the same projects whether it is resolved
against a fresh parse or against the cache.

The README gains the rules that were not written down: an absolute POSIX
path is case-sensitive while a Windows drive or UNC path folds case, and
a leading ~ is the shell's to expand.
This commit is contained in:
Mathieu Stanowski 2026-09-09 17:02:57 +02:00
parent 29fcb38976
commit 172b8f4df6
4 changed files with 128 additions and 25 deletions

View file

@ -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
}

View file

@ -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,

View file

@ -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')

View file

@ -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<void> {
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