Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions packages/utils/__tests__/file-filter-project-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { describe, expect, it } from 'vitest'
import {
CONTEXT_DEPENDENT_BLACKLISTED_DIRS,
DEV_BLACKLISTED_DIRS,
TEMP_BLACKLISTED_DIRS,
} from '../common/file-scan-constants'
import { fileFilterService } from '../common/file-filter-service'

/**
* #1727's last acceptance criterion: a decision on whether development names apply under a user's
* document roots.
*
* The decision is *not by name alone*. `node_modules` is nobody's document folder and keeps
* excluding everywhere; `build`, `dist`, `out`, `bin`, `target`, `coverage`, `logs`, `tmp`, `temp`
* and `cache` are ordinary English words, and they exclude only where a project marker sits beside
* them — the same signal ripgrep, fd and VS Code use, and free here because the traversal has
* already read the parent before it descends.
*
* Both directions are asserted below, because only one of them is the silent one: a wrongly-kept
* exclusion produces no error, no `errorCount`, and a search result identical to "file not there".
*/

/** A directory whose siblings were read and contain no project marker. */
const withoutProject = (p: string, siblings: readonly string[] = ['notes.txt']): string =>
String(fileFilterService.getTraversalExclusionReason(p, undefined, { siblingNames: siblings }) ?? '—')

/** The same, next to a `package.json`. */
const withProject = (p: string, marker = 'package.json'): string =>
String(
fileFilterService.getTraversalExclusionReason(p, undefined, {
siblingNames: [marker, 'src', 'README.md'],
}) ?? '—',
)

/** No context at all — every caller other than the traversal. */
const withoutContext = (p: string): string =>
String(fileFilterService.getTraversalExclusionReason(p) ?? '—')

const ORDINARY_WORDS = [...CONTEXT_DEPENDENT_BLACKLISTED_DIRS]

describe('#1727 ordinary-word dirs under a user root', () => {
it('has names to check, so the loops below are not vacuous', () => {
expect(ORDINARY_WORDS.length).toBeGreaterThan(0)
})

/**
* The set only ever weakens where an existing list applies. A name here that is on neither list
* would be inventing policy in a constant nobody reads.
*/
it('only relaxes names the two blacklists already own', () => {
for (const name of ORDINARY_WORDS) {
expect(
DEV_BLACKLISTED_DIRS.has(name) || TEMP_BLACKLISTED_DIRS.has(name),
name,
).toBe(true)
}
})

it('indexes them when nothing beside them says "project"', () => {
for (const name of ORDINARY_WORDS) {
const p = `/Users/someone/Documents/${name}`
expect(withoutProject(p), p).toBe('—')
}
})

it('still excludes them beside a project marker', () => {
for (const name of ORDINARY_WORDS) {
const p = `/Users/someone/Projects/app/${name}`
expect(withProject(p), p).not.toBe('—')
}
})

/**
* The half a leaf-name-only fix would have missed. `PATH_PATTERNS.DEV_PATHS` carries `/build\//`
* unanchored, so it matches an *ancestor* segment: `~/Documents/build` would come back indexed
* while everything under it stayed excluded.
*/
it('indexes what is below them too, not just the folder itself', () => {
const p = '/Users/someone/Documents/build/2026'
expect(withoutProject(p, ['2026', 'notes.txt']), p).toBe('—')
expect(withoutProject('/Users/someone/Documents/tmp/receipts'), 'tmp/receipts').toBe('—')
})

it('recognises project markers other than package.json', () => {
for (const marker of ['Cargo.toml', 'go.mod', 'Makefile', 'pom.xml', '.git'])
expect(withProject('/Users/someone/Documents/target', marker), marker).not.toBe('—')
})

/** `App.csproj` is named per project, so the marker list matches it by suffix. */
it('recognises per-project marker names by suffix', () => {
expect(withProject('/Users/someone/Documents/bin', 'App.csproj')).not.toBe('—')
})

it('matches markers case-insensitively', () => {
expect(withProject('/Users/someone/Documents/build', 'makefile')).not.toBe('—')
})
})

describe('#1727 what the relaxation must not touch', () => {
it('keeps node_modules excluded with or without a project beside it', () => {
const p = '/Users/someone/Documents/node_modules'
expect(withoutProject(p)).toBe('development-path')
expect(withProject(p)).toBe('development-path')
expect(withoutContext(p)).toBe('development-path')
})

/**
* `bin` is on the macOS system list *and* the dev list. Relaxing the dev half must not hand back
* `/usr/bin`, and it does not: `PATH_PATTERNS.SYSTEM_PATHS` is the one of the three pattern sets
* left unconditional, precisely because it catches what the per-level name rule cannot.
*/
it('keeps real system paths excluded once bin and tmp are relaxed', () => {
if (process.platform === 'win32') return
for (const p of ['/usr/bin', '/var/tmp', '/usr/local/bin'])
expect(withoutProject(p, ['lib', 'share']), p).toBe('system-path')
})

it('keeps hidden and bundle rules ahead of the relaxation', () => {
expect(withoutProject('/Users/someone/Documents/.cache')).toBe('hidden-name')
expect(withoutProject('/Users/someone/Pictures/Photos Library.photoslibrary')).toBe(
'bundle-internal',
)
})

it('still honours an explicit custom blacklist', () => {
const reason = fileFilterService.getTraversalExclusionReason(
'/Users/someone/Documents/build',
{ customBlacklistedDirs: new Set(['build']) },
{ siblingNames: ['notes.txt'] },
)
expect(reason).toBe('excluded-path')
})
})

describe('#1727 callers that supply no context are unchanged', () => {
/**
* The regression guard for everything that is not the traversal — index upserts, manual adds, the
* file-level containing-path check. They cannot see siblings, so they keep the pre-#1727 answer
* rather than being handed a guess.
*/
it('gives the strict answer when siblings were never read', () => {
expect(withoutContext('/Users/x/Documents/tmp')).toBe('cache-path')
expect(withoutContext('/Users/x/Downloads/cache')).toBe('development-path')
expect(withoutContext('/Users/x/Documents/build')).toBe('development-path')
expect(withoutContext('/Users/x/Documents/build/2026')).toBe('development-path')
})

/**
* Read-and-found-nothing is a different statement from never-looked, and an empty directory is a
* real thing to be standing in. Collapsing the two would make an empty parent strict again.
*/
it('treats an empty sibling list as read, not as unknown', () => {
expect(withoutProject('/Users/x/Documents/build', [])).toBe('—')
})
})
11 changes: 6 additions & 5 deletions packages/utils/__tests__/file-filter-traversal-anchor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,13 @@ describe('#1727 system-dir anchoring', () => {
})

/**
* Recorded, not fixed. `TEMP_BLACKLISTED_DIRS` and `DEV_BLACKLISTED_DIRS` are still matched on the
* leaf name at any depth, so the folders that prompted #1727 are still excluded — by a different
* list. Whether an ordinary word should exclude a folder under a user's documents is a policy
* question, and this pins the current answer rather than deciding it.
* `TEMP_BLACKLISTED_DIRS` and `DEV_BLACKLISTED_DIRS` are matched on the leaf name at any depth,
* so the folders that prompted #1727 were still excluded here — by a different list. That policy
* question is now decided in `file-filter-project-context.test.ts`: the ordinary words exclude
* only beside a project marker. `reason()` passes no context, which is the deliberate
* "cannot tell" answer, so this file keeps asserting the strict result.
*/
it('documents the two lists this change does not touch', () => {
it('keeps the two other lists strict for a caller that reads no siblings', () => {
expect(reason('/Users/x/Documents/tmp')).toBe('cache-path')
expect(reason('/Users/x/Downloads/cache')).toBe('development-path')
expect(reason('/Users/x/Documents/build')).toBe('development-path')
Expand Down
73 changes: 71 additions & 2 deletions packages/utils/common/file-filter-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import {
BLACKLISTED_EXTENSIONS,
BLACKLISTED_FILE_PREFIXES,
BLACKLISTED_FILE_SUFFIXES,
CONTEXT_DEPENDENT_BLACKLISTED_DIRS,
DATABASE_FILE_EXTENSIONS,
DEFAULT_SCAN_OPTIONS,
DEV_BLACKLISTED_DIRS,
INTERNAL_DATABASE_FILE_EXTENSIONS,
PATH_PATTERNS,
PHOTOS_LIBRARY_CONFIG,
PROJECT_MARKER_ENTRIES,
PROJECT_MARKER_SUFFIXES,
SEARCH_HIDDEN_FILE_EXTENSIONS,
SYSTEM_BLACKLISTED_DIRS,
SYSTEM_METADATA_FILE_NAMES,
Expand All @@ -36,6 +39,23 @@ export interface FileFilterTarget {
isDirectory?: boolean;
}

/**
* What the caller knows about the directory it is asking about, beyond the path itself (#1727).
*
* Only the traversal can fill this in, and only because it has already read the parent directory.
* Everything else — index upserts, manual adds, the file-level containing-path check — passes
* nothing and keeps the stricter pre-#1727 behaviour.
*/
export interface TraversalContext {
/**
* Entry names of the directory containing the path being classified, i.e. its siblings.
*
* Present-but-empty is a real answer ("read it, found nothing"); `undefined` means the caller
* never looked, which is why the two cannot be collapsed.
*/
siblingNames?: readonly string[];
}

export interface FileSearchItemLike {
meta?: {
file?: {
Expand All @@ -54,6 +74,9 @@ const LOWERCASE_INTERNAL_DATABASE_EXTENSIONS = lowerCaseSet(
);
const LOWERCASE_SYSTEM_DIRS = lowerCaseSet(SYSTEM_BLACKLISTED_DIRS);
const LOWERCASE_TEMP_DIRS = lowerCaseSet(TEMP_BLACKLISTED_DIRS);
const LOWERCASE_CONTEXT_DEPENDENT_DIRS = lowerCaseSet(
CONTEXT_DEPENDENT_BLACKLISTED_DIRS,
);

/**
* The system names that sit under a home directory instead of the filesystem root.
Expand Down Expand Up @@ -147,6 +170,30 @@ function isHomeDirectoryChild(
);
}

function isProjectMarker(entryName: string): boolean {
const lower = entryName.toLowerCase();
if (PROJECT_MARKER_ENTRIES.has(lower)) return true;
return PROJECT_MARKER_SUFFIXES.some((suffix) => lower.endsWith(suffix));
}

/**
* Whether the leaf-name blacklists should fire for this directory (#1727).
*
* Names outside `CONTEXT_DEPENDENT_BLACKLISTED_DIRS` are unaffected — `node_modules` excludes
* wherever it appears, as it always did. The ordinary English words fire only where a project
* marker sits beside the directory, and a caller that supplied no sibling list is treated as
* "cannot tell", which keeps the stricter pre-#1727 answer.
*/
function leafNameFilterApplies(
lowerDirectoryName: string,
context: TraversalContext | undefined,
): boolean {
if (!LOWERCASE_CONTEXT_DEPENDENT_DIRS.has(lowerDirectoryName)) return true;
const siblings = context?.siblingNames;
if (!siblings) return true;
return siblings.some(isProjectMarker);
}

function basename(value: string): string {
const normalized = normalizePath(value).replace(/\/+$/, "");
const separator = normalized.lastIndexOf("/");
Expand Down Expand Up @@ -237,6 +284,7 @@ export class FileFilterService {
getTraversalExclusionReason(
directoryPath: string,
options: FileScanOptions = DEFAULT_SCAN_OPTIONS,
context?: TraversalContext,
): FileFilterReason | null {
const path = directoryPath.trim();
if (!path) return "excluded-path";
Expand All @@ -250,7 +298,9 @@ export class FileFilterService {
if (hasBundleSegment(segments)) return "bundle-internal";
if (options.customBlacklistedDirs?.has(directoryName))
return "excluded-path";
if (LOWERCASE_DEV_DIRS.has(lowerDirectoryName)) return "development-path";
const byLeafName = leafNameFilterApplies(lowerDirectoryName, context);
if (byLeafName && LOWERCASE_DEV_DIRS.has(lowerDirectoryName))
return "development-path";
const normalized = normalizePath(path);
if (
(isFilesystemRootChild(normalized, segments) &&
Expand All @@ -260,7 +310,8 @@ export class FileFilterService {
) {
return "system-path";
}
if (LOWERCASE_TEMP_DIRS.has(lowerDirectoryName)) return "cache-path";
if (byLeafName && LOWERCASE_TEMP_DIRS.has(lowerDirectoryName))
return "cache-path";

if (
optionEnabled(
Expand All @@ -271,7 +322,24 @@ export class FileFilterService {
) {
return "system-path";
}

/**
* `DEV_PATHS` and `CACHE_PATHS` restate the same names as unanchored substrings — `/build\//`,
* `/\/tmp\//` — so they match on an *ancestor* segment. Relaxing only the leaf name above would
* therefore fix `~/Documents/build` and still lose `~/Documents/build/2026`: half a fix, and the
* half that looks right in a shallow test.
*
* A caller that supplied sibling names is walking top-down and has already had this same rule
* applied at every ancestor, so for it these patterns can only re-assert a decision already
* made one level too late. Every name they carry is in `DEV_BLACKLISTED_DIRS` /
* `TEMP_BLACKLISTED_DIRS` or starts with a dot, so nothing is lost by skipping them there.
*
* `SYSTEM_PATHS` above stays unconditional: it is the one of the three that catches paths the
* per-level name rule does not, `/usr/bin` being exactly the case once `bin` is relaxed.
*/
const walksEveryLevel = context?.siblingNames !== undefined;
if (
!walksEveryLevel &&
optionEnabled(
options.enableDevPathFilter,
DEFAULT_SCAN_OPTIONS.enableDevPathFilter,
Expand All @@ -281,6 +349,7 @@ export class FileFilterService {
return "development-path";
}
if (
!walksEveryLevel &&
optionEnabled(
options.enableCachePathFilter,
DEFAULT_SCAN_OPTIONS.enableCachePathFilter,
Expand Down
72 changes: 72 additions & 0 deletions packages/utils/common/file-scan-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,78 @@ export const BASE_BLACKLISTED_DIRS = new Set([
...TEMP_BLACKLISTED_DIRS,
]);

/**
* The dev/temp names that are also ordinary English words (#1727).
*
* `node_modules` is nobody's document folder. `build`, `dist`, `out`, `bin`, `target`, `coverage`,
* `logs`, `tmp`, `temp` and `cache` are, and matching them on the leaf name alone is what left
* `~/Documents/build` unindexable in silence: the check runs before `readdir`, so nothing errors,
* nothing increments `errorCount`, and the folder simply never appears in search.
*
* They still exclude — but only where the caller can show a project marker sits beside them.
* A caller that cannot say either way gets the pre-#1727 answer, so no existing caller changes.
*
* Every name here is already in `DEV_BLACKLISTED_DIRS` or `TEMP_BLACKLISTED_DIRS`; this set only
* weakens *where* those lists apply and never introduces a name of its own. A test asserts that.
*/
export const CONTEXT_DEPENDENT_BLACKLISTED_DIRS = new Set([
"bin",
"build",
"cache",
"coverage",
"dist",
"logs",
"out",
"target",
"temp",
"temporary",
"tmp",
]);

/**
* Files and directories whose presence makes the containing directory a code project.
*
* This is the signal `ripgrep`, `fd` and VS Code use to decide the same question, and it costs
* nothing here: the traversal has already called `readdir` on the parent before it descends, so the
* sibling list is in hand. Compared lowercase — `makefile` and `Makefile` are both real.
*/
export const PROJECT_MARKER_ENTRIES = new Set([
".git",
".gitignore",
".hg",
".svn",
"build.gradle",
"build.gradle.kts",
"build.sbt",
"cargo.toml",
"cmakelists.txt",
"composer.json",
"deno.json",
"gemfile",
"go.mod",
"makefile",
"meson.build",
"mix.exs",
"package.json",
"package.swift",
"pnpm-workspace.yaml",
"pom.xml",
"pubspec.yaml",
"pyproject.toml",
"requirements.txt",
"settings.gradle",
"setup.py",
"tsconfig.json",
]);

/** Project markers carrying a per-project name, so only the suffix is fixed. */
export const PROJECT_MARKER_SUFFIXES = [
".csproj",
".sln",
".xcodeproj",
".xcworkspace",
];

/**
* macOS 媒体库 package 后缀黑名单(小写,匹配时大小写不敏感)。
* 这类 bundle 内部全是 UUID 命名的衍生图/缓存/渲染件,按文件名搜索无意义,
Expand Down
Loading