mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-20 11:44:39 +00:00
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:
parent
29fcb38976
commit
172b8f4df6
4 changed files with 128 additions and 25 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue