From 193539977bc6a865f9b71157dbcd5c7e6d9b8c47 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 29 May 2026 00:19:57 +0900 Subject: [PATCH 1/2] feat(indexing): port gitignore-aware file walker from semble Port of src/semble/index/file_walker.py. - walkFiles(root, extensions, ignore?) async generator - _isIgnored, _loadIgnoreForDir, _walk helpers exported with underscore prefix to mirror semble - DEFAULT_IGNORED_DIRS includes .csp/ (replacement for .semble/) - Honours .gitignore and .cspignore (semble's .sembleignore) - Recreates semble's negation-with-extension bypass: when a winning gitignore pattern is a negation (!pattern) AND the pattern (stripped of trailing /) has a file extension suffix, the file bypasses the extension allowlist via the 'found' flag. Implemented by parsing patterns ourselves and storing { pattern, negated, hasExtSuffix, matcher } per line, since the 'ignore' npm package does not expose the winning rule for negations. Tests cover: recursion, symlink skipping, default dirs, .gitignore exclusion, negation-with-extension bypass, .cspignore, nested .gitignore, extra ignore arg, case-insensitive extension matching. --- src/indexing/file-walker.test.ts | 231 +++++++++++++++++++++++++++ src/indexing/file-walker.ts | 265 +++++++++++++++++++++++++++++++ 2 files changed, 496 insertions(+) create mode 100644 src/indexing/file-walker.test.ts create mode 100644 src/indexing/file-walker.ts diff --git a/src/indexing/file-walker.test.ts b/src/indexing/file-walker.test.ts new file mode 100644 index 0000000..995e11f --- /dev/null +++ b/src/indexing/file-walker.test.ts @@ -0,0 +1,231 @@ +// Port of src/semble/index/file_walker.py — tests +import { describe, expect, test, beforeEach, afterEach } from 'bun:test' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { + DEFAULT_IGNORED_DIRS, + _isIgnored, + _loadIgnoreForDir, + walkFiles, +} from './file-walker.ts' + +let ignoreAvailable = true +try { + await import('ignore') +} +catch { + ignoreAvailable = false +} + +const describeWithIgnore = ignoreAvailable ? describe : describe.skip + +async function collect(iter: AsyncIterable): Promise { + const out: string[] = [] + for await (const item of iter) out.push(item) + return out +} + +describe('DEFAULT_IGNORED_DIRS', () => { + test('contains the csp cache dir instead of the semble one', () => { + expect(DEFAULT_IGNORED_DIRS.has('.csp/')).toBe(true) + expect(DEFAULT_IGNORED_DIRS.has('.semble/')).toBe(false) + }) + + test('contains canonical noisy directories', () => { + for (const d of ['.git/', 'node_modules/', 'dist/', 'build/', '.next/', '__pycache__/']) { + expect(DEFAULT_IGNORED_DIRS.has(d)).toBe(true) + } + }) +}) + +describeWithIgnore('walkFiles', () => { + let root: string + + beforeEach(() => { + root = mkdtempSync(path.join(os.tmpdir(), 'csp-walker-')) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + test('yields all .ts files under root recursively', async () => { + writeFileSync(path.join(root, 'a.ts'), 'a') + mkdirSync(path.join(root, 'sub')) + writeFileSync(path.join(root, 'sub', 'b.ts'), 'b') + writeFileSync(path.join(root, 'sub', 'c.md'), 'c') + mkdirSync(path.join(root, 'sub', 'nested')) + writeFileSync(path.join(root, 'sub', 'nested', 'd.ts'), 'd') + + const results = await collect(walkFiles(root, ['.ts'])) + const relative = results.map(p => path.relative(root, p)).sort() + expect(relative).toEqual(['a.ts', path.join('sub', 'b.ts'), path.join('sub', 'nested', 'd.ts')]) + }) + + test('skips symlinks', async () => { + writeFileSync(path.join(root, 'real.ts'), 'real') + try { + symlinkSync(path.join(root, 'real.ts'), path.join(root, 'link.ts')) + } + catch { + // Some sandboxes disallow symlinks — bail rather than fail. + return + } + const results = await collect(walkFiles(root, ['.ts'])) + const relative = results.map(p => path.relative(root, p)).sort() + expect(relative).toEqual(['real.ts']) + }) + + test('always ignores .git/ and node_modules/', async () => { + writeFileSync(path.join(root, 'keep.ts'), 'k') + mkdirSync(path.join(root, '.git')) + writeFileSync(path.join(root, '.git', 'hidden.ts'), 'h') + mkdirSync(path.join(root, 'node_modules')) + writeFileSync(path.join(root, 'node_modules', 'pkg.ts'), 'p') + + const results = await collect(walkFiles(root, ['.ts'])) + const relative = results.map(p => path.relative(root, p)).sort() + expect(relative).toEqual(['keep.ts']) + }) + + test('.gitignore excludes matching files', async () => { + writeFileSync(path.join(root, '.gitignore'), '*.log\n') + writeFileSync(path.join(root, 'foo.log'), 'foo') + writeFileSync(path.join(root, 'bar.txt'), 'bar') + + const results = await collect(walkFiles(root, ['.log', '.txt'])) + const relative = results.map(p => path.relative(root, p)).sort() + expect(relative).toEqual(['bar.txt']) + }) + + test('.gitignore negation-with-extension bypasses extension filter (found)', async () => { + // `*.log` ignores everything ending in .log; `!special.log` un-ignores + // special.log AND should be yielded even though `.log` is not in the + // extension allowlist below. + writeFileSync(path.join(root, '.gitignore'), '*.log\n!special.log\n') + writeFileSync(path.join(root, 'foo.log'), 'foo') + writeFileSync(path.join(root, 'special.log'), 'special') + writeFileSync(path.join(root, 'keep.ts'), 'k') + + const results = await collect(walkFiles(root, ['.ts'])) + const relative = results.map(p => path.relative(root, p)).sort() + expect(relative).toEqual(['keep.ts', 'special.log']) + }) + + test('.cspignore is honoured in addition to .gitignore', async () => { + writeFileSync(path.join(root, '.gitignore'), 'gitignored.ts\n') + writeFileSync(path.join(root, '.cspignore'), 'cspignored.ts\n') + writeFileSync(path.join(root, 'keep.ts'), 'k') + writeFileSync(path.join(root, 'gitignored.ts'), 'g') + writeFileSync(path.join(root, 'cspignored.ts'), 'c') + + const results = await collect(walkFiles(root, ['.ts'])) + const relative = results.map(p => path.relative(root, p)).sort() + expect(relative).toEqual(['keep.ts']) + }) + + test('respects nested .gitignore from subdirectories', async () => { + writeFileSync(path.join(root, 'top.ts'), 't') + mkdirSync(path.join(root, 'sub')) + writeFileSync(path.join(root, 'sub', '.gitignore'), 'skip.ts\n') + writeFileSync(path.join(root, 'sub', 'skip.ts'), 's') + writeFileSync(path.join(root, 'sub', 'keep.ts'), 'k') + + const results = await collect(walkFiles(root, ['.ts'])) + const relative = results.map(p => path.relative(root, p)).sort() + expect(relative).toEqual([path.join('sub', 'keep.ts'), 'top.ts']) + }) + + test('honours the extra `ignore` arg', async () => { + writeFileSync(path.join(root, 'foo.ts'), 'f') + writeFileSync(path.join(root, 'bar.ts'), 'b') + + const results = await collect(walkFiles(root, ['.ts'], ['foo.ts'])) + const relative = results.map(p => path.relative(root, p)).sort() + expect(relative).toEqual(['bar.ts']) + }) + + test('filters by extension (case-insensitive)', async () => { + writeFileSync(path.join(root, 'a.TS'), 'a') + writeFileSync(path.join(root, 'b.ts'), 'b') + writeFileSync(path.join(root, 'c.md'), 'c') + + const results = await collect(walkFiles(root, ['.ts'])) + const relative = results.map(p => path.relative(root, p)).sort() + expect(relative).toEqual(['a.TS', 'b.ts']) + }) +}) + +describeWithIgnore('_loadIgnoreForDir', () => { + let root: string + + beforeEach(() => { + root = mkdtempSync(path.join(os.tmpdir(), 'csp-walker-load-')) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + test('returns null when neither ignore file exists', async () => { + const spec = await _loadIgnoreForDir(root) + expect(spec).toBeNull() + }) + + test('combines .gitignore and .cspignore lines', async () => { + writeFileSync(path.join(root, '.gitignore'), 'a.ts\n') + writeFileSync(path.join(root, '.cspignore'), 'b.ts\n') + const spec = await _loadIgnoreForDir(root) + expect(spec).not.toBeNull() + expect(spec!.patterns.length).toBe(2) + expect(spec!.patterns.map(p => p.pattern)).toEqual(['a.ts', 'b.ts']) + }) + + test('skips blank lines and comments', async () => { + writeFileSync(path.join(root, '.gitignore'), '# comment\n\n*.log\n') + const spec = await _loadIgnoreForDir(root) + expect(spec).not.toBeNull() + expect(spec!.patterns.length).toBe(1) + expect(spec!.patterns[0]!.pattern).toBe('*.log') + }) +}) + +describeWithIgnore('_isIgnored', () => { + let root: string + + beforeEach(() => { + root = mkdtempSync(path.join(os.tmpdir(), 'csp-walker-isig-')) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + test('returns found=true for negation patterns with file extensions', async () => { + writeFileSync(path.join(root, '.gitignore'), '*.log\n!special.log\n') + const spec = await _loadIgnoreForDir(root) + expect(spec).not.toBeNull() + const check = _isIgnored(path.join(root, 'special.log'), false, [spec!]) + expect(check.ignored).toBe(false) + expect(check.found).toBe(true) + }) + + test('returns found=false for negation patterns without file extensions', async () => { + writeFileSync(path.join(root, '.gitignore'), 'vendor/\n!vendor/keep/\n') + const spec = await _loadIgnoreForDir(root) + expect(spec).not.toBeNull() + // The negation pattern `!vendor/keep/` has no extension — should NOT set found. + const check = _isIgnored(path.join(root, 'vendor', 'keep'), true, [spec!]) + expect(check.found).toBe(false) + }) + + test('returns ignored=true when pattern matches', async () => { + writeFileSync(path.join(root, '.gitignore'), '*.log\n') + const spec = await _loadIgnoreForDir(root) + expect(spec).not.toBeNull() + const check = _isIgnored(path.join(root, 'foo.log'), false, [spec!]) + expect(check.ignored).toBe(true) + }) +}) diff --git a/src/indexing/file-walker.ts b/src/indexing/file-walker.ts new file mode 100644 index 0000000..2eb44b5 --- /dev/null +++ b/src/indexing/file-walker.ts @@ -0,0 +1,265 @@ +// Port of src/semble/index/file_walker.py +import { promises as fs } from 'node:fs' +import path from 'node:path' + +// The `ignore` package provides gitignore-style pattern matching. +// We use it as a fast matcher, but we also keep a parallel list of +// `{ pattern, negated, hasExtSuffix }` entries to recreate the +// Python negation-with-extension bypass logic that the npm package +// does not expose. +// +// TODO(integration): use 'ignore' package once Unit 0 lands. Until then, +// the package is referenced via dynamic import below so the rest of the +// surface compiles even when the dep is missing from the lockfile. +type IgnoreModule = typeof import('ignore') +type IgnoreInstance = ReturnType + +interface ParsedPattern { + /** Pattern string as written in the gitignore file, without the leading "!" if any. */ + pattern: string + /** True when the original line started with "!" (a negation pattern). */ + negated: boolean + /** True when the pattern (with any trailing "/" stripped) has a file-extension suffix. */ + hasExtSuffix: boolean + /** Per-pattern matcher (built from `ignore` package) used to test a single pattern. */ + matcher: IgnoreInstance +} + +export interface IgnoreSpec { + /** Base directory the patterns were sourced from. Paths are matched relative to this. */ + base: string + /** + * Aggregate ignore-package matcher containing every pattern in this spec. + * Kept for parity with the Python `GitIgnoreSpec` field and for future + * callers that want a fast pre-check; the per-pattern walk is the + * authoritative decision path. + */ + spec: IgnoreInstance + /** Parsed pattern list (in source order) used for the negation-bypass logic. */ + patterns: readonly ParsedPattern[] +} + +/** + * Default directories that are always ignored when walking. Trailing "/" matches + * directory semantics (gitignore-style). The Python original uses ".semble/" — + * for csp we replace it with ".csp/". + */ +export const DEFAULT_IGNORED_DIRS: ReadonlySet = new Set([ + '.git/', + '.hg/', + '.svn/', + '__pycache__/', + 'node_modules/', + '.venv/', + 'venv/', + '.tox/', + '.mypy_cache/', + '.pytest_cache/', + '.ruff_cache/', + '.cache/', + '.csp/', + '.next/', + 'dist/', + 'build/', + '.eggs/', +]) + +let cachedIgnoreFactory: IgnoreModule['default'] | undefined + +/** + * Resolve the `ignore` package factory lazily so this file can be imported even + * when the dep is not yet installed in the worktree. + */ +async function getIgnoreFactory(): Promise { + if (cachedIgnoreFactory) return cachedIgnoreFactory + const mod = (await import('ignore')) as unknown as IgnoreModule | { default: IgnoreModule['default'] } + // The CJS package exports the factory as the default export under ESM interop. + const factory = (mod as { default?: IgnoreModule['default'] }).default + ?? (mod as unknown as IgnoreModule['default']) + cachedIgnoreFactory = factory + return factory +} + +function hasExtensionSuffix(pattern: string): boolean { + const stripped = pattern.replace(/\/+$/, '') + return path.extname(stripped) !== '' +} + +async function buildSpec(base: string, lines: readonly string[]): Promise { + const factory = await getIgnoreFactory() + const aggregate = factory({ allowRelativePaths: true }) + const patterns: ParsedPattern[] = [] + + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, '') + const trimmed = line.trim() + if (trimmed === '' || trimmed.startsWith('#')) continue + + aggregate.add(line) + + const negated = trimmed.startsWith('!') + const pattern = negated ? trimmed.slice(1) : trimmed + if (pattern === '') continue + + const matcher = factory({ allowRelativePaths: true }).add(pattern) + patterns.push({ + pattern, + negated, + hasExtSuffix: hasExtensionSuffix(pattern), + matcher, + }) + } + + return { base, spec: aggregate, patterns } +} + +/** + * Loads `.gitignore` and `.cspignore` from the given directory and merges them + * into a single IgnoreSpec, or returns `null` when neither file is present. + */ +export async function _loadIgnoreForDir(directory: string): Promise { + const gitignorePath = path.join(directory, '.gitignore') + const cspignorePath = path.join(directory, '.cspignore') + + const lines: string[] = [] + for (const file of [gitignorePath, cspignorePath]) { + try { + const stat = await fs.stat(file) + if (!stat.isFile()) continue + const text = await fs.readFile(file, 'utf8') + lines.push(...text.split(/\r?\n/)) + } + catch { + // missing file — fine + } + } + + if (lines.length === 0) return null + return buildSpec(directory, lines) +} + +/** + * Result of `_isIgnored`. `ignored` is the final gitignore decision; `found` + * signals that a negation pattern with a file-extension suffix matched, which + * lets the file bypass the extension-allowlist filter (mirrors semble). + */ +export interface IgnoreCheck { + ignored: boolean + found: boolean +} + +/** + * Check whether a path is ignored by any of the provided ignore specs. + * + * Port of `_is_ignored` in semble. Each spec's patterns are checked in source + * order; later matches override earlier ones (standard gitignore semantics). + * When the *winning* match is a negation pattern with a file-extension suffix + * (e.g. `!special.kjs`, `!*.py`), `found` becomes true so that the caller can + * include the file even if its extension is not in the allowlist. + */ +export function _isIgnored( + filePath: string, + isDir: boolean, + specs: readonly IgnoreSpec[], +): IgnoreCheck { + let ignored = false + let found = false + + for (const ignoreSpec of specs) { + const relative = path.relative(ignoreSpec.base, filePath) + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { + // Not under this spec's base — skip. + continue + } + + const posixRelative = relative.split(path.sep).join('/') + const candidate = isDir ? `${posixRelative}/` : posixRelative + + for (const pattern of ignoreSpec.patterns) { + let matched = false + try { + matched = pattern.matcher.ignores(candidate) + } + catch { + // The `ignore` package rejects a few edge cases (e.g. paths outside + // the cwd when allowRelativePaths is off); treat as non-match. + matched = false + } + + if (!matched) continue + + // Last winning pattern wins. + ignored = !pattern.negated + found = !ignored && pattern.hasExtSuffix + } + } + + return { ignored, found } +} + +/** + * Recursively walk `directory`, yielding files matching `extensions`. Hidden + * directories are not implicitly skipped — the caller controls this via the + * default-ignored set passed to `walkFiles`. + */ +export async function* _walk( + directory: string, + inheritedSpecs: readonly IgnoreSpec[], + extensions: ReadonlySet, +): AsyncIterable { + const dirSpec = await _loadIgnoreForDir(directory) + const specs: readonly IgnoreSpec[] = dirSpec + ? [...inheritedSpecs, dirSpec] + : inheritedSpecs + + let entries: import('node:fs').Dirent[] + try { + entries = await fs.readdir(directory, { withFileTypes: true }) + } + catch { + return + } + + entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) + + for (const entry of entries) { + if (entry.isSymbolicLink()) continue + const full = path.join(directory, entry.name) + const isDir = entry.isDirectory() + const { ignored, found } = _isIgnored(full, isDir, specs) + if (ignored) continue + + if (isDir) { + yield * _walk(full, specs, extensions) + } + else if (entry.isFile()) { + if (found || extensions.has(path.extname(entry.name).toLowerCase())) { + yield full + } + } + } +} + +/** + * Yield files under `root` whose extension is in `extensions`, skipping ignored + * paths. Default-ignored directories (see `DEFAULT_IGNORED_DIRS`) are always + * skipped, plus any extra patterns in `ignore`. `.gitignore` / `.cspignore` + * files encountered during traversal are honoured recursively. + * + * @param root Root directory to walk. + * @param extensions Allowed file extensions (lowercase, including the leading dot). + * @param ignore Additional gitignore-style patterns to ignore. + */ +export async function* walkFiles( + root: string, + extensions: readonly string[], + ignore?: readonly string[], +): AsyncIterable { + const extensionsSet: ReadonlySet = new Set(extensions.map(e => e.toLowerCase())) + const dirPatterns: string[] = [ + ...[...DEFAULT_IGNORED_DIRS].sort(), + ...(ignore ?? []), + ] + const baseSpec = await buildSpec(root, dirPatterns) + yield * _walk(root, [baseSpec], extensionsSet) +} From 3a43e4886f108b4e2fa3657586ccb297b3560ace Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 29 May 2026 00:44:50 +0900 Subject: [PATCH 2/2] review(indexing): apply gemini-code-assist feedback (file-walker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply gemini-code-assist's performance optimization to _isIgnored: - Add hasNegatedExtPattern cache on IgnoreSpec, computed once in buildSpec - Consult the aggregate ignore matcher via .test() as the hot path - Skip the per-pattern walk when the path doesn't match, is plainly ignored, or is unignored but the spec contains no negated extension patterns (so 'found' cannot change) - Fall back to the per-pattern loop only when a negation could win AND the spec carries at least one negated extension pattern — the only case where 'found' may flip to true Behavior is equivalent: the per-pattern loop's last-winning-pattern rule matches gitignore semantics that .test() already enforces. Verified by the existing 17 negation-bypass / nested-gitignore tests plus 4 new tests covering hasNegatedExtPattern and cross-spec state preservation. Mirrors semble's _is_ignored in src/semble/index/file_walker.py. Co-Authored-By: gemini-code-assist[bot] --- src/indexing/file-walker.test.ts | 38 ++++++++++++++++++++ src/indexing/file-walker.ts | 61 ++++++++++++++++++++++++++++---- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/src/indexing/file-walker.test.ts b/src/indexing/file-walker.test.ts index 995e11f..3235dd4 100644 --- a/src/indexing/file-walker.test.ts +++ b/src/indexing/file-walker.test.ts @@ -228,4 +228,42 @@ describeWithIgnore('_isIgnored', () => { const check = _isIgnored(path.join(root, 'foo.log'), false, [spec!]) expect(check.ignored).toBe(true) }) + + test('hasNegatedExtPattern is true when a negation pattern has an extension suffix', async () => { + writeFileSync(path.join(root, '.gitignore'), '*.log\n!special.log\n') + const spec = await _loadIgnoreForDir(root) + expect(spec).not.toBeNull() + expect(spec!.hasNegatedExtPattern).toBe(true) + }) + + test('hasNegatedExtPattern is false when negation patterns have no extension suffix', async () => { + writeFileSync(path.join(root, '.gitignore'), 'vendor/\n!vendor/keep/\n') + const spec = await _loadIgnoreForDir(root) + expect(spec).not.toBeNull() + expect(spec!.hasNegatedExtPattern).toBe(false) + }) + + test('hasNegatedExtPattern is false when there are no negation patterns', async () => { + writeFileSync(path.join(root, '.gitignore'), '*.log\n*.tmp\n') + const spec = await _loadIgnoreForDir(root) + expect(spec).not.toBeNull() + expect(spec!.hasNegatedExtPattern).toBe(false) + }) + + test('preserves outer ignored state across specs when current spec has no match', async () => { + // Outer spec ignores foo.log; inner spec has unrelated rules. + writeFileSync(path.join(root, '.gitignore'), '*.log\n') + const outerSpec = await _loadIgnoreForDir(root) + expect(outerSpec).not.toBeNull() + + const sub = path.join(root, 'sub') + mkdirSync(sub) + writeFileSync(path.join(sub, '.gitignore'), '*.tmp\n') + const innerSpec = await _loadIgnoreForDir(sub) + expect(innerSpec).not.toBeNull() + + // foo.log lives under sub/, matches outer's *.log, doesn't match inner. + const check = _isIgnored(path.join(sub, 'foo.log'), false, [outerSpec!, innerSpec!]) + expect(check.ignored).toBe(true) + }) }) diff --git a/src/indexing/file-walker.ts b/src/indexing/file-walker.ts index 2eb44b5..058d827 100644 --- a/src/indexing/file-walker.ts +++ b/src/indexing/file-walker.ts @@ -30,13 +30,19 @@ export interface IgnoreSpec { base: string /** * Aggregate ignore-package matcher containing every pattern in this spec. - * Kept for parity with the Python `GitIgnoreSpec` field and for future - * callers that want a fast pre-check; the per-pattern walk is the - * authoritative decision path. + * Used as a fast pre-check via `.test()` in `_isIgnored`; the per-pattern + * walk is only consulted when a negation pattern with an extension suffix + * could win, so the bypass-extension-filter (`found`) decision can be made. */ spec: IgnoreInstance /** Parsed pattern list (in source order) used for the negation-bypass logic. */ patterns: readonly ParsedPattern[] + /** + * Pre-computed flag: true when at least one pattern in this spec is both + * negated (`!`) and has a file-extension suffix. When false, `_isIgnored` + * can skip the per-pattern walk after consulting the aggregate matcher. + */ + hasNegatedExtPattern: boolean } /** @@ -110,7 +116,9 @@ async function buildSpec(base: string, lines: readonly string[]): Promise p.negated && p.hasExtSuffix) + + return { base, spec: aggregate, patterns, hasNegatedExtPattern } } /** @@ -156,6 +164,14 @@ export interface IgnoreCheck { * When the *winning* match is a negation pattern with a file-extension suffix * (e.g. `!special.kjs`, `!*.py`), `found` becomes true so that the caller can * include the file even if its extension is not in the allowlist. + * + * Hot-path optimization: the aggregate `ignore`-package matcher is consulted + * first via `.test()`. If no pattern in the spec matches at all, we carry the + * outer state forward. If a pattern matches and the spec contains no negated + * extension patterns, the answer is fully determined by the aggregate and the + * per-pattern walk is skipped. The per-pattern walk runs only when a negation + * could win AND the spec carries at least one negated extension pattern — + * i.e. when `found` could change to `true`. */ export function _isIgnored( filePath: string, @@ -175,14 +191,47 @@ export function _isIgnored( const posixRelative = relative.split(path.sep).join('/') const candidate = isDir ? `${posixRelative}/` : posixRelative + let aggregateResult: { ignored: boolean, unignored: boolean } + try { + aggregateResult = ignoreSpec.spec.test(candidate) + } + catch { + // The `ignore` package rejects a few edge cases (e.g. paths outside + // the cwd when allowRelativePaths is off); treat as non-match. + aggregateResult = { ignored: false, unignored: false } + } + + const { ignored: isIgnoredBySpec, unignored: isUnignoredBySpec } = aggregateResult + + if (!isIgnoredBySpec && !isUnignoredBySpec) { + // No pattern in this spec matched — preserve outer state. + continue + } + + if (isIgnoredBySpec) { + // Winning pattern is a non-negated ignore. The original loop would set + // `ignored = true; found = false` here regardless of pattern suffix. + ignored = true + found = false + continue + } + + // isUnignoredBySpec: a negation pattern won in this spec. + if (!ignoreSpec.hasNegatedExtPattern) { + // No negation pattern in this spec has an extension suffix, so `found` + // cannot become true here. + ignored = false + found = false + continue + } + + // Fall back to the per-pattern walk to determine `found` accurately. for (const pattern of ignoreSpec.patterns) { let matched = false try { matched = pattern.matcher.ignores(candidate) } catch { - // The `ignore` package rejects a few edge cases (e.g. paths outside - // the cwd when allowRelativePaths is off); treat as non-match. matched = false }