fix(cli): expand a leading ~ in a project pattern

A quoted '~/work/app' reaches the CLI unexpanded, and a pattern typed
into a field has no shell behind it at all. It was neither rooted nor a
substring of any stored path, so it selected nothing and said nothing.
The README made it worse by telling people to quote it.

A leading ~ is now expanded against the home directory and anchored like
any absolute path, so the quoted form selects the same project as the one
the shell would have expanded, and a wrong one is reported.

Two more from reading the hot path: the compiled filter only pays for the
project path key when some pattern is rooted and can read it, which the
day cache runs per project, per day, per provider slice; and
unmatchedRootedPatterns now calls matchesProjectPattern instead of
open-coding it.
This commit is contained in:
Mathieu Stanowski 2026-09-11 17:29:55 +02:00
parent 648fa40ebd
commit 740ec551e3
3 changed files with 33 additions and 6 deletions

View file

@ -653,7 +653,7 @@ codeburn month --project api --project web # include multiple projects
codeburn export --project inventory # export only "inventory" project data
```
Filter by provider, project, or exact date range. The `--project` and `--exclude` flags work on every reporting command and can be combined with `--provider`. A plain word matches a project's name or path as a case-insensitive substring, so `--project my-company` also covers `my-company-kit` and its worktrees. An absolute path selects that one project and anything inside it, so `--exclude /Users/me/work/my-company` leaves the sibling `/Users/me/work/my-company-kit` alone. A leading `~` is the shell's to expand, so quote it and it is read as a plain word, not a path. An absolute POSIX path is case-sensitive, the same rule that decides project identity everywhere else (`/Users/me/Vault` and `/Users/me/vault` are two projects); a Windows drive or UNC path folds case. An absolute path that matches no project in the period you asked for is reported on stderr, since it would otherwise leave a total that looks right.
Filter by provider, project, or exact date range. The `--project` and `--exclude` flags work on every reporting command and can be combined with `--provider`. A plain word matches a project's name or path as a case-insensitive substring, so `--project my-company` also covers `my-company-kit` and its worktrees. An absolute path selects that one project and anything inside it, so `--exclude /Users/me/work/my-company` leaves the sibling `/Users/me/work/my-company-kit` alone. A leading `~` is expanded against your home directory, so a quoted `'~/work/my-company'` selects the same project as the path the shell would have expanded. An absolute POSIX path is case-sensitive, the same rule that decides project identity everywhere else (`/Users/me/Vault` and `/Users/me/vault` are two projects); a Windows drive or UNC path folds case. An absolute path that matches no project in the period you asked for is reported on stderr, since it would otherwise leave a total that looks right.
```bash
codeburn report --from 2026-04-01 --to 2026-04-10 # explicit window

View file

@ -1,3 +1,4 @@
import { homedir } from 'node:os'
import { existsSync } from 'fs'
import { lstat, readFile, readdir, stat } from 'fs/promises'
import { createHash } from 'crypto'
@ -3170,6 +3171,10 @@ export function setInteractiveScanUI(active = true): void {
interactiveScanUI = active
}
export function isInteractiveScanUI(): boolean {
return interactiveScanUI
}
// Machine-readable scan progress for the desktop app's first-run splash. Plain
// CLI/terminal usage is untouched: emission is gated on CODEBURN_PROGRESS=1,
// which only the app's cold-start warmup spawn sets. Each event is one
@ -4415,7 +4420,16 @@ export function setCachePutMeta(meta: { startMs: number; endMs: number; sig: str
export function isRootedProjectPattern(pattern: string): boolean {
const raw = pattern.trim().replace(/\\/g, '/')
return raw.startsWith('/') || /^[a-zA-Z]:\//.test(raw)
return raw.startsWith('/') || raw === '~' || raw.startsWith('~/') || /^[a-zA-Z]:\//.test(raw)
}
/// A quoted "~/proj" reaches us unexpanded, and so does one typed into a field
/// with no shell behind it. Left as a loose word it would match nothing and say
/// nothing, since no stored path contains a tilde.
function expandTilde(pattern: string): string {
const raw = pattern.trim().replace(/\\/g, '/')
if (raw !== '~' && !raw.startsWith('~/')) return raw
return homedir().replace(/\\/g, '/') + raw.slice(1)
}
/// A pattern is normalized once and matched many times: the day cache runs the
@ -4426,7 +4440,7 @@ export type ProjectFilterTarget = { project: string; projectPath?: string }
function compile(patterns: readonly string[]): CompiledPattern[] {
return patterns.map(pattern => isRootedProjectPattern(pattern)
? { rooted: true as const, anchor: normalizeAbsProjectPathKey(pattern) }
? { rooted: true as const, anchor: normalizeAbsProjectPathKey(expandTilde(pattern)) }
: { rooted: false as const, needle: pattern.toLowerCase() })
}
@ -4452,8 +4466,12 @@ export function makeProjectFilter(
): (entry: ProjectFilterTarget) => boolean {
const inc = compile(include ?? [])
const exc = compile(exclude ?? [])
// The key costs a trim, a global replace and three regex passes. The day cache
// runs this per project, per day, per provider slice, so it is only paid when
// some pattern is rooted and can actually read it.
const needsKey = inc.some(p => p.rooted) || exc.some(p => p.rooted)
return entry => {
const key = normalizeAbsProjectPathKey(entry.projectPath ?? '')
const key = needsKey ? normalizeAbsProjectPathKey(entry.projectPath ?? '') : null
if (inc.length > 0 && !inc.some(pattern => hit(entry, pattern, key))) return false
if (exc.length > 0 && exc.some(pattern => hit(entry, pattern, key))) return false
return true
@ -4471,8 +4489,7 @@ export function matchesProjectPattern(project: ProjectFilterTarget, pattern: str
export function unmatchedRootedPatterns(projects: readonly ProjectFilterTarget[], patterns: readonly string[]): string[] {
return patterns.filter(pattern => {
if (!isRootedProjectPattern(pattern)) return false
const matches = makeProjectFilter([pattern])
return !projects.some(entry => matches(entry))
return !projects.some(entry => matchesProjectPattern(entry, pattern))
})
}

View file

@ -1,3 +1,5 @@
import { homedir } from 'node:os'
import { describe, it, expect } from 'vitest'
import { filterProjectsByName } from '../src/parser.js'
@ -104,6 +106,14 @@ describe('filterProjectsByName', () => {
expect(filterProjectsByName(projects, undefined, ['//'])).toEqual(projects)
})
it('expands a leading ~ the shell did not, and anchors it', () => {
const home = homedir().replace(/\\/g, '/')
const mine = [makeProject('app', `${home}/work/app`), makeProject('app-kit', `${home}/work/app-kit`)]
expect(filterProjectsByName(mine, ['~/work/app']).map(p => p.project)).toEqual(['app'])
expect(filterProjectsByName(mine, undefined, ['~/work/app']).map(p => p.project)).toEqual(['app-kit'])
expect(filterProjectsByName(mine, ['~/nowhere'])).toEqual([])
})
it('exclude matches path substring', () => {
const result = filterProjectsByName(projects, undefined, ['/tmp'])
expect(result.map(p => p.project)).not.toContain('sandbox')