diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c8a17a43..51d4b70a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Running `codegraph status` while `codegraph index` rebuilds a project no longer makes the rebuild fail with "database is locked": status now only reads the index, and it says when a rebuild is running in another process, in its output and as `rebuildInProgress` in `status --json`. Thanks @sealad886 for the report, and @danusha2345 and @KissFeng for the fixes. (#2913) - Running `codegraph query`, `explore`, `node`, `callers`, `callees`, `impact`, `context`, `files` or `affected` while `codegraph index` rebuilds a project no longer makes the rebuild fail with "database is locked", and neither does the context hook Claude Code runs on its own when you ask about your code: like `codegraph status`, they now only read the index and say when a rebuild is running, and the hook stays quiet until the rebuild is done. They still answer from an index an older CodeGraph version built. Thanks @sealad886 for the report. (#2913) - A qualified name given to `codegraph callers`, `callees` or `impact`, or to `codegraph_node`, like `Format.Price`, now names only the symbol whose scope is spelled that way, so its answer no longer merges in `LegacyFormat.Price` or any other class whose name only ends with `Format`, nor a class that just sits in a file named `Format.cs`. In C#, `BasketService.AddItemToBasket` no longer also selects the `IBasketService` interface's method; ask for `IBasketService.AddItemToBasket` for that one. A file or folder still names the module in Rust, Python and Go, and a PHP namespace can be written with its own backslashes, like `Billing\Format::price`. No re-index needed. Thanks @Gobakabana for the report and @yang0228. (#2908) +- `codegraph affected --filter` now matches its glob against whole names: `tests/**/*.py` also finds the tests directly inside `tests/`, including a changed test file itself, `tests/*.py` no longer counts a changed `tests/x.py.bak` as a test, and `e2e/*.spec.ts` no longer matches a folder that only ends in `e2e`, like `xe2e/`. A pattern with a folder in it still matches that folder at any depth, so `e2e/*.spec.ts` keeps finding `apps/web/e2e/login.spec.ts`; start it with `/` to match from the project root only. Thanks @Jseca666. - `codegraph init` and `codegraph index` no longer report a file you deleted as one that "could not be read" while git still lists it, which it does until the deletion is staged: such a file is now skipped, and so is a symlink that points at nothing. A file that is there but can't be read is still reported. Thanks @xianzhi-xzw for the report and the first fix. (#2904) - A full index of a project that holds a video named `.ts`, like an MPEG-TS clip among test fixtures, no longer warns that the index is PARTIAL and missing a file, and `codegraph status` no longer reports the index as partial: the video is skipped on purpose, and it now counts as skipped. Thanks @Hetox for the original report. (#1910) - In CFML, a `` or `` block the parser can't read no longer marks its whole file as contributing nothing to the graph: `codegraph init` no longer warns about the file, and `codegraph status` no longer lists it among files whose symbols are missing, since its component, functions and calls are all indexed. The file's recorded errors now name the block and its line instead. Thanks @HarryMuc for the report. (#2654) diff --git a/__tests__/cli-affected-filter-globs.test.ts b/__tests__/cli-affected-filter-globs.test.ts new file mode 100644 index 000000000..0f4a48f86 --- /dev/null +++ b/__tests__/cli-affected-filter-globs.test.ts @@ -0,0 +1,122 @@ +// `codegraph affected --filter ` matches whole names (#1273). +// +// The filter used to be hand-converted to an unanchored regex (`**` → `.+`, +// `*` → `[^/]*`), so it matched anywhere inside a path: `tests/**/*.py` +// missed `tests/x.py` (`.+` needs a folder), `tests/*.py` took a changed +// `tests/x.py.bak` for a test, and `e2e/*.spec.ts` took an `xe2e/` folder. +// A pattern with a slash still matches from any folder boundary, as the help +// text's own `e2e/*.spec.ts` always did for `apps/web/e2e/…`; a leading `/` +// anchors it at the project root. Exercised end-to-end against the built binary. +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +describe('codegraph affected --filter globs (#1273)', () => { + let dir: string; + + function affected(changed: string, filter: string): string[] { + const out = execFileSync(process.execPath, [BIN, 'affected', changed, '--filter', filter, '--quiet', '-p', dir], { + encoding: 'utf-8', + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return out.split('\n').map((s) => s.trim()).filter(Boolean); + } + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-affected-globs-')); + const w = (rel: string, body: string) => { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), body); + }; + const pyTest = 'from math_utils import add_one\n\ndef test_add_one():\n assert add_one(1) == 2\n'; + w('math_utils.py', 'def add_one(value):\n return value + 1\n'); + w('tests/test_math_utils.py', pyTest); + w('tests/unit/test_math_utils.py', pyTest); + w('tests/unit/deep/test_math_utils.py', pyTest); + w('other/tests/test_math_utils.py', pyTest); + w('checks/math_check.py', pyTest); + w('tests/test_math_utils.py.bak', '# a backup, not a test\n'); + w('tests/.hidden.py', '# a dotfile the filter names\n'); + w('src/login.ts', 'export function login(user: string): boolean { return user.length > 0; }\n'); + const spec = (rel: string) => `import { login } from '${rel}';\ntest('logs in', () => login('a'));\n`; + w('e2e/login.spec.ts', spec('../src/login')); + w('apps/web/e2e/login.spec.ts', spec('../../../src/login')); + w('xe2e/login.spec.ts', spec('../src/login')); + const cg = CodeGraph.initSync(dir); + try { + await cg.indexAll(); + } finally { + cg.close(); + } + }, 60_000); + + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('lets ** match zero folders, so a test directly in tests/ counts', () => { + // main: only the two files below tests/unit/. + expect(affected('math_utils.py', 'tests/**/*.py')).toEqual([ + 'other/tests/test_math_utils.py', + 'tests/test_math_utils.py', + 'tests/unit/deep/test_math_utils.py', + 'tests/unit/test_math_utils.py', + ]); + // main: [] — the changed test itself didn't match its own filter. + expect(affected('tests/test_math_utils.py', 'tests/**/*.py')).toEqual(['tests/test_math_utils.py']); + }); + + it('matches the whole file name, so a .bak file is not a test', () => { + // main: listed the backup as an affected test. + expect(affected('tests/test_math_utils.py.bak', 'tests/*.py')).toEqual([]); + }); + + it('matches a slash pattern at folder boundaries only, at any depth', () => { + // main: also listed xe2e/login.spec.ts. + expect(affected('src/login.ts', 'e2e/*.spec.ts')).toEqual(['apps/web/e2e/login.spec.ts', 'e2e/login.spec.ts']); + // Depth-free by design: tests/*.py keeps matching a nested tests/ folder. + expect(affected('math_utils.py', 'tests/*.py')).toEqual(['other/tests/test_math_utils.py', 'tests/test_math_utils.py']); + }); + + it('anchors a pattern that starts with / or ./ at the project root', () => { + expect(affected('math_utils.py', '/tests/*.py')).toEqual(['tests/test_math_utils.py']); + expect(affected('math_utils.py', './tests/*.py')).toEqual(['tests/test_math_utils.py']); + expect(affected('math_utils.py', '/tests/**/*.py')).toEqual([ + 'tests/test_math_utils.py', + 'tests/unit/deep/test_math_utils.py', + 'tests/unit/test_math_utils.py', + ]); + expect(affected('src/login.ts', '/e2e/*.spec.ts')).toEqual(['e2e/login.spec.ts']); + }); + + it('takes everything under a folder named with a trailing slash', () => { + expect(affected('math_utils.py', 'tests/')).toEqual([ + 'other/tests/test_math_utils.py', + 'tests/test_math_utils.py', + 'tests/unit/deep/test_math_utils.py', + 'tests/unit/test_math_utils.py', + ]); + }); + + it('keeps file-name patterns at any depth, folders outside the test conventions, and dotfiles', () => { + expect(affected('math_utils.py', 'test_*.py')).toEqual([ + 'other/tests/test_math_utils.py', + 'tests/test_math_utils.py', + 'tests/unit/deep/test_math_utils.py', + 'tests/unit/test_math_utils.py', + ]); + expect(affected('src/login.ts', '*.spec.ts')).toEqual([ + 'apps/web/e2e/login.spec.ts', + 'e2e/login.spec.ts', + 'xe2e/login.spec.ts', + ]); + expect(affected('math_utils.py', 'checks/*.py')).toEqual(['checks/math_check.py']); + expect(affected('tests/.hidden.py', 'tests/*.py')).toEqual(['tests/.hidden.py']); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index b041843ff..17748416e 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -2617,6 +2617,37 @@ program } }); +// The matcher for `affected --filter `, 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 ', 'Project path') .option('--stdin', 'Read file list from stdin (one per line)') .option('-d, --depth ', 'Max dependency traversal depth', '5') - .option('-f, --filter ', 'Custom glob filter for test files (e.g. "e2e/*.spec.ts")') + .option('-f, --filter ', '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); } - const cg = await openIndexToRead(projectPath); - const maxDepth = parseInt(options.depth || '5', 10); - - // Custom filter pattern - let customFilter: RegExp | null = null; + // 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) { - // Convert glob to regex: ** → .+, * → [^/]*, . → \. - const regex = options.filter - .replace(/[+[\]{}()^$|\\]/g, '\\$&') - .replace(/\./g, '\\.') - .replace(/\*\*/g, '.+') - .replace(/\*/g, '[^/]*'); - customFilter = new RegExp(regex); + 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); + // 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); }