fix(affected): a --filter glob matches whole names, from any folder boundary (#3029)

`codegraph affected --filter` turned its glob into an unanchored regex by
hand (`**` to `.+`, `*` to `[^/]*`, no `^` or `$`), so it matched anywhere
inside a path: `tests/**/*.py` missed `tests/x.py` (`.+` needs a folder),
a changed `tests/x.py.bak` counted as a test under `tests/*.py`, the
changed test itself was missed under `tests/**/*.py`, `e2e/*.spec.ts`
took an `xe2e/` folder, and `/tests/*.py` matched only `other/tests/...`.

The filter is now a picomatch glob (an existing dependency, loaded only by
this command) with `dot: true`, matched against the whole project-relative
path. A pattern with no slash matches the file name at any depth. One with
a slash matches from any folder boundary, an implicit leading `**/`, which
keeps the help text's own `e2e/*.spec.ts` matching `apps/web/e2e/...` as
the filter always did; a leading `/` or `./` anchors it at the project
root, and a trailing `/` takes everything under that folder. A pattern
picomatch rejects is reported as an invalid --filter instead of failing
later.

Validation: new CLI-level cli-affected-filter-globs tests (6): 4 fail on
main's source and pass with the fix; the other 2 guard behavior kept on
purpose (file-name patterns at any depth, folders outside the test
conventions, dotfiles, a trailing-slash folder). The existing affected
CLI suites pass. The triage fixture shows the same before and after.

Reimplements PR #1273 by @Jseca666.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-29 22:26:45 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 3d19da2f59
commit 60b0d7a92d
3 changed files with 168 additions and 14 deletions
+45 -14
View File
@@ -2617,6 +2617,37 @@ program
}
});
// The matcher for `affected --filter <glob>`, tested against the
// project-relative, forward-slash paths the index stores (#1273).
//
// - No slash (`*_test.go`, `test_*.py`): the file name, at any depth.
// - A slash (`e2e/*.spec.ts`, `tests/**/*.py`): the path from any folder
// boundary, so `e2e/*.spec.ts` also matches `apps/web/e2e/login.spec.ts`
// (an implicit leading `**/`), as the filter always has.
// - A leading `/` or `./`: from the project root only.
// - A trailing `/` (`tests/`): everything under that folder.
//
// The whole name must match: `tests/*.py` no longer takes `tests/x.py.bak`,
// nor `e2e/*.spec.ts` an `xe2e/` folder, and `tests/**/*.py` now takes
// `tests/x.py` (`**` matches zero folders). Dotfiles count. picomatch is
// loaded here, not at startup, since only this command needs it.
async function affectedFilterMatcher(pattern: string): Promise<(filePath: string) => boolean> {
const { default: picomatch } = await import('picomatch');
// `windows: false`: the stored paths always use `/`, so the glob reads the
// same on every platform (picomatch otherwise follows the host OS).
const options = { dot: true, windows: false };
let glob = pattern.trim();
if (glob.endsWith('/')) glob += '**';
if (!glob.includes('/')) {
const matchName = picomatch(glob, options);
return (filePath) => matchName(path.posix.basename(filePath));
}
const anchored = /^\.?\//.test(glob);
glob = glob.replace(/^\.?\/+/, '');
if (!anchored && !glob.startsWith('**/')) glob = `**/${glob}`;
return picomatch(glob, options);
}
/**
* codegraph affected [files...]
*
@@ -2633,7 +2664,7 @@ program
.option('-p, --path <path>', 'Project path')
.option('--stdin', 'Read file list from stdin (one per line)')
.option('-d, --depth <number>', 'Max dependency traversal depth', '5')
.option('-f, --filter <glob>', 'Custom glob filter for test files (e.g. "e2e/*.spec.ts")')
.option('-f, --filter <glob>', 'Glob for the files that count as tests, matched against the whole name: "*_test.go" matches the file name at any depth, "e2e/*.spec.ts" that folder at any depth, "/tests/**/*.py" from the project root only')
.option('-j, --json', 'Output as JSON')
.option('-q, --quiet', 'Only output file paths, no decoration')
.action(async (fileArgs: string[], options: { path?: string; stdin?: boolean; depth?: string; filter?: string; json?: boolean; quiet?: boolean }) => {
@@ -2667,28 +2698,28 @@ program
process.exit(0);
}
// Custom filter pattern, compiled before the index is opened so a bad
// pattern fails fast.
let customFilter: ((filePath: string) => boolean) | null = null;
if (options.filter) {
try {
customFilter = await affectedFilterMatcher(options.filter);
} catch (err) {
error(`Invalid --filter pattern "${options.filter}": ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
const cg = await openIndexToRead(projectPath);
const maxDepth = parseInt(options.depth || '5', 10);
// Custom filter pattern
let customFilter: RegExp | null = null;
if (options.filter) {
// Convert glob to regex: ** → .+, * → [^/]*, . → \.
const regex = options.filter
.replace(/[+[\]{}()^$|\\]/g, '\\$&')
.replace(/\./g, '\\.')
.replace(/\*\*/g, '.+')
.replace(/\*/g, '[^/]*');
customFilter = new RegExp(regex);
}
// One notion of "a test" for the whole tool (#1507): the CLI used to keep
// its own six regexes here, which knew `.test.` and `/tests/` but not Go's
// `_test.go`, Python's `test_x.py` or the JVM's `FooTest.kt` — so
// `affected` reported "no tests" for whole ecosystems while `search` and
// the MCP tools counted those very files as tests.
function isTestFile(filePath: string): boolean {
if (customFilter) return customFilter.test(filePath);
if (customFilter) return customFilter(filePath);
return isTestPath(filePath);
}