From 2f8cce5c57beddf2da1922d6c02906e1928184c7 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 8 Sep 2026 12:17:06 -0500 Subject: [PATCH 1/5] fix(installer): honor CLAUDE_CONFIG_DIR and CODEX_HOME (#1627) (#1783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(installer): honor CODEX_HOME for the Codex global install (#1627) Codex resolves its user layer from `CODEX_HOME` and only falls back to `~/.codex`. The target hardcoded the fallback, so a user on a custom profile got a correct install into a directory Codex never reads — the MCP entry, the AGENTS.md block, and detect() all pointed at the wrong profile, and the failure is silent. Resolve the global config dir from `CODEX_HOME` when set and non-blank, mirroring what the copilot-cli target already does for `COPILOT_HOME`. Only the user layer moves; the project layer (#1531) stays anchored to the project. The test harness now also clears `CODEX_HOME` in setHome() alongside HERMES_HOME/COPILOT_HOME — without that, the existing codex tests fail on a developer machine that has the variable exported. Note this is only half of #1627: the CLAUDE_CONFIG_DIR half is already covered by the open PR #1029, which this deliberately does not touch. Co-Authored-By: Claude * fix(installer): honor CLAUDE_CONFIG_DIR for global Claude installs Build on #1633 by @maxmilian and port the CLAUDE_CONFIG_DIR approach from #1029 by @borfast onto the current installer. Keep the CODEX_HOME cherry-pick cbb08231 intact. Resolve non-blank Claude profile paths with path.resolve. Put the global MCP JSON inside a custom profile while preserving ~/.claude.json for the default profile. Settings, instructions, detection, and uninstall follow the selected profile; local installs keep their existing paths. Clear and restore CLAUDE_CONFIG_DIR in the setHome test harness. Cover absolute and relative profiles, idempotency, unset/empty/blank fallback, default-profile preservation, detection/uninstall, and local installs. Combine the Unreleased note for both environment variables. Thanks @seanchann for reporting the issue. Validation on Linux with Node 22.19.0: - npx tsc -p tsconfig.json - npx vitest run __tests__/installer-targets.test.ts: 245 passed, 3 skipped - Reproduced both failures against main e720f6ca; the rebuilt CLI writes all files into CLAUDE_CONFIG_DIR and CODEX_HOME with no ~/.claude, ~/.claude.json, or ~/.codex created. Fixes #1627 --------- Co-authored-by: Max Hsu Co-authored-by: Claude Co-authored-by: Colby McHenry --- CHANGELOG.md | 2 + __tests__/installer-targets.test.ts | 211 ++++++++++++++++++++++++++++ src/installer/targets/claude.ts | 26 +++- src/installer/targets/codex.ts | 16 ++- 4 files changed, 246 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 380d612ba..b90cdd753 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -145,6 +145,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Indexing no longer checks whether files outside your project exist. A relative import that points above the project directory (`../../something`) made CodeGraph probe that location on disk while resolving it. Nothing outside the project was ever read, and no such file was ever added to the index or linked to, but the check itself should not have happened — such an import now simply resolves to nothing. Symlinks inside your project that point at code kept elsewhere are unaffected and still index as before. Thanks @ErQrYfkrju. (#1631) +- `codegraph install` now honors `CLAUDE_CONFIG_DIR` and `CODEX_HOME` for global Claude Code and Codex setup so CodeGraph loads in your chosen profile (thanks @seanchann; #1627). + #### Screens, links and navigation - **Where the app goes after login is a fork, not two always-es.** A navigation whose destination comes back from a helper — `router.replace(await resolvePostLoginRoute())` over `return (await hasSeenWelcome(…)) ? '/home/' : '/welcome/'` — drew both screens with no condition, reading as if the welcome screen always shows. The two arms share a line, and only a column can tell them apart; each synthesized edge now carries its literal's own position, so the guard reader says which arm it is: `WHEN await hasSeenWelcome(…)` → home, and its negation → welcome. And the scan starts at the helper's body, so a literal-union return type — `Promise<'/welcome/' | '/home/'>`, whose routes are string literals too, written first — no longer stands in for the navigation itself. Re-index after upgrading to pick the positions up. diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 6741c867e..bdf57bd03 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -40,6 +40,8 @@ function setHome(dir: string): { restore: () => void } { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, HERMES_HOME: process.env.HERMES_HOME, COPILOT_HOME: process.env.COPILOT_HOME, + CODEX_HOME: process.env.CODEX_HOME, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, }; process.env.HOME = dir; process.env.USERPROFILE = dir; @@ -47,6 +49,8 @@ function setHome(dir: string): { restore: () => void } { process.env.XDG_CONFIG_HOME = path.join(dir, '.config'); delete process.env.HERMES_HOME; delete process.env.COPILOT_HOME; + delete process.env.CODEX_HOME; + delete process.env.CLAUDE_CONFIG_DIR; return { restore() { if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME; @@ -55,6 +59,8 @@ function setHome(dir: string): { restore: () => void } { if (prev.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = prev.XDG_CONFIG_HOME; if (prev.HERMES_HOME === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = prev.HERMES_HOME; if (prev.COPILOT_HOME === undefined) delete process.env.COPILOT_HOME; else process.env.COPILOT_HOME = prev.COPILOT_HOME; + if (prev.CODEX_HOME === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = prev.CODEX_HOME; + if (prev.CLAUDE_CONFIG_DIR === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = prev.CLAUDE_CONFIG_DIR; }, }; } @@ -2665,3 +2671,208 @@ describe('Installer targets — Copilot family', () => { expect(jetbrains.detect('global').alreadyConfigured).toBe(true); }); }); + +describe('Installer targets — Claude CLAUDE_CONFIG_DIR override (#1627)', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + + beforeEach(() => { + tmpHome = mkTmpDir('home'); + tmpCwd = mkTmpDir('cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + it.each(['absolute', 'relative'])('global install honors %s CLAUDE_CONFIG_DIR paths', (kind) => { + const custom = path.join(tmpHome, 'claude profile'); + process.env.CLAUDE_CONFIG_DIR = kind === 'relative' ? path.relative(tmpCwd, custom) : custom; + + const claude = getTarget('claude')!; + const result = claude.install('global', { autoAllow: true }); + const paths = [ + path.join(custom, '.claude.json'), + path.join(custom, 'settings.json'), + path.join(custom, 'CLAUDE.md'), + ] as const; + + expect(result.files.map((f) => f.path)).toEqual(paths); + const mcp = JSON.parse(fs.readFileSync(paths[0], 'utf-8')); + expect(mcp.mcpServers.codegraph.alwaysLoad).toBe(true); + const settings = JSON.parse(fs.readFileSync(paths[1], 'utf-8')); + expect(settings.permissions.allow).toContain('mcp__codegraph__*'); + expect(fs.readFileSync(paths[2], 'utf-8')).toContain('codegraph explore'); + expect(claude.describePaths('global')).toEqual(paths); + expect(claude.printConfig('global')).toContain(`# Add to ${paths[0]}`); + + const before = paths.map((p) => fs.readFileSync(p, 'utf-8')); + expect(claude.install('global', { autoAllow: true }).files.every((f) => f.action === 'unchanged')).toBe(true); + expect(paths.map((p) => fs.readFileSync(p, 'utf-8'))).toEqual(before); + expect(fs.existsSync(path.join(tmpHome, '.claude'))).toBe(false); + expect(fs.existsSync(path.join(tmpHome, '.claude.json'))).toBe(false); + }); + + it('detect and uninstall follow CLAUDE_CONFIG_DIR without touching the default profile', () => { + const claude = getTarget('claude')!; + claude.install('global', { autoAllow: true }); + const defaults = claude.describePaths('global'); + const before = defaults.map((p) => fs.readFileSync(p, 'utf-8')); + + const custom = path.join(tmpHome, 'claude-profile'); + process.env.CLAUDE_CONFIG_DIR = custom; + const mcpPath = path.join(custom, '.claude.json'); + expect(claude.detect('global')).toEqual({ + installed: false, alreadyConfigured: false, configPath: mcpPath, + }); + + claude.install('global', { autoAllow: true }); + expect(claude.detect('global')).toEqual({ + installed: true, alreadyConfigured: true, configPath: mcpPath, + }); + + const removed = claude.uninstall('global'); + expect(removed.files).toEqual([ + { path: mcpPath, action: 'removed' }, + { path: path.join(custom, 'settings.json'), action: 'removed' }, + { path: path.join(custom, 'CLAUDE.md'), action: 'removed' }, + ]); + expect(JSON.parse(fs.readFileSync(mcpPath, 'utf-8')).mcpServers).toBeUndefined(); + expect(JSON.parse(fs.readFileSync(path.join(custom, 'settings.json'), 'utf-8')).permissions).toBeUndefined(); + expect(fs.existsSync(path.join(custom, 'CLAUDE.md'))).toBe(false); + expect(claude.detect('global').alreadyConfigured).toBe(false); + expect(defaults.map((p) => fs.readFileSync(p, 'utf-8'))).toEqual(before); + }); + + it.each([undefined, '', ' '])('falls back to the default profile when CLAUDE_CONFIG_DIR is %j', (override) => { + if (override !== undefined) process.env.CLAUDE_CONFIG_DIR = override; + const claude = getTarget('claude')!; + const result = claude.install('global', { autoAllow: true }); + + expect(result.files.map((f) => f.path)).toEqual([ + path.join(tmpHome, '.claude.json'), + path.join(tmpHome, '.claude', 'settings.json'), + path.join(tmpHome, '.claude', 'CLAUDE.md'), + ]); + expect(JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude.json'), 'utf-8')).mcpServers.codegraph).toBeDefined(); + expect(fs.existsSync(path.join(tmpHome, '.claude', 'settings.json'))).toBe(true); + expect(fs.existsSync(path.join(tmpHome, '.claude', 'CLAUDE.md'))).toBe(true); + // Claude Code keeps the default MCP JSON beside ~/.claude, not inside it. + expect(fs.existsSync(path.join(tmpHome, '.claude', '.claude.json'))).toBe(false); + }); + + it('leaves local install, detect, and uninstall unaffected by CLAUDE_CONFIG_DIR', () => { + const custom = path.join(tmpHome, 'claude-profile'); + process.env.CLAUDE_CONFIG_DIR = custom; + const claude = getTarget('claude')!; + const result = claude.install('local', { autoAllow: true }); + const mcpPath = path.join(tmpCwd, '.mcp.json'); + + expect(result.files.map((f) => f.path)).toEqual([ + mcpPath, + path.join(tmpCwd, '.claude', 'settings.json'), + path.join(tmpCwd, '.claude', 'CLAUDE.md'), + ]); + expect(JSON.parse(fs.readFileSync(mcpPath, 'utf-8')).mcpServers.codegraph).toBeDefined(); + expect(claude.detect('local')).toEqual({ + installed: true, alreadyConfigured: true, configPath: mcpPath, + }); + claude.uninstall('local'); + expect(claude.detect('local').alreadyConfigured).toBe(false); + expect(fs.existsSync(custom)).toBe(false); + expect(fs.existsSync(path.join(tmpHome, '.claude'))).toBe(false); + expect(fs.existsSync(path.join(tmpHome, '.claude.json'))).toBe(false); + }); +}); + +describe('Installer targets — Codex CODEX_HOME override (#1627)', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + + beforeEach(() => { + tmpHome = mkTmpDir('home'); + tmpCwd = mkTmpDir('cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + const defaultDir = () => path.join(tmpHome, '.codex'); + + it('global install writes to $CODEX_HOME, not ~/.codex', () => { + const custom = path.join(tmpHome, 'codex-profile'); + process.env.CODEX_HOME = custom; + + const codex = getTarget('codex')!; + const result = codex.install('global', { autoAllow: false }); + + const toml = result.files.find((f) => f.path.endsWith('config.toml'))!; + expect(path.resolve(toml.path)).toBe(path.resolve(path.join(custom, 'config.toml'))); + expect(fs.readFileSync(path.join(custom, 'config.toml'), 'utf-8')).toContain('[mcp_servers.codegraph]'); + // The global AGENTS.md follows the config dir. + expect(fs.existsSync(path.join(custom, 'AGENTS.md'))).toBe(true); + // Nothing of ours may land in the default profile Codex is not reading. + expect(fs.existsSync(defaultDir())).toBe(false); + }); + + it('detect and uninstall follow $CODEX_HOME too', () => { + const custom = path.join(tmpHome, 'codex-profile'); + process.env.CODEX_HOME = custom; + const codex = getTarget('codex')!; + + expect(codex.detect('global').alreadyConfigured).toBe(false); + codex.install('global', { autoAllow: false }); + + const detected = codex.detect('global'); + expect(detected.alreadyConfigured).toBe(true); + expect(path.resolve(detected.configPath!)).toBe(path.resolve(path.join(custom, 'config.toml'))); + + const removed = codex.uninstall('global'); + expect(path.resolve(removed.files.find((f) => f.path.endsWith('config.toml'))!.path)) + .toBe(path.resolve(path.join(custom, 'config.toml'))); + // Our table was the only content, so the file goes with it. + expect(fs.existsSync(path.join(custom, 'config.toml'))).toBe(false); + }); + + it('falls back to ~/.codex when CODEX_HOME is unset or blank', () => { + const codex = getTarget('codex')!; + codex.install('global', { autoAllow: false }); + expect(fs.existsSync(path.join(defaultDir(), 'config.toml'))).toBe(true); + + fs.rmSync(defaultDir(), { recursive: true, force: true }); + process.env.CODEX_HOME = ' '; // set-but-empty must not become the config dir + codex.install('global', { autoAllow: false }); + expect(fs.existsSync(path.join(defaultDir(), 'config.toml'))).toBe(true); + }); + + it('leaves the local install alone — CODEX_HOME is the user layer only (#1531)', () => { + const custom = path.join(tmpHome, 'codex-profile'); + process.env.CODEX_HOME = custom; + + const codex = getTarget('codex')!; + const result = codex.install('local', { autoAllow: false }); + + const paths = result.files.map((f) => f.path.replace(/\\/g, '/')); + expect(paths.some((p) => p.endsWith('/.codex/config.toml'))).toBe(true); + expect(fs.existsSync(path.join(process.cwd(), '.codex', 'config.toml'))).toBe(true); + // The project layer lives beside the project, never under the user profile. + expect(fs.existsSync(path.join(custom, 'config.toml'))).toBe(false); + }); +}); diff --git a/src/installer/targets/claude.ts b/src/installer/targets/claude.ts index 1394791d7..3c789849d 100644 --- a/src/installer/targets/claude.ts +++ b/src/installer/targets/claude.ts @@ -10,6 +10,9 @@ * - Instructions to `~/.claude/CLAUDE.md` (global) or * `./.claude/CLAUDE.md` (local). * + * A non-blank `CLAUDE_CONFIG_DIR` moves all three global files into + * that profile directory, including `.claude.json` (#1627). + * * Earlier versions wrote the local MCP entry to `./.claude.json` — a * file Claude Code never reads — so the server silently never loaded * until the user manually renamed it to `.mcp.json` (issue #207). We @@ -53,18 +56,31 @@ function getClaudeMcpServerConfig() { return { ...getMcpServerConfig(), alwaysLoad: true }; } +/** + * Root of the global Claude Code profile. Settings and instructions follow + * CLAUDE_CONFIG_DIR; local installs stay anchored to the project (#1627). + */ +function globalConfigDir(): string { + const override = process.env.CLAUDE_CONFIG_DIR; + return override && override.trim().length > 0 + ? path.resolve(override) + : path.join(os.homedir(), '.claude'); +} function configDir(loc: Location): string { return loc === 'global' - ? path.join(os.homedir(), '.claude') + ? globalConfigDir() : path.join(process.cwd(), '.claude'); } function mcpJsonPath(loc: Location): string { - // global → ~/.claude.json (user scope: visible in every project). + // global → $CLAUDE_CONFIG_DIR/.claude.json for a custom profile, else + // ~/.claude.json (beside ~/.claude, not inside it). User scope: every project. // local → ./.mcp.json (project scope: the ONLY project-level MCP // file Claude Code reads — NOT ./.claude.json, which it ignores). - return loc === 'global' - ? path.join(os.homedir(), '.claude.json') - : path.join(process.cwd(), '.mcp.json'); + if (loc !== 'global') return path.join(process.cwd(), '.mcp.json'); + const override = process.env.CLAUDE_CONFIG_DIR; + return override && override.trim().length > 0 + ? path.join(path.resolve(override), '.claude.json') + : path.join(os.homedir(), '.claude.json'); } /** * Where pre-#207 installers wrote the local MCP entry. Claude Code diff --git a/src/installer/targets/codex.ts b/src/installer/targets/codex.ts index d5e361ce3..797acff1e 100644 --- a/src/installer/targets/codex.ts +++ b/src/installer/targets/codex.ts @@ -7,7 +7,8 @@ * - Instructions to `AGENTS.md`. * * Both locations are supported (#1531): - * - global: `~/.codex/config.toml` + `~/.codex/AGENTS.md` + * - global: `$CODEX_HOME/config.toml` + `$CODEX_HOME/AGENTS.md`, + * falling back to `~/.codex` when the env var is unset (#1627) * - local: `/.codex/config.toml` + `/AGENTS.md` * * Codex has a first-class project config layer: `.codex/config.toml` @@ -53,9 +54,16 @@ import { buildTomlTable, removeTomlTable, upsertTomlTable } from './toml'; const TOML_HEADER = 'mcp_servers.codegraph'; function configDir(loc: Location): string { - return loc === 'global' - ? path.join(os.homedir(), '.codex') - : path.join(process.cwd(), '.codex'); + if (loc !== 'global') return path.join(process.cwd(), '.codex'); + // Codex resolves its user layer from `CODEX_HOME` and only falls back to + // `~/.codex` (#1627). Installing to the fallback while Codex reads the + // override is a silent no-op: the files are written, and Codex never looks + // at them. Same resolution the copilot-cli target already does for + // `COPILOT_HOME`. Only the user layer moves — the project layer below is + // anchored to the project, not the profile. + const override = process.env.CODEX_HOME; + if (override && override.trim().length > 0) return override; + return path.join(os.homedir(), '.codex'); } function tomlConfigPath(loc: Location): string { return path.join(configDir(loc), 'config.toml'); From 7be699cd917dd5ff13ab7c274a7b28a01293b96f Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 8 Sep 2026 12:55:13 -0500 Subject: [PATCH 2/5] fix(resolution): resolve Python aliased module imports (#1626) (#1785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(resolution): resolve Python module members through an aliased from-import (#1626) resolvePythonModuleMember rebuilt the submodule's dotted path by joining the import source with the LOCAL name. Under 'from pkg import mod as alias' that produces 'pkg.alias' — a module that does not exist — so the file lookup found nothing and the call fell through to unresolved_refs with status='failed'. codegraph_callers then reported the target as having fewer callers than it does, which is the same wrong 'is this dead code?' answer #578 produced for the unaliased form. Join with the exported name instead. For an unaliased import the two names are identical, so nothing changes there; '*' (the namespace form) keeps using the local name, which is what it already bound to. Scope note: the issue also reports 'import top as alias' failing. That form is a namespace import and binds at source, so it resolves on current main — a probe against the reverted resolver confirms it already produces its call edge. The regression test pins both halves so the working one cannot silently break. Co-Authored-By: Claude (cherry picked from commit f7a8940e679b5d4093dd2d00412306a6b4800723) * fix(resolution): restore aliased Python module import edges (#1626) Use the exported module name in the file-import resolver, matching the member resolver from upstream PR #1635. Keep both aliased call assertions and verify the file-to-file imports edge in the #1626 regression test. Update the Unreleased note to cover file dependencies. Validation on Node 22.19.0: npm run build; supplied cg1626 repro; vitest run __tests__/resolution.test.ts -t 1626. Pass evidence saved in /workspace/cg1626-PASS.json and /workspace/cg1626-VERIFY.json. --------- Co-authored-by: Max Hsu Co-authored-by: Claude Co-authored-by: Colby McHenry --- CHANGELOG.md | 2 ++ __tests__/resolution.test.ts | 59 +++++++++++++++++++++++++++++++ src/resolution/import-resolver.ts | 19 +++++++--- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b90cdd753..5529bcfe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -251,6 +251,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Fixed a long-running `codegraph ui` session serving a symbol that a sync had already deleted. The viewer keeps one connection to your index open, and its in-memory lookup didn't notice when another process — your agent's sync, or `codegraph sync` — rewrote the file underneath it, so a symbol screen could keep showing a body with no callers while search correctly reported it had moved. Because a symbol's identity includes the line it starts on, this happened after almost any edit above it. +- Python calls and file dependencies through `from package import module as alias` now appear in the graph, so renamed imports no longer hide live callers or imported modules. Thanks @JoeyNPP. (#1626) + ## [1.6.0] - 2026-08-26 ### Highlights diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 5142edcf6..7c697a315 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1558,6 +1558,65 @@ def add_outcome(row): expect(buildMapCalls.map((e) => e.target)).not.toContain(ledgerAppend!.id); }); + it('resolves Python module-attribute calls and file imports through an alias (#1626)', async () => { + // #715 taught resolvePythonModuleMember to fall back to a dotted-module + // file lookup, which fixed `from pkg import module` (#578). The aliased + // form still missed: the module path was rebuilt from the LOCAL name, so + // `from pkg import module as alias` looked for `pkg.alias` — a file that + // does not exist — and the call landed in unresolved_refs. The plain + // `import top as alias` form is a namespace import and binds at `source`, + // so it was already correct; it is pinned here so the fix can't regress it. + fs.mkdirSync(path.join(tempDir, 'pkg')); + fs.writeFileSync(path.join(tempDir, 'pkg', '__init__.py'), ''); + fs.writeFileSync( + path.join(tempDir, 'pkg', 'module.py'), + 'def func():\n return 1\n' + ); + fs.writeFileSync( + path.join(tempDir, 'top_level.py'), + 'def top_func():\n return 2\n' + ); + fs.writeFileSync( + path.join(tempDir, 'main.py'), + `from pkg import module as mod_alias +import top_level as tl + + +def from_import_caller(): + return mod_alias.func() + + +def plain_import_caller(): + return tl.top_func() +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const fromImportCaller = cg.getNodesByKind('function').filter((n) => n.name === 'from_import_caller')[0]; + expect(fromImportCaller).toBeDefined(); + const aliasCalls = cg.getOutgoingEdges(fromImportCaller!.id).filter((e) => e.kind === 'calls'); + expect(aliasCalls).toHaveLength(1); + const aliasTarget = cg.getNode(aliasCalls[0]!.target); + expect(aliasTarget?.name).toBe('func'); + expect(aliasTarget?.filePath.replace(/\\/g, '/')).toBe('pkg/module.py'); + + const plainCaller = cg.getNodesByKind('function').filter((n) => n.name === 'plain_import_caller')[0]; + expect(plainCaller).toBeDefined(); + const plainCalls = cg.getOutgoingEdges(plainCaller!.id).filter((e) => e.kind === 'calls'); + expect(plainCalls).toHaveLength(1); + expect(cg.getNode(plainCalls[0]!.target)?.name).toBe('top_func'); + + // The file dependency must resolve too: fixing only the member lookup + // restores calls but leaves the aliased module's imports edge missing. + const mainFile = cg.getNodesByKind('file').find((n) => n.filePath === 'main.py'); + const moduleFile = cg.getNodesByKind('file').find((n) => n.filePath.replace(/\\/g, '/') === 'pkg/module.py'); + expect(mainFile).toBeDefined(); + expect(moduleFile).toBeDefined(); + const fileImports = cg.getOutgoingEdges(mainFile!.id).filter((e) => e.kind === 'imports'); + expect(fileImports.map((e) => e.target)).toContain(moduleFile!.id); + }); + it('attaches Go methods to their receiver type across files (#583, cross-file half)', async () => { // In Go a type's methods are commonly declared in a different file from the // `type` declaration (`type Box` in box.go, `func (b *Box) Get()` in diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index aa173a2c1..3d9eb3ef3 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -1692,11 +1692,19 @@ function resolvePythonModuleMember( // `import mod` / `import numpy as np` bind the module at `source` itself; // `from . import certs` / `from pkg import mod` bind a SUBMODULE whose // dotted path is the source joined with the imported name. + // + // Join with the EXPORTED name, not the local one: under + // `from pkg import mod as alias` the receiver is `alias` but the module on + // disk is `pkg.mod`, and building `pkg.alias` looked for a file that does + // not exist — so the aliased form dropped its `calls` edge while the plain + // form (where the two names coincide) worked (#1626). For an unaliased + // import the two are identical, so this changes nothing there. + const moduleName = imp.exportedName === '*' ? imp.localName : imp.exportedName; const modulePath = imp.isNamespace ? imp.source : imp.source.endsWith('.') - ? imp.source + imp.localName - : imp.source + '.' + imp.localName; + ? imp.source + moduleName + : imp.source + '.' + moduleName; // resolveImportPath only maps RELATIVE dotted paths (`.mod`, `..pkg.mod`); an // ABSOLUTE package path (`pkg.module` from `from pkg import module`, or a bare @@ -1884,9 +1892,12 @@ function resolveModuleImportToFile( modulePath = imp.source; } else if (ref.language === 'python') { // `from . import certs` — the imported NAME is a submodule of the source. + // As in resolvePythonModuleMember, use the exported name so an alias + // still links to the real module file (#1626). + const moduleName = imp.exportedName === '*' ? imp.localName : imp.exportedName; modulePath = imp.source.endsWith('.') - ? imp.source + imp.localName - : imp.source + '.' + imp.localName; + ? imp.source + moduleName + : imp.source + '.' + moduleName; } else { // A named TS/JS import binds a symbol, not a module — leave it alone. continue; From b8d46d13c77008b4feb67ac6f47eca8e9a8e4a6b Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 8 Sep 2026 13:13:03 -0500 Subject: [PATCH 3/5] fix(mcp): land #1624 opt-in explore dedup (#1788) Land the #1624 approach by @danusha2345: re-serve source by default so subagents and compacted contexts never inherit stale already-sent pointers. Keep cross-call dedup available through explicit truthy CODEGRAPH_EXPLORE_DEDUP values for durable contexts. Preserve current Unreleased entries, credit the contribution, and align the MCP server guidance with the safe default. Validation on Linux / Node 22.23.2: - Reproduced default-on failure before the fix; default-off now passes. - Focused explore-cross-call-dedup suite: 26 passed. - TypeScript: npx tsc -p tsconfig.json --noEmit passed. Fixes #1620. (cherry picked from commit 63992facabbbcef2797167dcdd90695d3802b533) Co-authored-by: danusha2345 --- CHANGELOG.md | 2 + __tests__/explore-cross-call-dedup.test.ts | 44 ++++++++++++++++++++++ src/mcp/explore-dedup.ts | 13 ++++--- src/mcp/server-instructions.ts | 2 +- 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5529bcfe1..80cb94a25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### MCP / indexing +- `codegraph_explore` now re-serves source to fresh subagents and after context compaction, with cross-call dedup available only through an explicit `CODEGRAPH_EXPLORE_DEDUP=1` opt-in; thanks @danusha2345. (#1620, #1624) + - **Watcher scope now matches `git ls-files --exclude-standard` (#1728).** `buildDefaultIgnore` / `buildScopeIgnore` read `.git/info/exclude` and `core.excludesFile` (not only the root `.gitignore`), and seed directories git reports as ignored-untracked so nested `.gitignore` effects prune the live watcher the same way the indexer skips them. Single-file auto-sync was already incremental (`pendingFiles` → scoped `sync({ paths })`); the remaining gap was watching trees git had excluded. - **Live sync no longer lets the write-ahead log grow without a bound when a reader is holding it open (#1539).** Incremental sync now uses the same writer pause that full indexing already used, and if checkpointing still cannot finish once the log is past its documented size limit — typically because the query pool is reading at the same time — sync stops with a clear error instead of keeping writing until the disk fills. The previous behaviour could leave a multi-tens-of-gigabyte log beside a few-gigabyte index on a large project. Close concurrent readers and retry, or raise `CODEGRAPH_WAL_VALVE_MB` if the limit is too tight for the project. diff --git a/__tests__/explore-cross-call-dedup.test.ts b/__tests__/explore-cross-call-dedup.test.ts index 608d85ce8..ce208dc95 100644 --- a/__tests__/explore-cross-call-dedup.test.ts +++ b/__tests__/explore-cross-call-dedup.test.ts @@ -28,6 +28,7 @@ import { ExploreSessionState, type ExploreProjectState } from '../src/mcp/explor import { EXPLORE_DEDUP, dedupeRange, + exploreDedupEnabled, fileFingerprint, formatBackReference, intersectRange, @@ -41,6 +42,27 @@ const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'payroll-go'); const QUERY = 'how does payroll cycle create and calculate payslips?'; const POINTER = 'Already sent earlier in this conversation'; +describe('dedup configuration', () => { + it('defaults off and requires an explicit truthy opt-in', () => { + const previous = process.env.CODEGRAPH_EXPLORE_DEDUP; + try { + delete process.env.CODEGRAPH_EXPLORE_DEDUP; + expect(exploreDedupEnabled()).toBe(false); + for (const enabled of ['1', 'true', 'on', 'yes', ' YES ']) { + process.env.CODEGRAPH_EXPLORE_DEDUP = enabled; + expect(exploreDedupEnabled()).toBe(true); + } + for (const disabled of ['0', 'false', 'off', 'no', 'unexpected']) { + process.env.CODEGRAPH_EXPLORE_DEDUP = disabled; + expect(exploreDedupEnabled()).toBe(false); + } + } finally { + if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEDUP; + else process.env.CODEGRAPH_EXPLORE_DEDUP = previous; + } + }); +}); + /** A prior-state shaped like the session tracker's, for the algebra tests. */ function prior(files: Array<{ path: string; ranges: Array<[number, number]>; fingerprint?: string }>): ExploreProjectState { return { @@ -183,8 +205,11 @@ describe('a second call against a real index', () => { let testDir: string; let cg: CodeGraph; let handler: ToolHandler; + let previousDedup: string | undefined; beforeAll(async () => { + previousDedup = process.env.CODEGRAPH_EXPLORE_DEDUP; + process.env.CODEGRAPH_EXPLORE_DEDUP = '1'; testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg18-')); fs.cpSync(FIXTURE_SRC, testDir, { recursive: true }); fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true }); @@ -194,6 +219,8 @@ describe('a second call against a real index', () => { }, 120_000); afterAll(() => { + if (previousDedup === undefined) delete process.env.CODEGRAPH_EXPLORE_DEDUP; + else process.env.CODEGRAPH_EXPLORE_DEDUP = previousDedup; if (cg) cg.destroy(); if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true }); }); @@ -328,6 +355,23 @@ describe('a second call against a real index', () => { } }, 120_000); + it('re-serves source by default when a connection may outlive the current context', async () => { + const session = new ExploreSessionState(); + const previous = process.env.CODEGRAPH_EXPLORE_DEDUP; + delete process.env.CODEGRAPH_EXPLORE_DEDUP; + try { + const first = await explore(QUERY, session); + const second = await explore(QUERY, session); + expect(second).toBe(first); + expect(second).not.toContain(POINTER); + expect([...fencedLines(second).values()].reduce((sum, lines) => sum + lines.size, 0)) + .toBeGreaterThan(20); + } finally { + if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEDUP; + else process.env.CODEGRAPH_EXPLORE_DEDUP = previous; + } + }, 120_000); + it('reports the reclaimed bytes through the CG-4 diagnostic', async () => { const sidecar = path.join(testDir, 'cg18-diagnostic.jsonl'); const session = new ExploreSessionState(); diff --git a/src/mcp/explore-dedup.ts b/src/mcp/explore-dedup.ts index 541945c7d..f39f2211b 100644 --- a/src/mcp/explore-dedup.ts +++ b/src/mcp/explore-dedup.ts @@ -68,16 +68,19 @@ export const EXPLORE_DEDUP = { MAX_SYMBOLS_IN_POINTER: 5, } as const; -const OFF = new Set(['0', 'false', 'off', 'no']); +const ON = new Set(['1', 'true', 'on', 'yes']); /** - * Kill switch: `CODEGRAPH_EXPLORE_DEDUP=0` renders every call as if the session - * had no history. Read per call (not memoized) so a test can toggle it. + * Cross-call source suppression is opt-in. An MCP connection is not a reliable + * conversation boundary: some hosts reuse it for subagents, and compaction can + * discard source while keeping the connection alive (#1620). Without a host- + * supplied context lifecycle, re-serving source is the only always-correct + * default. Read per call (not memoized) so tests and launchers can toggle it. */ export function exploreDedupEnabled(): boolean { const raw = process.env.CODEGRAPH_EXPLORE_DEDUP; - if (raw === undefined) return true; - return !OFF.has(raw.trim().toLowerCase()); + if (raw === undefined) return false; + return ON.has(raw.trim().toLowerCase()); } /** diff --git a/src/mcp/server-instructions.ts b/src/mcp/server-instructions.ts index c5a1b688b..a1762860c 100644 --- a/src/mcp/server-instructions.ts +++ b/src/mcp/server-instructions.ts @@ -64,7 +64,7 @@ calls; a grep/read exploration is dozens. - **After editing, check the staleness banner.** When a tool response starts with "⚠️ Some files referenced below were edited since the last index sync…", the listed files are pending re-index — Read those specific files for accurate content. Every file NOT in that banner is fresh, so still trust codegraph. A different, rarer banner — "⚠️ CodeGraph auto-sync is DISABLED…" — means live watching stopped entirely (the whole index is frozen, not just a few files); until it's resolved, Read files directly to confirm anything that may have changed. - **A file flagged "⚠ changed on disk after the last index sync" drifted from its index** (most common on projects queried via \`projectPath\`, which have no live watcher). Codegraph never serves a possibly-mis-sliced body from such a file — it either shows the file's full CURRENT source (trust it as a Read) or omits the source with this flag. When the source was omitted, Read that specific file; line numbers referencing it elsewhere in the response may be shifted until that project's next sync. All unflagged files remain trustworthy. -- **"Already sent earlier in this conversation" is a pointer, not a gap.** When a file's section carries that line instead of (or above) its source, an earlier \`codegraph_explore\` in THIS conversation already returned those exact lines and the file has not changed since — so the copy already in your context is current and exact. Scroll back to it; don't re-fetch it and don't Read the file. The bytes it freed went into source you have not seen yet, elsewhere in the same response. +- **Source is re-served on every call by default**, including for fresh subagents and after context compaction. Cross-call dedup requires \`CODEGRAPH_EXPLORE_DEDUP=1\` and is only suitable for hosts that guarantee one durable context per connection. With that opt-in, **"Already sent earlier in this conversation"** points to exact, unchanged source returned by an earlier \`codegraph_explore\` in that context. Use that copy; don't re-fetch it and don't Read the file. The bytes it freed went into source you have not seen yet, elsewhere in the same response. ## Limitations From 72c1ff13cc5f49f456f7a1ce4295c9d45c9bf663 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 8 Sep 2026 13:45:22 -0500 Subject: [PATCH 4/5] fix(resolution): restore orphan-sweep parity after interruption (#1577) (#1789) Co-authored-by: Colby McHenry --- CHANGELOG.md | 2 + __tests__/orphaned-refs-sweep.test.ts | 95 ++++++++++++++++ src/db/queries.ts | 20 +++- src/resolution/index.ts | 152 +++++++++++++++++++------- 4 files changed, 223 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80cb94a25..1758fc138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### MCP / indexing +- Sync now recovers the same connections as a clean index after interrupted reference resolution, including inherited calls and callbacks that previously stayed missing. (#1577) + - `codegraph_explore` now re-serves source to fresh subagents and after context compaction, with cross-call dedup available only through an explicit `CODEGRAPH_EXPLORE_DEDUP=1` opt-in; thanks @danusha2345. (#1620, #1624) - **Watcher scope now matches `git ls-files --exclude-standard` (#1728).** `buildDefaultIgnore` / `buildScopeIgnore` read `.git/info/exclude` and `core.excludesFile` (not only the root `.gitignore`), and seed directories git reports as ignored-untracked so nested `.gitignore` effects prune the live watcher the same way the indexer skips them. Single-file auto-sync was already incremental (`pendingFiles` → scoped `sync({ paths })`); the remaining gap was watching trees git had excluded. diff --git a/__tests__/orphaned-refs-sweep.test.ts b/__tests__/orphaned-refs-sweep.test.ts index 7864ab9d3..e1ee21ee8 100644 --- a/__tests__/orphaned-refs-sweep.test.ts +++ b/__tests__/orphaned-refs-sweep.test.ts @@ -18,6 +18,8 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import CodeGraph from '../src/index'; +import { createDatabase } from '../src/db/sqlite-adapter'; +import type { ReferenceResolver } from '../src/resolution'; describe('Orphaned refs sweep (#1187)', () => { let testDir: string; @@ -62,6 +64,99 @@ describe('Orphaned refs sweep (#1187)', () => { return hit!.node; } + // Compare call sites and resolution evidence, not just edge counts: a + // recovery can also silently downgrade confidence without losing a row. + function graphSnapshot() { + const { db } = createDatabase(path.join(testDir, '.codegraph', 'codegraph.db'), { readOnly: true }); + try { + const sorted = (sql: string) => db.prepare(sql).all().map((row) => JSON.stringify(row)).sort(); + return { + nodes: sorted('SELECT id, kind, name, qualified_name, file_path FROM nodes'), + edges: sorted('SELECT source, target, kind, line, col, metadata, provenance FROM edges'), + refs: sorted('SELECT from_node_id, reference_name, reference_kind, line, col, file_path, language, status FROM unresolved_refs'), + }; + } finally { + db.close(); + } + } + + describe('recovery has clean-index resolution parity (#1577)', () => { + it('persists prerequisites before calls even when the orphan order is reversed', async () => { + fs.writeFileSync(path.join(testDir, 'aTypes.java'), [ + 'class Base { void draw() {} }', + 'class Child extends Base {}', + 'class Decoy { void draw() {} }', + ].join('\n')); + // Put the caller beyond the first clean-index batch. Recovery below + // queues that same caller FIRST and its inheritance prerequisite LAST. + fs.writeFileSync(path.join(testDir, 'bPadding.java'), + 'class Padding { void noop() {\n' + 'externalCall();\n'.repeat(5100) + '} }\n'); + fs.writeFileSync(path.join(testDir, 'zCaller.java'), + 'class Caller { void run(Child child) { child.draw(); } }\n'); + + cg = CodeGraph.initSync(testDir); + await cg.indexAll(); + const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Base::draw')!; + expect(callerFiles(target)).toEqual(['zCaller.java']); + const clean = graphSnapshot(); + + for (const file of ['zCaller.java', 'bPadding.java', 'aTypes.java']) { + await interruptAfterExtraction(file); + } + cg.destroy(); + cg = CodeGraph.openSync(testDir); + expect(cg.getPendingReferenceCount()).toBeGreaterThan(5000); + + const recovered = await cg.sync(); + expect(recovered.filesAdded + recovered.filesModified + recovered.filesRemoved).toBe(0); + expect(cg.getPendingReferenceCount()).toBe(0); + expect(callerFiles(target)).toEqual(['zCaller.java']); + expect(graphSnapshot()).toEqual(clean); + + await cg.sync(); + expect(graphSnapshot()).toEqual(clean); + }, 15000); + + it('recovers inherited callbacks when the process restarts before the deferred pass', async () => { + fs.writeFileSync(path.join(testDir, 'form.ts'), [ + 'class Base { handleSubmit() {} }', + 'class Unrelated { missingHandler() {} }', + 'class Form extends Base {', + ' wire() { bus.on("submit", this.handleSubmit); }', + ' save() { bus.on("save", this.handleSubmit); }', + ' confirm() { bus.on("confirm", this.handleSubmit); }', + ' missing() { bus.on("missing", this.missingHandler); }', + '}', + ].join('\n')); + cg = CodeGraph.initSync(testDir); + await cg.indexAll(); + const target = findMethod('handleSubmit'); + expect(cg.getIncomingEdges(target.id).filter((e) => e.kind === 'references')).toHaveLength(3); + const clean = graphSnapshot(); + + await interruptAfterExtraction('form.ts'); + // Stop after the final batch has persisted, before the deferred + // inherited-member pass runs. There are no later batches to hide the + // bug: failed rows plus a lost in-memory queue used to look healthy. + // One ref per batch also exercises consecutive all-deferred batches: + // their intentionally pending rows must not trip the non-progress guard. + const resolver = (cg as unknown as { resolver: ReferenceResolver }).resolver; + await expect(resolver.resolveAndPersistBatched((current, total) => { + if (current === total) throw new Error('interrupted before deferred resolution'); + }, 1)).rejects.toThrow('interrupted before deferred resolution'); + cg.destroy(); + cg = CodeGraph.openSync(testDir); + + await cg.sync(); + expect(cg.getIncomingEdges(target.id).filter((e) => e.kind === 'references')).toHaveLength(3); + expect(cg.getIncomingEdges(findMethod('missingHandler').id).filter((e) => e.kind === 'references')).toEqual([]); + expect(cg.getPendingReferenceCount()).toBe(0); + expect(graphSnapshot()).toEqual(clean); + await cg.sync(); + expect(graphSnapshot()).toEqual(clean); + }); + }); + describe('sync() heals an interrupted resolution run', () => { beforeEach(async () => { // The #1187 shape: a concrete @Component class called through Spring diff --git a/src/db/queries.ts b/src/db/queries.ts index 2ec444428..c668d2538 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -280,6 +280,8 @@ export class QueryBuilder { getUnresolvedCount?: SqliteStatement; getUnresolvedBatch?: SqliteStatement; getUnresolvedBatchAfter?: SqliteStatement; + getUnresolvedPrerequisitesAfter?: SqliteStatement; + getUnresolvedDependentsAfter?: SqliteStatement; deleteRefsByRowIdsFull?: SqliteStatement; getAllFilePaths?: SqliteStatement; getAllNodeNames?: SqliteStatement; @@ -3115,13 +3117,21 @@ export class QueryBuilder { * (§7a.2) — while the seek is O(batch) forever. `id` is the rowid alias, so * the enumeration order is identical to the OFFSET reader's. */ - getUnresolvedReferencesBatchAfter(afterRowId: number, limit: number): UnresolvedReference[] { - if (!this.stmts.getUnresolvedBatchAfter) { - this.stmts.getUnresolvedBatchAfter = this.db.prepare( - "SELECT * FROM unresolved_refs WHERE status = 'pending' AND id > ? ORDER BY id LIMIT ?" + getUnresolvedReferencesBatchAfter(afterRowId: number, limit: number, prerequisites?: boolean): UnresolvedReference[] { + // Resolution prerequisites must be committed before dependent calls, + // even when an interrupted sync queued their rows in a different order + // from a clean index (#1577). Each phase still seeks by row id in bounded + // memory; the default preserves the public reader's original enumeration. + const key = prerequisites === undefined ? 'getUnresolvedBatchAfter' + : prerequisites ? 'getUnresolvedPrerequisitesAfter' : 'getUnresolvedDependentsAfter'; + if (!this.stmts[key]) { + const filter = prerequisites === undefined ? '' + : ` AND reference_kind ${prerequisites ? 'IN' : 'NOT IN'} ('imports', 'extends', 'implements')`; + this.stmts[key] = this.db.prepare( + `SELECT * FROM unresolved_refs WHERE status = 'pending' AND id > ?${filter} ORDER BY id LIMIT ?` ); } - const rows = this.stmts.getUnresolvedBatchAfter.all(afterRowId, limit) as UnresolvedRefRow[]; + const rows = this.stmts[key]!.all(afterRowId, limit) as UnresolvedRefRow[]; return rows.map((row) => ({ fromNodeId: row.from_node_id, referenceName: row.reference_name, diff --git a/src/resolution/index.ts b/src/resolution/index.ts index f233b7fe1..2502ccbe8 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -208,8 +208,9 @@ export class ReferenceResolver { private context: ResolutionContext; private frameworks: FrameworkResolver[] = []; // Chained static-factory/fluent call refs the first pass couldn't resolve, - // collected in-memory (the batched resolver deletes unresolved refs from the - // DB, so they can't be re-read). Drained by resolveChainedCallsViaConformance + // collected in-memory and left pending in the DB until the post-pass + // finishes, so a restart can recover the queue (#1577). Drained by + // resolveChainedCallsViaConformance // once implements/extends edges exist, to resolve methods on a supertype the // receiver conforms to (#750). private deferredChainRefs: UnresolvedRef[] = []; @@ -218,6 +219,7 @@ export class ReferenceResolver { // same reason as deferredChainRefs and drained by // resolveDeferredThisMemberRefs once implements/extends edges exist (#808). private deferredThisMemberRefs: UnresolvedRef[] = []; + private deferredRowIds = new Set(); // Per-`.razor`/`.cshtml`-file `@using` namespace set (own directives + folder // `_Imports.razor`, cascading to the project root). Used to disambiguate a // markup type ref to the right C# namespace. @@ -711,6 +713,7 @@ export class ReferenceResolver { ): ResolutionResult { // Pre-load all nodes into memory for fast lookups this.warmCaches(); + this.advanceSupertypeGeneration(); const resolved: ResolvedRef[] = []; const unresolved: UnresolvedRef[] = []; @@ -1067,7 +1070,7 @@ export class ReferenceResolver { CHAIN_LANGUAGES.has(ref.language) && CHAIN_SHAPE.test(ref.referenceName) ) { - this.deferredChainRefs.push(ref); + this.deferReference(ref, this.deferredChainRefs); } else if ( // PHP `$this->prop->method()` (encoded `this->prop.method`): its method // may live on the property's declared supertype, resolvable only once @@ -1076,7 +1079,7 @@ export class ReferenceResolver { ref.language === 'php' && PHP_PROP_SHAPE.test(ref.referenceName) ) { - this.deferredChainRefs.push(ref); + this.deferReference(ref, this.deferredChainRefs); } return null; } @@ -1216,6 +1219,16 @@ export class ReferenceResolver { return { byRowId, legacyKeys }; } + /** A deferred attempt is unfinished work, not a final failure (#1577). */ + private nonDeferredFailures(unresolved: UnresolvedRef[]): UnresolvedRef[] { + return unresolved.filter((ref) => ref.rowId == null || !this.deferredRowIds.has(ref.rowId)); + } + + private deferReference(ref: UnresolvedRef, queue: UnresolvedRef[]): void { + queue.push(ref); + if (ref.rowId != null) this.deferredRowIds.add(ref.rowId); + } + /** * Resolve and persist edges to database */ @@ -1223,6 +1236,15 @@ export class ReferenceResolver { unresolvedRefs: UnresolvedReference[], onProgress?: (current: number, total: number) => void ): ResolutionResult { + const prerequisites = unresolvedRefs.filter(ReferenceResolver.isPrerequisite); + if (prerequisites.length > 0 && prerequisites.length < unresolvedRefs.length) { + const first = this.resolveAndPersist(prerequisites, (current) => onProgress?.(current, unresolvedRefs.length)); + const rest = this.resolveAndPersist( + unresolvedRefs.filter((ref) => !ReferenceResolver.isPrerequisite(ref)), + (current) => onProgress?.(prerequisites.length + current, unresolvedRefs.length) + ); + return ReferenceResolver.mergeResults(first, rest); + } const result = this.resolveAll(unresolvedRefs, onProgress); // Create edges from resolved references @@ -1250,7 +1272,7 @@ export class ReferenceResolver { // is still 'pending', so any pending row at rest belongs to an // interrupted run and the sweep can key off the pending count. if (result.unresolved.length > 0) { - const { byRowId, legacyKeys } = ReferenceResolver.partitionFailedCleanup(result.unresolved); + const { byRowId, legacyKeys } = ReferenceResolver.partitionFailedCleanup(this.nonDeferredFailures(result.unresolved)); this.queries.markReferencesFailedByRowIds(byRowId); this.queries.markReferencesFailed(legacyKeys); } @@ -1268,9 +1290,19 @@ export class ReferenceResolver { * a large edit lands many popular symbol names at once. */ async resolveAndPersistListYielding(refs: UnresolvedReference[]): Promise { + const prerequisites = refs.filter(ReferenceResolver.isPrerequisite); + if (prerequisites.length > 0 && prerequisites.length < refs.length) { + const first = await this.resolveAndPersistListYielding(prerequisites); + const rest = await this.resolveAndPersistListYielding(refs.filter((ref) => !ReferenceResolver.isPrerequisite(ref))); + return ReferenceResolver.mergeResults(first, rest); + } const maybeYield = createYielder(); const result = await this.resolveBatchYielding(refs, maybeYield); + await this.persistResolutionResult(result, maybeYield); + return result; + } + private async persistResolutionResult(result: ResolutionResult, maybeYield: MaybeYield): Promise { const PERSIST_CHUNK = 1000; const edges = this.createEdges(result.resolved); for (let i = 0; i < edges.length; i += PERSIST_CHUNK) { @@ -1288,7 +1320,7 @@ export class ReferenceResolver { await maybeYield(); } - const failedCleanup = ReferenceResolver.partitionFailedCleanup(result.unresolved); + const failedCleanup = ReferenceResolver.partitionFailedCleanup(this.nonDeferredFailures(result.unresolved)); for (let i = 0; i < failedCleanup.byRowId.length; i += PERSIST_CHUNK) { this.queries.markReferencesFailedByRowIds(failedCleanup.byRowId.slice(i, i + PERSIST_CHUNK)); await maybeYield(); @@ -1298,7 +1330,43 @@ export class ReferenceResolver { await maybeYield(); } - return result; + return edges.length; + } + + /** Finalize the durable queue only AFTER its edges have been inserted. */ + private async persistDeferredReferences(deferred: UnresolvedRef[], resolved: ResolvedRef[]): Promise { + for (const ref of deferred) if (ref.rowId != null) this.deferredRowIds.delete(ref.rowId); + const matched = new Set(resolved.map((ref) => ref.original)); + const unresolved = deferred.filter((ref) => !matched.has(ref)); + const count = await this.persistResolutionResult({ + resolved, + unresolved, + stats: { total: deferred.length, resolved: resolved.length, unresolved: unresolved.length, byMethod: {} }, + }, createYielder()); + if (count > 0) this.clearCaches(); + return count; + } + + /** Same two phases as the bounded DB reader: persist wiring before calls. */ + private static isPrerequisite(ref: UnresolvedReference): boolean { + return ref.referenceKind === 'imports' || ref.referenceKind === 'extends' || ref.referenceKind === 'implements'; + } + + private static mergeResults(first: ResolutionResult, rest: ResolutionResult): ResolutionResult { + const byMethod = { ...first.stats.byMethod }; + for (const [method, count] of Object.entries(rest.stats.byMethod)) { + byMethod[method] = (byMethod[method] ?? 0) + count; + } + return { + resolved: first.resolved.concat(rest.resolved), + unresolved: first.unresolved.concat(rest.unresolved), + stats: { + total: first.stats.total + rest.stats.total, + resolved: first.stats.resolved + rest.stats.resolved, + unresolved: first.stats.unresolved + rest.stats.unresolved, + byMethod, + }, + }; } /** @@ -1342,14 +1410,7 @@ export class ReferenceResolver { if (match) resolved.push(match); await maybeYield(); } - if (resolved.length === 0) return 0; - - const edges = this.createEdges(resolved); - if (edges.length > 0) { - this.queries.insertEdges(edges); - this.clearCaches(); - } - return edges.length; + return this.persistDeferredReferences(deferred, resolved); } /** @@ -1515,6 +1576,7 @@ export class ReferenceResolver { unresolved.push(ref); } } + this.deferredRowIds.clear(); // the admission side now owns both queues return { resolved, unresolved, @@ -1539,8 +1601,8 @@ export class ReferenceResolver { * would have. */ appendDeferredFromWorkers(deferredChain: UnresolvedRef[], deferredThisMember: UnresolvedRef[]): void { - this.deferredChainRefs.push(...deferredChain); - this.deferredThisMemberRefs.push(...deferredThisMember); + for (const ref of deferredChain) this.deferReference(ref, this.deferredChainRefs); + for (const ref of deferredThisMember) this.deferReference(ref, this.deferredThisMemberRefs); } /** @@ -1748,8 +1810,25 @@ export class ReferenceResolver { try { try { + // Orphans retain interruption/re-extraction order, not clean-index order. + // A caller can precede its imports or supertypes by many batches (#1577). + // Drain those prerequisites first, then start a fresh keyset cursor over + // the remaining kinds. The disjoint filters let us prefetch across the + // phase boundary before cleanup without re-reading the current batch. + let prerequisites = true; + let afterRowId = 0; + const readNextBatch = (): UnresolvedReference[] => { + let next = this.queries.getUnresolvedReferencesBatchAfter(afterRowId, batchSize, prerequisites); + if (next.length === 0 && prerequisites) { + prerequisites = false; + afterRowId = 0; + next = this.queries.getUnresolvedReferencesBatchAfter(afterRowId, batchSize, prerequisites); + } + if (next.length > 0) afterRowId = next[next.length - 1]!.rowId!; + return next; + }; tLp = Date.now(); - let batch = this.queries.getUnresolvedReferencesBatchAfter(0, batchSize); + let batch = readNextBatch(); lp('read', tLp); let inFlight: InFlight | null = batch.length > 0 ? beginBatch(batch) : null; while (batch.length > 0 && inFlight) { @@ -1759,7 +1838,7 @@ export class ReferenceResolver { // enumeration yields the following batch (keyset — OFFSET re-walked the // accumulated failed prefix every read, 54.6s at kernel scale, §7a.2). tLp = Date.now(); - const nextBatch = this.queries.getUnresolvedReferencesBatchAfter(batch[batch.length - 1]!.rowId!, batchSize); + const nextBatch = readNextBatch(); lp('read', tLp); const tBatch = Date.now(); @@ -1880,7 +1959,9 @@ export class ReferenceResolver { // only see pending rows) but stay retryable when a later sync adds a // symbol that could satisfy them (#1240). tLp = Date.now(); - const failedCleanup = ReferenceResolver.partitionFailedCleanup(result.unresolved); + const failures = this.nonDeferredFailures(result.unresolved); + const deferredCount = result.unresolved.length - failures.length; + const failedCleanup = ReferenceResolver.partitionFailedCleanup(failures); for (let i = 0; i < failedCleanup.byRowId.length; i += PERSIST_CHUNK) { removedThisBatch += this.queries.markReferencesFailedByRowIds(failedCleanup.byRowId.slice(i, i + PERSIST_CHUNK)); await maybeYield(); @@ -1915,17 +1996,11 @@ export class ReferenceResolver { // batch one and left the rest of the table as permanent orphans (#1187). // The count-based guard below catches the true no-progress case. - // Non-progress guard (defense-in-depth). Each iteration enumerates from - // the head of the pending set, so the PENDING population MUST shrink - // every iteration — resolved refs are deleted and unresolvable ones are - // marked failed above, and both leave the pending set the batch reader - // sees. If it didn't shrink, a resolver returned a match whose - // `original.referenceName` differs from the stored row, so the keyed - // delete/update no-ops, and we'd re-read + re-resolve + re-insert the - // same rows forever (the runaway that grew a 99-file repo to 5M edges / - // 1.4 GB before the Go-fallback fix). Stop rather than grow the graph - // without bound. (An in-flight prefetched batch is abandoned unsettled — - // fan-out has no side effects until settleBatch appends its results.) + // Non-progress guard (defense-in-depth). Ordinary attempts must leave + // the pending set; a mismatched original reference can make legacy-key + // cleanup a no-op. Keep the guard against that broken persistence even + // though keyset pagination now advances independently of row cleanup. + // An abandoned prefetched batch has no side effects until settleBatch. // Non-progress signal, now O(1): `changes` summed across this batch's // deletes + failed-parks is the DIRECT evidence the guard's old count // diff inferred — a resolver returning a mismatched name makes the keyed @@ -1935,7 +2010,9 @@ export class ReferenceResolver { // runs only on the suspicious path (claimed-work batch removed nothing — // e.g. every row was a sibling a legacy-key sweep already consumed), // where it arbitrates stop-vs-continue exactly as before. - if (removedThisBatch <= 0 && batch.length > 0) { + // Deferred refs legitimately remain pending for the post-pass. The + // keyset cursor advances past them; they must not trigger this guard. + if (removedThisBatch + deferredCount <= 0 && batch.length > 0) { tLp = Date.now(); const remaining = this.queries.getUnresolvedReferencesCount(); lp('countGuard', tLp); @@ -2389,7 +2466,7 @@ export class ReferenceResolver { // Not on the class itself — possibly INHERITED. implements/extends // edges don't exist yet in this pass, so retry in the supertype pass // (resolveDeferredThisMemberRefs) instead of giving up. - this.deferredThisMemberRefs.push(ref); + this.deferReference(ref, this.deferredThisMemberRefs); return null; } const target = candidates.reduce((a, b) => (a.startLine <= b.startLine ? a : b)); @@ -2503,14 +2580,7 @@ export class ReferenceResolver { }); } } - if (resolved.length === 0) return 0; - - const edges = this.createEdges(resolved); - if (edges.length > 0) { - this.queries.insertEdges(edges); - this.clearCaches(); - } - return edges.length; + return this.persistDeferredReferences(deferred, resolved); } private gateLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null { From de5adba7ea6677501d2b2fe6342dea67987fd9e5 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 8 Sep 2026 14:04:43 -0500 Subject: [PATCH 5/5] fix(resolution): leave built-in Map calls unresolved (#1566) (#1790) Co-authored-by: Colby McHenry --- CHANGELOG.md | 2 + __tests__/kernel-tsjs-parity.test.ts | 26 +++++++- __tests__/resolution.test.ts | 81 +++++++++++++++++++++++++ __tests__/ts-chained-receiver.test.ts | 4 +- codegraph-kernel/src/tsjs/extractors.rs | 29 ++++----- src/extraction/tree-sitter.ts | 43 ++++--------- src/resolution/index.ts | 9 +-- src/resolution/js-builtins.ts | 8 +++ src/resolution/name-matcher.ts | 8 +++ 9 files changed, 151 insertions(+), 59 deletions(-) create mode 100644 src/resolution/js-builtins.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1758fc138..1fd25a1b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -219,6 +219,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### Symbols, tests and the viewer +- TypeScript and JavaScript collection calls through local variables and their nested properties no longer link to unrelated project methods; re-index after upgrading. (#1566) + - Objective-C headers now index in a project that has no `.m` file. A `.h` file is read as C from its name alone, and only later — once its contents are read — recognized as Objective-C; the grammar for that was never loaded up front, so the file failed with a parser error and nothing in it reached the index. Adding any `.m` file used to make the same header work, which is what made this look arbitrary. Thanks @Juddd. (#1628) - TypeScript interface methods and properties are now indexed, so `node`, `callers` and impact can find platform `.d.ts` APIs while declaration-only files keep their lower ranking on flow queries; re-index TypeScript projects after upgrading. (#1638) diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index c16d41ff0..3d4316a34 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -76,7 +76,7 @@ describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => { resetKernelForTests(); }); - function assertParity(filePath: string, source: string, language: Language): void { + function assertParity(filePath: string, source: string, language: Language): ExtractionResult { process.env.CODEGRAPH_KERNEL_LANGS = 'all'; delete process.env.CODEGRAPH_KERNEL; const viaKernel = tryKernelExtract(filePath, source, language); @@ -93,8 +93,32 @@ describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => { expect(k.refs, `${filePath}: refs`).toEqual(w.refs); // Meaningful comparison, not empty-vs-empty. expect(viaWasm.nodes.length).toBeGreaterThan(3); + return viaWasm; } + it.each([ + ['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'], + ] as const)('leaves nested identifier receivers unresolved and keeps argument calls: %s (#1566)', (ext, language) => { + const result = assertParity(`fixture.${ext}`, ` +function readKey() { return 'answer'; } +function local() { + const values = new Map(); + return values.get(readKey()); +} +function nested(holder) { + holder.values.get(readKey()); + holder.values?.get(readKey()); + holder['values'].get(readKey()); + holder.deep.values.get(readKey()); +} +`, language); + const nested = result.nodes.find((n) => n.name === 'nested' && n.kind === 'function'); + expect(nested).toBeDefined(); + expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'calls' && r.fromNodeId === nested!.id) + .map((r) => r.referenceName)).toEqual(['readKey', 'readKey', 'readKey', 'readKey']); + expect(result.unresolvedReferences.some((r) => r.referenceName === 'values.get')).toBe(true); + }); + it('torture fixture (tsx): components, stores, RTK, fn-refs, value-refs, decorators', () => { const file = path.join(FIXTURE_DIR, 'torture.tsx'); assertParity('fixtures/torture.tsx', fs.readFileSync(file, 'utf8'), 'tsx'); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 7c697a315..b8d25f6b8 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -2316,6 +2316,87 @@ func main() { }); describe('Local-variable receiver-type inference (#1108)', () => { + it.each(['ts', 'tsx', 'js', 'jsx'])('keeps built-in Map calls off project methods — %s (#1566)', async (ext) => { + const typed = ext === 'ts' || ext === 'tsx'; + fs.writeFileSync(path.join(tempDir, `cache.${ext}`), ` +export class LRUCache { + get(key) { return key; } + set(key, value) { return value; } + has(key) { return true; } +} +export function useLocalMap() { + const values = new Map${typed ? '' : ''}(); + values.set('answer', '42'); + values.get('answer'); + return values.has('answer'); +} +export function useNestedMap(holder${typed ? ': { values: Map }' : ''}) { + return holder.values.get('answer'); +} +export function useProjectCache() { + const cache = new LRUCache(); + cache.set('answer', '42'); + cache.get('answer'); + return cache.has('answer'); +} +`); + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + for (const name of ['useLocalMap', 'useNestedMap', 'useProjectCache']) { + const caller = cg.getNodesByName(name).find((n) => n.kind === 'function'); + expect(caller, name).toBeDefined(); + const calls = cg.getOutgoingEdges(caller!.id).filter((e) => e.kind === 'calls'); + if (name === 'useProjectCache') { + const methods = cg.getNodesByKind('method').filter((n) => n.qualifiedName.startsWith('LRUCache::')); + expect(methods).toHaveLength(3); + expect(calls.map((e) => e.target).sort()).toEqual(methods.map((n) => n.id).sort()); + expect(calls.every((e) => e.metadata?.confidence === 0.9)).toBe(true); + } else { + expect.soft(calls, `${ext}: ${name} must not call a project method`).toEqual([]); + } + } + }); + + it('keeps a validated project class that shadows Map (#1566)', async () => { + fs.writeFileSync(path.join(tempDir, 'shadow.ts'), ` +export class Map { get() { return 1; } } +export class Other { get() { return 2; } } +export function useShadow() { + const values = new Map(); + return values.get(); +} +`); + cg = await CodeGraph.init(tempDir, { index: true }); + const caller = cg.getNodesByName('useShadow').find((n) => n.kind === 'function'); + expect(caller).toBeDefined(); + expect(cg.getCallees(caller!.id).filter(({ edge }) => edge.kind === 'calls').map(({ node }) => node.qualifiedName)) + .toEqual(['Map::get']); + }); + + it.each([ + ['Set', 'has'], ['WeakMap', 'get'], ['WeakSet', 'has'], ['Array', 'map'], ['Promise', 'then'], + ])('declines same-name guesses for an inferred %s receiver (#1566)', async (type, method) => { + fs.writeFileSync(path.join(tempDir, 'builtin.ts'), ` +export class Collision { ${method}() { return 1; } } +export function constructed() { + const values = new ${type}(); + return values.${method}(); +} +export function annotated(values: ${type}) { + return values.${method}(); +} +`); + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + expect(cg.getNodesByKind('method').some((n) => n.name === method)).toBe(true); + for (const name of ['constructed', 'annotated']) { + const caller = cg.getNodesByName(name).find((n) => n.kind === 'function'); + expect(caller, name).toBeDefined(); + expect.soft(cg.getOutgoingEdges(caller!.id).filter((e) => e.kind === 'calls'), name).toEqual([]); + } + }); + // `lg.log()` where `lg` is a local whose type is inferred from its // declaration/initializer. Before this, only C++ resolved these; every // other language produced no method edge. Each case is one file with a diff --git a/__tests__/ts-chained-receiver.test.ts b/__tests__/ts-chained-receiver.test.ts index 71049d4a5..cdfa95be5 100644 --- a/__tests__/ts-chained-receiver.test.ts +++ b/__tests__/ts-chained-receiver.test.ts @@ -3,8 +3,8 @@ * .get(k)`, `document.body.querySelector(s)` — ends in a platform API. Emitting * the bare method name for it let every such call exact-match whatever project * symbol shared the name, so a storage wrapper's `get` called itself (#1707). - * Those are dropped. A chain rooted at a project value keeps the bare name: - * `window.MyNs.run()` and `this..m()` reach real targets. + * Those are dropped, as are untyped identifier chains (#1566). The existing + * `window.MyNs.run()` and `this..m()` paths remain outside that guard. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 844916b67..c46495e51 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -1122,15 +1122,10 @@ impl<'t> Walker<'t> { // --- extractCall (TS/JS generic tail) ------------------------------------------------- - /// Whether a member-call receiver is a chain rooted at a host object a - /// TS/JS project never declares. `window` is absent on purpose: - /// `window.MyNs.doThing()` reaches a project symbol (#1707). - fn is_host_global_chain(&self, receiver: Node<'t>) -> bool { - const HOST_GLOBAL_ROOTS: [&str; 19] = [ - "chrome", "browser", "document", "navigator", "performance", "console", - "localStorage", "sessionStorage", "indexedDB", "crypto", "globalThis", - "process", "Math", "JSON", "Object", "Array", "Reflect", "Promise", "Intl", - ]; + /// Identifier-rooted member chains have no inferred property type (#1566), + /// including host API chains (#1707). Keep the existing window namespace + /// escape; call-result and `this` receivers are outside this guard. + fn is_unresolved_member_chain(&self, receiver: Node<'t>) -> bool { let mut cur = receiver; if !matches!(cur.kind(), "member_expression" | "subscript_expression") { return false; @@ -1141,7 +1136,7 @@ impl<'t> Walker<'t> { None => return false, } } - cur.kind() == "identifier" && HOST_GLOBAL_ROOTS.contains(&self.text(cur)) + cur.kind() == "identifier" && self.text(cur) != "window" } pub(super) fn extract_call(&mut self, node: Node<'t>) { @@ -1171,14 +1166,12 @@ impl<'t> Walker<'t> { if is_literal_receiver(r.kind()) { return; } - // A chain rooted at a host namespace — `chrome.storage - // .local.get(k)`, `document.body.querySelector(s)` — - // ends in a platform API, so the bare method name emitted - // here could only exact-match an unrelated project symbol - // sharing it (#1707). Emit nothing. A chain rooted at a - // project value keeps the bare name. Mirrors the TS - // extractor's extractCall (extraction/tree-sitter.ts). - if self.is_host_global_chain(r) { + // `holder.values.get()` has no inferred property type + // (#1566). Dropping the receiver or merely preserving it + // would allow unrelated same-name method guesses. Emit + // nothing, as for host chains (#1707); argument calls are + // visited independently. Mirrors extractCall in TS. + if self.is_unresolved_member_chain(r) { return; } } diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index d1b5b7e9f..030607b92 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -407,34 +407,21 @@ const LITERAL_RECEIVER_TYPES = new Set([ */ const TS_JS_CHAIN_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']); -/** - * Host objects a TS/JS project never declares: the browser, extension, and - * runtime namespaces, plus the builtin constructors whose statics are library - * calls. A member chain ROOTED at one of these ends in a platform API, so the - * bare method name the extractor used to emit for `chrome.storage.local.get(k)` - * or `document.body.querySelector(s)` could only ever exact-match an unrelated - * project symbol that happened to share the name (#1707). `window` is absent on - * purpose: `window.MyNamespace.doThing()` reaches a project symbol. - */ -const TS_JS_HOST_GLOBAL_ROOTS = new Set([ - 'chrome', 'browser', 'document', 'navigator', 'performance', 'console', - 'localStorage', 'sessionStorage', 'indexedDB', 'crypto', 'globalThis', - 'process', 'Math', 'JSON', 'Object', 'Array', 'Reflect', 'Promise', 'Intl', -]); - /** Receiver node types (TS/JS grammars) that continue a member chain downward. */ const TS_JS_CHAIN_RECEIVER_TYPES = new Set(['member_expression', 'subscript_expression']); /** - * Root identifier of a TS/JS member chain — `chrome` for `chrome.storage.local` - * — or null when the chain bottoms out in a call, a literal, or `this`. + * Identifier-rooted member chains have no inferred property type (#1566), + * including host API chains (#1707). Keep the existing `window.MyNamespace` + * escape for project globals; call-result and `this` receivers have their own + * paths and are outside this guard. */ -function tsJsChainRoot(node: SyntaxNode, source: string): string | null { +function isUnresolvedTsJsChain(node: SyntaxNode, source: string): boolean { let cur: SyntaxNode | null = node; while (cur && TS_JS_CHAIN_RECEIVER_TYPES.has(cur.type)) { cur = getChildByField(cur, 'object'); } - return cur && cur.type === 'identifier' ? getNodeText(cur, source) : null; + return !!cur && cur.type === 'identifier' && getNodeText(cur, source) !== 'window'; } /** @@ -4786,18 +4773,14 @@ export class TreeSitterExtractor { TS_JS_CHAIN_LANGUAGES.has(this.language) && receiver && TS_JS_CHAIN_RECEIVER_TYPES.has(receiver.type) && - TS_JS_HOST_GLOBAL_ROOTS.has(tsJsChainRoot(receiver, this.source) ?? '') + isUnresolvedTsJsChain(receiver, this.source) ) { - // TS/JS member call reached through a host namespace — - // `chrome.storage.local.get(key)`, `document.body.querySelector(s)`. - // The bare method name this used to emit exact-matched whatever - // project symbol shared it: every `chrome.storage.local.get/set` - // in a storage wrapper bound to the wrapper's own `get`/`set`, - // a self-edge not in the source (#1707). Emit nothing: a silent - // miss, never a wrong edge. A chain rooted at a project value - // (`window.MyNs.run()`, `store.getState().act()`, `ref.value.m()`) - // keeps the bare name — those targets are real, and dropping them - // would cost far more recall than the mis-bind costs precision. + // `holder.values.get()` has no inferred property type (#1566). + // Emitting bare `get` exact-matches an unrelated project method; + // preserving the chain alone would still allow receiver guessing. + // Emit nothing until the property type can be established. This + // also covers host chains such as `chrome.storage.local.get()` + // (#1707). Calls inside arguments are visited independently. // Mirrored in the kernel's extract_call (tsjs/extractors.rs). return; } else { diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 2502ccbe8..4d6b66472 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -29,6 +29,7 @@ import { logDebug } from '../errors'; import { lexicalPathWithinRoot } from '../utils'; import type { ReExport } from './types'; import { LRUCache } from './lru-cache'; +import { JS_BUILT_INS } from './js-builtins'; /** Node kinds that can declare supertypes (extends/implements). */ const SUPERTYPE_BEARING_KINDS = new Set([ @@ -70,14 +71,6 @@ function resolveCacheLimit(): number { export * from './types'; // Pre-built Sets for O(1) built-in lookups (allocated once, shared across all instances) -const JS_BUILT_INS = new Set([ - 'console', 'window', 'document', 'global', 'process', - 'Promise', 'Array', 'Object', 'String', 'Number', 'Boolean', - 'Date', 'Math', 'JSON', 'RegExp', 'Error', 'Map', 'Set', - 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', - 'fetch', 'require', 'module', 'exports', '__dirname', '__filename', -]); - const REACT_HOOKS = new Set([ 'useState', 'useEffect', 'useContext', 'useReducer', 'useCallback', 'useMemo', 'useRef', 'useLayoutEffect', 'useImperativeHandle', 'useDebugValue', diff --git a/src/resolution/js-builtins.ts b/src/resolution/js-builtins.ts new file mode 100644 index 000000000..ad3e33879 --- /dev/null +++ b/src/resolution/js-builtins.ts @@ -0,0 +1,8 @@ +/** Shared JS/TS built-ins for direct references and inferred receiver types. */ +export const JS_BUILT_INS = new Set([ + 'console', 'window', 'document', 'global', 'process', + 'Promise', 'Array', 'Object', 'String', 'Number', 'Boolean', + 'Date', 'Math', 'JSON', 'RegExp', 'Error', 'Map', 'Set', 'WeakMap', 'WeakSet', + 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', + 'fetch', 'require', 'module', 'exports', '__dirname', '__filename', +]); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index f18acf227..9f9b3b3e3 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types'; import { blankStringContents, stripCommentsForRegex } from './strip-comments'; +import { JS_BUILT_INS } from './js-builtins'; /** * Ceiling on how many same-named definitions a FUZZY name-match strategy will @@ -2194,6 +2195,13 @@ export function matchMethodCall( if (typedMatch) { return typedMatch; } + // A known JS/TS builtin receiver is external when it has no project + // method (#1566). Inference already strips generics (`Map` → + // `Map`); do not let Strategy 3 guess an unrelated `get`/`set`/`has`. + // Keep the validated match above for a project type shadowing a builtin. + if (ESM_FAMILY.has(ref.language) && JS_BUILT_INS.has(inferredType)) { + return null; + } } }