Merge branch 'main' into dependabot/npm_and_yarn/gitnexus-web/npm_and_yarn-a5d3e3ad53

This commit is contained in:
Gergő Magyar 2026-05-15 07:55:49 +01:00 • committed by GitHub
commit a4ae8a88fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
152 changed files with 9131 additions and 191 deletions

View file

@ -174,6 +174,20 @@ Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). A plain `analyze` no
| Tools/resources/schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| CLI commands (index, status, clean, wiki) | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
## Hook env knobs
The Claude Code hook (`gitnexus/hooks/claude/gitnexus-hook.cjs` and the mirrored plugin copy under `gitnexus-claude-plugin/hooks/`) honours these env vars. Defaults work for normal installations; set them only to override resolution. All path overrides ignore values that do not exist on disk and fall through to the standard resolution chain.
| Env var | Type | Default | Purpose |
|---------|------|---------|---------|
| `GITNEXUS_HOOK_CLI_PATH` | path | resolved via package layout / `require.resolve` | Override path to the `gitnexus` CLI entry the hook spawns for `augment`. |
| `GITNEXUS_HOOK_LSOF_PATH` | path | `lsof` on `PATH` (with `/usr/bin/lsof`, `/usr/sbin/lsof`, `/sbin/lsof` fallbacks) | Override POSIX `lsof` location for the DB-lock probe. |
| `GITNEXUS_HOOK_PS_PATH` | path | `ps` on `PATH` (with `/bin/ps`, `/usr/bin/ps` fallbacks) | Override POSIX `ps` location. |
| `GITNEXUS_HOOK_POWERSHELL_PATH` | path | `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe` (then `SysWOW64`, then `powershell.exe` on `PATH`) | Override Windows PowerShell location used by the Restart-Manager probe. |
| `GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS` | integer ms | `1200` | Max wall-clock for the Linux `/proc` fd scan before bailing out to the `lsof` fallback. |
| `GITNEXUS_HOOK_RM_TARGET` | path | derived | Restart-Manager target file (the LadybugDB path under `.gitnexus/`). Set internally by the hook; rarely overridden manually. |
| `GITNEXUS_DEBUG` | boolean (`1`/`true`) | unset | Verbose stderr from the hook: prints discarded augment-stderr prefixes and one-shot `.ps1` load-failure warnings. |
<!-- gitnexus:end -->
## Repo reference

View file

@ -15,6 +15,7 @@ const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { acquireHookSlot } = require('./hook-lock.js');
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
/**
* Read JSON input from stdin synchronously.
@ -103,6 +104,28 @@ function findGitNexusDir(startDir) {
return null;
}
function hasGitNexusServerOwner(gitNexusDir) {
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
}
function extractAugmentContext(stderr) {
const output = (stderr || '').trim();
const marker = output.indexOf('[GitNexus]');
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
if (debug && output.length > 0) {
// Emit the FULL discarded prefix (everything before the marker, or all of
// it when no marker is present) so suppressed diagnostics — LadybugDB lock
// warnings, parser errors, etc. — remain recoverable on the hook's own
// stderr. The untruncated payload lets operators see exactly what was
// filtered out instead of a 180-char JSON-quoted preview.
const discarded = marker === -1 ? output : output.slice(0, marker).trim();
if (discarded.length > 0) {
process.stderr.write(`[GitNexus hook] augment stderr discarded prefix:\n${discarded}\n`);
}
}
return marker === -1 ? '' : output.slice(marker).trim();
}
/**
* Extract search pattern from tool input.
*/
@ -170,6 +193,15 @@ function extractPattern(toolName, toolInput) {
*/
function runGitNexusCli(args, cwd, timeout) {
const isWin = process.platform === 'win32';
const hookCli = process.env.GITNEXUS_HOOK_CLI_PATH;
if (hookCli !== undefined && String(hookCli).trim() && fs.existsSync(String(hookCli))) {
return spawnSync(process.execPath, [String(hookCli), ...args], {
encoding: 'utf-8',
timeout,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
}
// Detect whether 'gitnexus' is on PATH (cheap check, no execution)
let useDirectBinary = false;
@ -228,6 +260,10 @@ function handlePreToolUse(input) {
const pattern = extractPattern(toolName, toolInput);
if (!pattern || pattern.length < 3) return;
if (hasGitNexusServerOwner(gitNexusDir)) {
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
return;
}
const release = acquireHookSlot(gitNexusDir);
if (!release) return;
@ -236,7 +272,7 @@ function handlePreToolUse(input) {
try {
const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
result = child.stderr || '';
result = extractAugmentContext(child.stderr || '');
}
} catch {
/* graceful failure */
@ -244,8 +280,8 @@ function handlePreToolUse(input) {
release();
}
if (result && result.trim()) {
sendHookResponse('PreToolUse', result.trim());
if (result) {
sendHookResponse('PreToolUse', result);
}
}

View file

@ -0,0 +1,238 @@
/**
* Cross-platform best-effort probe: does another process hold dbPath open
* with a command line that looks like a GitNexus MCP/serve server?
*
* Backends (no user-installed Sysinternals):
* - Linux: scan procfs under /proc (per-PID fd entries) via stat(2) (dev+inode); works without lsof;
* optional lsof fallback when proc scan finds nothing.
* - macOS / *BSD / etc.: trusted lsof + ps (absolute paths first).
* - Windows: Restart Manager (rstrtmgr) via bundled PowerShell script +
* Win32_Process for command lines; trusted powershell.exe under %SystemRoot%.
*
* Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
* PowerShell ETIMEDOUT (Windows), matching the hook contract.
*/
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
function isGitNexusServerCommand(command) {
const hasServerMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(command);
const hasGitNexus =
/(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(command) ||
/node_modules[/\\]gitnexus[/\\]/.test(command);
return hasServerMode && hasGitNexus;
}
function resolveHookBinary(tool) {
const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH';
const fromEnv = process.env[envKey];
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) {
return String(fromEnv);
}
const candidates =
tool === 'lsof'
? ['/usr/bin/lsof', '/usr/sbin/lsof', '/sbin/lsof', tool]
: ['/bin/ps', '/usr/bin/ps', tool];
for (const candidate of candidates) {
if (candidate === tool) return tool;
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
/* ignore */
}
}
return tool;
}
function resolveWindowsPowerShellPath() {
const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
return String(fromEnv).trim();
}
const root = process.env.SystemRoot || 'C:\\Windows';
const ps = path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
if (fs.existsSync(ps)) return ps;
const psWow = path.join(root, 'SysWOW64', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
if (fs.existsSync(psWow)) return psWow;
return 'powershell.exe';
}
// Sentinel:
// undefined = not loaded yet (try the read)
// string = encoded PowerShell command (successful load)
// null = load attempted and failed (do not retry; warning already emitted)
let windowsRmListPsEncodedCommandCache;
let windowsRmListPsLoadFailureWarned = false;
function getWindowsRmListEncodedCommand() {
if (windowsRmListPsEncodedCommandCache !== undefined) {
return windowsRmListPsEncodedCommandCache;
}
try {
const ps1Path = path.join(__dirname, 'win-rm-list-json.ps1');
const src = fs
.readFileSync(ps1Path, 'utf8')
.replace(/^\uFEFF/, '')
.replace(/\r\n/g, '\n');
windowsRmListPsEncodedCommandCache = Buffer.from(src, 'utf16le').toString('base64');
} catch (err) {
windowsRmListPsEncodedCommandCache = null;
if (
!windowsRmListPsLoadFailureWarned &&
(process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true')
) {
windowsRmListPsLoadFailureWarned = true;
const msg = err && err.message ? String(err.message).slice(0, 200) : 'unknown';
process.stderr.write(`[GitNexus hook] win-rm-list-json.ps1 load failed: ${msg}\n`);
}
}
return windowsRmListPsEncodedCommandCache;
}
function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) {
const encoded = getWindowsRmListEncodedCommand();
if (!encoded) return false;
const psExe = resolveWindowsPowerShellPath();
const r = spawnSync(
psExe,
[
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-STA',
'-EncodedCommand',
encoded,
],
{
encoding: 'utf-8',
timeout: 6000,
stdio: ['ignore', 'pipe', 'ignore'],
env: { ...process.env, GITNEXUS_HOOK_RM_TARGET: dbPathAbs },
},
);
// ETIMEDOUT means the PowerShell probe didn't return in time; treat as 'unresponsive process holds DB' → fail-closed (skip augment).
if (r.error) return r.error.code === 'ETIMEDOUT';
if (r.status !== 0) return false;
let rows;
try {
rows = JSON.parse(String(r.stdout || '').trim() || '[]');
} catch {
return false;
}
if (!Array.isArray(rows)) return false;
for (const row of rows) {
const procId = Number(row.pid);
const cmd = String(row.cmd || '');
if (!Number.isFinite(procId) || procId === myPid) continue;
if (isGitNexusServerCommand(cmd)) return true;
}
return false;
}
function readLinuxCmdline(pidStr) {
try {
return fs.readFileSync(`/proc/${pidStr}/cmdline`, 'utf8').replace(/\0+/g, ' ').trim();
} catch {
return '';
}
}
function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
const raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS;
const budget = Number(raw && String(raw).trim()) ? Number.parseInt(String(raw), 10) : 1200;
const start = Date.now();
let targetStat;
try {
targetStat = fs.statSync(dbPathAbs);
} catch {
return false;
}
let procEntries;
try {
procEntries = fs.readdirSync('/proc', { withFileTypes: true });
} catch {
return false;
}
for (const ent of procEntries) {
if (Date.now() - start > budget) return false;
if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue;
const pid = Number.parseInt(ent.name, 10);
if (!Number.isFinite(pid) || pid === myPid) continue;
const fdDir = path.join('/proc', ent.name, 'fd');
let fds;
try {
fds = fs.readdirSync(fdDir);
} catch {
continue;
}
let holds = false;
for (const fd of fds) {
if (Date.now() - start > budget) return false;
try {
const st = fs.statSync(path.join(fdDir, fd));
if (st.dev === targetStat.dev && st.ino === targetStat.ino) {
holds = true;
break;
}
} catch {
/* ignore */
}
}
if (!holds) continue;
if (isGitNexusServerCommand(readLinuxCmdline(ent.name))) return true;
}
return false;
}
function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
const lsofPath = resolveHookBinary('lsof');
const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
encoding: 'utf-8',
timeout: 1000,
stdio: ['ignore', 'pipe', 'ignore'],
});
if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
const psPath = resolveHookBinary('ps');
for (const pid of pids) {
if (Number(pid) === myPid) continue;
const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
encoding: 'utf-8',
timeout: 500,
stdio: ['ignore', 'pipe', 'ignore'],
});
if (ps.error) {
if (ps.error.code === 'ETIMEDOUT') return true;
continue;
}
if (isGitNexusServerCommand(ps.stdout || '')) return true;
}
return false;
}
/**
* @param {string} dbPath Absolute or relative path to the DB file (e.g. .../lbug).
* @param {number} myPid Current process PID (hook runner), excluded from matches.
*/
function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
if (!fs.existsSync(dbPath)) return false;
const dbPathAbs = path.resolve(dbPath);
if (process.platform === 'win32') {
return hasGitNexusServerOwnerWindows(dbPathAbs, myPid);
}
if (process.platform === 'linux') {
if (linuxProcScanFindGitNexusServer(dbPathAbs, myPid)) return true;
return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
}
return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
}
module.exports = {
hasGitNexusDbLockedByGitNexusServer,
};

View file

@ -0,0 +1,76 @@
$ErrorActionPreference = 'Stop'
$target = $env:GITNEXUS_HOOK_RM_TARGET
if ([string]::IsNullOrWhiteSpace($target)) { Write-Output '[]'; exit 0 }
$target = (Resolve-Path -LiteralPath $target).ProviderPath
if (-not ([Management.Automation.PSTypeName]'GitNexusHookRm.Native').Type) {
Add-Type @'
using System;
using System.Runtime.InteropServices;
namespace GitNexusHookRm {
public static class Native {
public const int ErrorMoreData = 234;
[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct RM_UNIQUE_PROCESS {
public int dwProcessId;
public long ProcessStartTime;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct RM_PROCESS_INFO {
public RM_UNIQUE_PROCESS Process;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string strAppName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
public string strServiceShortName;
public uint ApplicationType;
public uint AppStatus;
public uint TSSessionId;
public uint bRestartable;
}
[DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
public static extern int RmStartSession(out uint pSessionHandle, uint dwSessionFlags, string strSessionKey);
[DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
public static extern int RmRegisterResources(uint pSessionHandle, uint nFiles, string[] rgsFileNames, uint nApplications, IntPtr rgApplications, uint nServices, string[] rgsServiceNames);
[DllImport("rstrtmgr.dll")]
public static extern int RmGetList(uint dwSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, [In, Out] RM_PROCESS_INFO[] rgAffectedApps, ref uint lpdwRebootReasons);
[DllImport("rstrtmgr.dll")]
public static extern int RmEndSession(uint pSessionHandle);
}
}
'@
}
$h = [uint32]0
$key = [guid]::NewGuid().ToString('N')
$rmErr = [GitNexusHookRm.Native]::RmStartSession([ref]$h, 0, $key)
if ($rmErr -ne 0) { Write-Output '[]'; exit 0 }
$files = @($target)
$err = [GitNexusHookRm.Native]::RmRegisterResources($h, 1, $files, 0, [IntPtr]::Zero, 0, $null)
if ($err -ne 0) {
[void][GitNexusHookRm.Native]::RmEndSession($h)
Write-Output '[]'
exit 0
}
$need = [uint32]0
$n = [uint32]0
$reboot = [uint32]0
$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $null, [ref]$reboot)
if ($err -ne [GitNexusHookRm.Native]::ErrorMoreData) {
[void][GitNexusHookRm.Native]::RmEndSession($h)
Write-Output '[]'
exit 0
}
$n = $need
$buf = New-Object GitNexusHookRm.Native+RM_PROCESS_INFO[] ([int]$n)
$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $buf, [ref]$reboot)
[void][GitNexusHookRm.Native]::RmEndSession($h)
if ($err -ne 0) { Write-Output '[]'; exit 0 }
$out = @()
for ($i = 0; $i -lt [int]$n; $i++) {
$procId = $buf[$i].Process.dwProcessId
$p = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$procId" -ErrorAction SilentlyContinue
$cmd = if ($p) { $p.CommandLine } else { '' }
$out += [PSCustomObject]@{ pid = [int]$procId; cmd = $cmd }
}
ConvertTo-Json -InputObject @($out) -Compress

View file

@ -833,7 +833,16 @@ function expandWildcard(
if (target === undefined) return [edge];
const names = hooks.expandsWildcardTo(edge.targetModuleScope, workspace);
if (names.length === 0) return [];
if (names.length === 0) {
// Resolved wildcard with zero propagating names is still a real file-
// level dependency (e.g. a C++ header that only declares classes —
// `#include` is a valid IMPORTS edge, but unqualified-binding names
// are correctly empty since class methods require `Class::method`).
// Preserve the original wildcard edge so the file→file IMPORTS edge
// survives; downstream binding materialization sees no propagated
// names because the edge has no `targetExportedName`/`localName`.
return [edge];
}
const expanded: ImportEdge[] = [];
for (const name of names) {

View file

@ -30,6 +30,8 @@ export interface SymbolDefinition {
returnType?: string;
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
declaredType?: string;
/** Generic/template specialization arguments for class-like symbols (e.g. ['User'], ['T*']). */
templateArguments?: string[];
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string;
}

View file

@ -15,6 +15,7 @@ const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { acquireHookSlot } = require('./hook-lock.cjs');
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
/**
* Read JSON input from stdin synchronously.
@ -103,6 +104,28 @@ function findGitNexusDir(startDir) {
return null;
}
function hasGitNexusServerOwner(gitNexusDir) {
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
}
function extractAugmentContext(stderr) {
const output = (stderr || '').trim();
const marker = output.indexOf('[GitNexus]');
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
if (debug && output.length > 0) {
// Emit the FULL discarded prefix (everything before the marker, or all of
// it when no marker is present) so suppressed diagnostics — KuzuDB lock
// warnings, parser errors, etc. — remain recoverable on the hook's own
// stderr. The untruncated payload lets operators see exactly what was
// filtered out instead of a 180-char JSON-quoted preview.
const discarded = marker === -1 ? output : output.slice(0, marker).trim();
if (discarded.length > 0) {
process.stderr.write(`[GitNexus hook] augment stderr discarded prefix:\n${discarded}\n`);
}
}
return marker === -1 ? '' : output.slice(marker).trim();
}
/**
* Extract search pattern from tool input.
*/
@ -168,6 +191,10 @@ function extractPattern(toolName, toolInput) {
* 3. Fall back to npx (returns empty string)
*/
function resolveCliPath() {
const fromEnv = process.env.GITNEXUS_HOOK_CLI_PATH;
if (fromEnv !== undefined && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) {
return String(fromEnv);
}
let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');
if (!fs.existsSync(cliPath)) {
try {
@ -218,6 +245,10 @@ function handlePreToolUse(input) {
const pattern = extractPattern(toolName, toolInput);
if (!pattern || pattern.length < 3) return;
if (hasGitNexusServerOwner(gitNexusDir)) {
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
return;
}
const release = acquireHookSlot(gitNexusDir);
if (!release) return;
@ -227,7 +258,7 @@ function handlePreToolUse(input) {
try {
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
result = child.stderr || '';
result = extractAugmentContext(child.stderr || '');
}
} catch {
/* graceful failure */
@ -235,8 +266,8 @@ function handlePreToolUse(input) {
release();
}
if (result && result.trim()) {
sendHookResponse('PreToolUse', result.trim());
if (result) {
sendHookResponse('PreToolUse', result);
}
}

View file

@ -0,0 +1,238 @@
/**
* Cross-platform best-effort probe: does another process hold dbPath open
* with a command line that looks like a GitNexus MCP/serve server?
*
* Backends (no user-installed Sysinternals):
* - Linux: scan procfs under /proc (per-PID fd entries) via stat(2) (dev+inode); works without lsof;
* optional lsof fallback when proc scan finds nothing.
* - macOS / *BSD / etc.: trusted lsof + ps (absolute paths first).
* - Windows: Restart Manager (rstrtmgr) via bundled PowerShell script +
* Win32_Process for command lines; trusted powershell.exe under %SystemRoot%.
*
* Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
* PowerShell ETIMEDOUT (Windows), matching the hook contract.
*/
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
function isGitNexusServerCommand(command) {
const hasServerMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(command);
const hasGitNexus =
/(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(command) ||
/node_modules[/\\]gitnexus[/\\]/.test(command);
return hasServerMode && hasGitNexus;
}
function resolveHookBinary(tool) {
const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH';
const fromEnv = process.env[envKey];
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) {
return String(fromEnv);
}
const candidates =
tool === 'lsof'
? ['/usr/bin/lsof', '/usr/sbin/lsof', '/sbin/lsof', tool]
: ['/bin/ps', '/usr/bin/ps', tool];
for (const candidate of candidates) {
if (candidate === tool) return tool;
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
/* ignore */
}
}
return tool;
}
function resolveWindowsPowerShellPath() {
const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
return String(fromEnv).trim();
}
const root = process.env.SystemRoot || 'C:\\Windows';
const ps = path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
if (fs.existsSync(ps)) return ps;
const psWow = path.join(root, 'SysWOW64', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
if (fs.existsSync(psWow)) return psWow;
return 'powershell.exe';
}
// Sentinel:
// undefined = not loaded yet (try the read)
// string = encoded PowerShell command (successful load)
// null = load attempted and failed (do not retry; warning already emitted)
let windowsRmListPsEncodedCommandCache;
let windowsRmListPsLoadFailureWarned = false;
function getWindowsRmListEncodedCommand() {
if (windowsRmListPsEncodedCommandCache !== undefined) {
return windowsRmListPsEncodedCommandCache;
}
try {
const ps1Path = path.join(__dirname, 'win-rm-list-json.ps1');
const src = fs
.readFileSync(ps1Path, 'utf8')
.replace(/^\uFEFF/, '')
.replace(/\r\n/g, '\n');
windowsRmListPsEncodedCommandCache = Buffer.from(src, 'utf16le').toString('base64');
} catch (err) {
windowsRmListPsEncodedCommandCache = null;
if (
!windowsRmListPsLoadFailureWarned &&
(process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true')
) {
windowsRmListPsLoadFailureWarned = true;
const msg = err && err.message ? String(err.message).slice(0, 200) : 'unknown';
process.stderr.write(`[GitNexus hook] win-rm-list-json.ps1 load failed: ${msg}\n`);
}
}
return windowsRmListPsEncodedCommandCache;
}
function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) {
const encoded = getWindowsRmListEncodedCommand();
if (!encoded) return false;
const psExe = resolveWindowsPowerShellPath();
const r = spawnSync(
psExe,
[
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-STA',
'-EncodedCommand',
encoded,
],
{
encoding: 'utf-8',
timeout: 6000,
stdio: ['ignore', 'pipe', 'ignore'],
env: { ...process.env, GITNEXUS_HOOK_RM_TARGET: dbPathAbs },
},
);
// ETIMEDOUT means the PowerShell probe didn't return in time; treat as 'unresponsive process holds DB' → fail-closed (skip augment).
if (r.error) return r.error.code === 'ETIMEDOUT';
if (r.status !== 0) return false;
let rows;
try {
rows = JSON.parse(String(r.stdout || '').trim() || '[]');
} catch {
return false;
}
if (!Array.isArray(rows)) return false;
for (const row of rows) {
const procId = Number(row.pid);
const cmd = String(row.cmd || '');
if (!Number.isFinite(procId) || procId === myPid) continue;
if (isGitNexusServerCommand(cmd)) return true;
}
return false;
}
function readLinuxCmdline(pidStr) {
try {
return fs.readFileSync(`/proc/${pidStr}/cmdline`, 'utf8').replace(/\0+/g, ' ').trim();
} catch {
return '';
}
}
function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
const raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS;
const budget = Number(raw && String(raw).trim()) ? Number.parseInt(String(raw), 10) : 1200;
const start = Date.now();
let targetStat;
try {
targetStat = fs.statSync(dbPathAbs);
} catch {
return false;
}
let procEntries;
try {
procEntries = fs.readdirSync('/proc', { withFileTypes: true });
} catch {
return false;
}
for (const ent of procEntries) {
if (Date.now() - start > budget) return false;
if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue;
const pid = Number.parseInt(ent.name, 10);
if (!Number.isFinite(pid) || pid === myPid) continue;
const fdDir = path.join('/proc', ent.name, 'fd');
let fds;
try {
fds = fs.readdirSync(fdDir);
} catch {
continue;
}
let holds = false;
for (const fd of fds) {
if (Date.now() - start > budget) return false;
try {
const st = fs.statSync(path.join(fdDir, fd));
if (st.dev === targetStat.dev && st.ino === targetStat.ino) {
holds = true;
break;
}
} catch {
/* ignore */
}
}
if (!holds) continue;
if (isGitNexusServerCommand(readLinuxCmdline(ent.name))) return true;
}
return false;
}
function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
const lsofPath = resolveHookBinary('lsof');
const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
encoding: 'utf-8',
timeout: 1000,
stdio: ['ignore', 'pipe', 'ignore'],
});
if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
const psPath = resolveHookBinary('ps');
for (const pid of pids) {
if (Number(pid) === myPid) continue;
const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
encoding: 'utf-8',
timeout: 500,
stdio: ['ignore', 'pipe', 'ignore'],
});
if (ps.error) {
if (ps.error.code === 'ETIMEDOUT') return true;
continue;
}
if (isGitNexusServerCommand(ps.stdout || '')) return true;
}
return false;
}
/**
* @param {string} dbPath Absolute or relative path to the DB file (e.g. .../lbug).
* @param {number} myPid Current process PID (hook runner), excluded from matches.
*/
function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
if (!fs.existsSync(dbPath)) return false;
const dbPathAbs = path.resolve(dbPath);
if (process.platform === 'win32') {
return hasGitNexusServerOwnerWindows(dbPathAbs, myPid);
}
if (process.platform === 'linux') {
if (linuxProcScanFindGitNexusServer(dbPathAbs, myPid)) return true;
return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
}
return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
}
module.exports = {
hasGitNexusDbLockedByGitNexusServer,
};

View file

@ -0,0 +1,76 @@
$ErrorActionPreference = 'Stop'
$target = $env:GITNEXUS_HOOK_RM_TARGET
if ([string]::IsNullOrWhiteSpace($target)) { Write-Output '[]'; exit 0 }
$target = (Resolve-Path -LiteralPath $target).ProviderPath
if (-not ([Management.Automation.PSTypeName]'GitNexusHookRm.Native').Type) {
Add-Type @'
using System;
using System.Runtime.InteropServices;
namespace GitNexusHookRm {
public static class Native {
public const int ErrorMoreData = 234;
[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct RM_UNIQUE_PROCESS {
public int dwProcessId;
public long ProcessStartTime;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct RM_PROCESS_INFO {
public RM_UNIQUE_PROCESS Process;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string strAppName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
public string strServiceShortName;
public uint ApplicationType;
public uint AppStatus;
public uint TSSessionId;
public uint bRestartable;
}
[DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
public static extern int RmStartSession(out uint pSessionHandle, uint dwSessionFlags, string strSessionKey);
[DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
public static extern int RmRegisterResources(uint pSessionHandle, uint nFiles, string[] rgsFileNames, uint nApplications, IntPtr rgApplications, uint nServices, string[] rgsServiceNames);
[DllImport("rstrtmgr.dll")]
public static extern int RmGetList(uint dwSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, [In, Out] RM_PROCESS_INFO[] rgAffectedApps, ref uint lpdwRebootReasons);
[DllImport("rstrtmgr.dll")]
public static extern int RmEndSession(uint pSessionHandle);
}
}
'@
}
$h = [uint32]0
$key = [guid]::NewGuid().ToString('N')
$rmErr = [GitNexusHookRm.Native]::RmStartSession([ref]$h, 0, $key)
if ($rmErr -ne 0) { Write-Output '[]'; exit 0 }
$files = @($target)
$err = [GitNexusHookRm.Native]::RmRegisterResources($h, 1, $files, 0, [IntPtr]::Zero, 0, $null)
if ($err -ne 0) {
[void][GitNexusHookRm.Native]::RmEndSession($h)
Write-Output '[]'
exit 0
}
$need = [uint32]0
$n = [uint32]0
$reboot = [uint32]0
$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $null, [ref]$reboot)
if ($err -ne [GitNexusHookRm.Native]::ErrorMoreData) {
[void][GitNexusHookRm.Native]::RmEndSession($h)
Write-Output '[]'
exit 0
}
$n = $need
$buf = New-Object GitNexusHookRm.Native+RM_PROCESS_INFO[] ([int]$n)
$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $buf, [ref]$reboot)
[void][GitNexusHookRm.Native]::RmEndSession($h)
if ($err -ne 0) { Write-Output '[]'; exit 0 }
$out = @()
for ($i = 0; $i -lt [int]$n; $i++) {
$procId = $buf[$i].Process.dwProcessId
$p = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$procId" -ErrorAction SilentlyContinue
$cmd = if ($p) { $p.CommandLine } else { '' }
$out += [PSCustomObject]@{ pid = [int]$procId; cmd = $cmd }
}
ConvertTo-Json -InputObject @($out) -Compress

View file

@ -142,9 +142,9 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
@ -1572,9 +1572,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
"integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
"version": "0.130.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
"dev": true,
"license": "MIT",
"funding": {
@ -1652,9 +1652,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"cpu": [
"arm64"
],
@ -1669,9 +1669,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"cpu": [
"arm64"
],
@ -1686,9 +1686,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"cpu": [
"x64"
],
@ -1703,9 +1703,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"cpu": [
"x64"
],
@ -1720,9 +1720,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz",
"integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"cpu": [
"arm"
],
@ -1737,9 +1737,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"cpu": [
"arm64"
],
@ -1754,9 +1754,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"cpu": [
"arm64"
],
@ -1771,9 +1771,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"cpu": [
"ppc64"
],
@ -1788,9 +1788,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"cpu": [
"s390x"
],
@ -1805,9 +1805,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"cpu": [
"x64"
],
@ -1822,9 +1822,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"cpu": [
"x64"
],
@ -1839,9 +1839,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"cpu": [
"arm64"
],
@ -1856,9 +1856,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz",
"integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"cpu": [
"wasm32"
],
@ -1866,8 +1866,8 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.9.2",
"@emnapi/runtime": "1.9.2",
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
@ -1875,9 +1875,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
@ -1886,9 +1886,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"cpu": [
"arm64"
],
@ -1903,9 +1903,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
"cpu": [
"x64"
],
@ -1920,9 +1920,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz",
"integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
@ -1941,9 +1941,9 @@
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
@ -2132,14 +2132,14 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz",
"integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.5",
"@vitest/utils": "4.1.6",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@ -2153,8 +2153,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.5",
"vitest": "4.1.5"
"@vitest/browser": "4.1.6",
"vitest": "4.1.6"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@ -2163,16 +2163,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
"integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/spy": "4.1.6",
"@vitest/utils": "4.1.6",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@ -2181,13 +2181,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
"integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.5",
"@vitest/spy": "4.1.6",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@ -2208,9 +2208,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
"integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2221,13 +2221,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
"integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.5",
"@vitest/utils": "4.1.6",
"pathe": "^2.0.3"
},
"funding": {
@ -2235,14 +2235,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
"integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/pretty-format": "4.1.6",
"@vitest/utils": "4.1.6",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@ -2251,9 +2251,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
"integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
"dev": true,
"license": "MIT",
"funding": {
@ -2261,13 +2261,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
"integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/pretty-format": "4.1.6",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@ -4151,9 +4151,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
@ -4498,9 +4498,9 @@
"license": "MIT"
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
@ -4703,14 +4703,14 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
"integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.126.0",
"@rolldown/pluginutils": "1.0.0-rc.16"
"@oxc-project/types": "=0.130.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@ -4719,21 +4719,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-x64": "1.0.0-rc.16",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.16",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.16",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.16",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.16",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16"
"@rolldown/binding-android-arm64": "1.0.1",
"@rolldown/binding-darwin-arm64": "1.0.1",
"@rolldown/binding-darwin-x64": "1.0.1",
"@rolldown/binding-freebsd-x64": "1.0.1",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
"@rolldown/binding-linux-arm64-musl": "1.0.1",
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
"@rolldown/binding-linux-x64-gnu": "1.0.1",
"@rolldown/binding-linux-x64-musl": "1.0.1",
"@rolldown/binding-openharmony-arm64": "1.0.1",
"@rolldown/binding-wasm32-wasi": "1.0.1",
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
"@rolldown/binding-win32-x64-msvc": "1.0.1"
}
},
"node_modules/router": {
@ -5612,16 +5612,16 @@
}
},
"node_modules/vite": {
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.16",
"postcss": "^8.5.14",
"rolldown": "1.0.1",
"tinyglobby": "^0.2.16"
},
"bin": {
@ -5638,7 +5638,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@ -5690,19 +5690,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
"integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
"@vitest/pretty-format": "4.1.5",
"@vitest/runner": "4.1.5",
"@vitest/snapshot": "4.1.5",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/expect": "4.1.6",
"@vitest/mocker": "4.1.6",
"@vitest/pretty-format": "4.1.6",
"@vitest/runner": "4.1.6",
"@vitest/snapshot": "4.1.6",
"@vitest/spy": "4.1.6",
"@vitest/utils": "4.1.6",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@ -5730,12 +5730,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.5",
"@vitest/browser-preview": "4.1.5",
"@vitest/browser-webdriverio": "4.1.5",
"@vitest/coverage-istanbul": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"@vitest/ui": "4.1.5",
"@vitest/browser-playwright": "4.1.6",
"@vitest/browser-preview": "4.1.6",
"@vitest/browser-webdriverio": "4.1.6",
"@vitest/coverage-istanbul": "4.1.6",
"@vitest/coverage-v8": "4.1.6",
"@vitest/ui": "4.1.6",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"

View file

@ -373,6 +373,24 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
// Helper not found in source — skip
}
try {
await fs.copyFile(
path.join(pluginHooksPath, 'hook-db-lock-probe.cjs'),
path.join(destHooksDir, 'hook-db-lock-probe.cjs'),
);
} catch {
// Helper not found in source — skip
}
try {
await fs.copyFile(
path.join(pluginHooksPath, 'win-rm-list-json.ps1'),
path.join(destHooksDir, 'win-rm-list-json.ps1'),
);
} catch {
// Helper not found in source — skip
}
const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
// Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization).
// The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote`

View file

@ -774,6 +774,7 @@ export const processCalls = async (
propertyName: string;
filePath: string;
srcId: string;
line?: number;
}[] = [];
// Phase P cross-file: accumulate heritage across files for cross-file isSubclassOf.
// Used as a secondary check when per-file parentMap lacks the relationship — helps
@ -1102,11 +1103,16 @@ export const processCalls = async (
provider,
);
const srcId = enclosing || generateId('File', file.path);
// Defer resolution so write-access tracking sees the FINAL graph
// state — properties from the pre-pass are present, but receiver-type
// resolution can still depend on inference that completes during the
// main loop. Resolve after all files have been processed.
pendingWrites.push({ receiverTypeName, propertyName, filePath: file.path, srcId });
// Defer resolution: Ruby attr_accessor properties are registered during
// this same loop, so cross-file lookups fail if the declaring file hasn't
// been processed yet. Collect now, resolve after all files are done.
pendingWrites.push({
receiverTypeName,
propertyName,
filePath: file.path,
srcId,
line: captureMap['assignment'].startPosition.row + 1,
});
}
// Assignment-only capture (no @call sibling): skip the rest of this
// forEach iteration — this acts as a `continue` in the match loop.
@ -1516,7 +1522,10 @@ export const processCalls = async (
);
if (fieldOwner) {
graph.addRelationship({
id: generateId('ACCESSES', `${pw.srcId}:${fieldOwner.nodeId}:write`),
id: generateId(
'ACCESSES',
`${pw.srcId}:${fieldOwner.nodeId}:write${pw.line !== undefined ? `:${pw.line}` : ''}`,
),
sourceId: pw.srcId,
targetId: fieldOwner.nodeId,
type: 'ACCESSES',
@ -3113,7 +3122,10 @@ export const processAssignmentsFromExtracted = (
const fieldOwner = resolveFieldOwnership(receiverTypeName, asn.propertyName, asn.filePath, ctx);
if (!fieldOwner) continue;
graph.addRelationship({
id: generateId('ACCESSES', `${asn.sourceId}:${fieldOwner.nodeId}:write`),
id: generateId(
'ACCESSES',
`${asn.sourceId}:${fieldOwner.nodeId}:write${asn.line !== undefined ? `:${asn.line}` : ''}`,
),
sourceId: asn.sourceId,
targetId: fieldOwner.nodeId,
type: 'ACCESSES',

View file

@ -2,6 +2,40 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { ClassExtractionConfig } from '../../class-types.js';
import {
extractTemplateArguments,
stripTemplateArguments,
} from '../../utils/template-arguments.js';
function shouldSkipCppTemplateDuplicateCapture(
captureMap: Record<string, { text: string } | undefined>,
definitionName: string | undefined,
capturedName: string | undefined,
): boolean {
if (captureMap['template-arguments'] !== undefined) return false;
if (!definitionName) return false;
const argsFromDefinitionName = extractTemplateArguments(definitionName);
if (argsFromDefinitionName === undefined) return false;
const argsFromCaptureName = capturedName ? extractTemplateArguments(capturedName) : undefined;
// Generic class capture emits only `List`, while the specialization-aware
// capture emits `List` + `@declaration.template-arguments`. Skip the former
// when the declaration name itself is templated to avoid duplicate class defs.
return argsFromCaptureName === undefined;
}
function extractCppTemplateArgumentsWithFallback(
captureMap: Record<string, { text: string } | undefined>,
definitionName: string | undefined,
capturedName: string | undefined,
): string[] | undefined {
return (
(captureMap['template-arguments']
? extractTemplateArguments(captureMap['template-arguments'].text)
: undefined) ??
(definitionName ? extractTemplateArguments(definitionName) : undefined) ??
(capturedName ? extractTemplateArguments(capturedName) : undefined)
);
}
export const cClassConfig: ClassExtractionConfig = {
language: SupportedLanguages.C,
@ -12,4 +46,27 @@ export const cppClassConfig: ClassExtractionConfig = {
language: SupportedLanguages.CPlusPlus,
typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'enum_specifier'],
ancestorScopeNodeTypes: ['namespace_definition', 'class_specifier', 'struct_specifier'],
extractName: (node) => {
const nameNode = node.childForFieldName?.('name');
if (!nameNode) return undefined;
if (nameNode.type !== 'template_type') return undefined;
return stripTemplateArguments(nameNode.text);
},
extractTemplateArguments: (node) => {
const nameNode = node.childForFieldName?.('name');
if (!nameNode || nameNode.type !== 'template_type') return undefined;
return extractTemplateArguments(nameNode.text);
},
shouldSkipClassCapture: ({ captureMap, definitionNode, nameNode }) =>
shouldSkipCppTemplateDuplicateCapture(
captureMap,
definitionNode?.childForFieldName?.('name')?.text,
nameNode?.text,
),
extractTemplateArgumentsFromCapture: ({ captureMap, definitionNode, nameNode }) =>
extractCppTemplateArgumentsWithFallback(
captureMap,
definitionNode?.childForFieldName?.('name')?.text,
nameNode?.text,
),
};

View file

@ -154,10 +154,12 @@ export function createClassExtractor(config: ClassExtractionConfig): ClassExtrac
if (!name || !type) return null;
const templateArguments = config.extractTemplateArguments?.(node);
return {
name,
type,
qualifiedName: buildQualifiedName(node, name) || name,
...(templateArguments !== undefined ? { templateArguments } : {}),
};
};
@ -173,5 +175,13 @@ export function createClassExtractor(config: ClassExtractionConfig): ClassExtrac
extractQualifiedName(node: SyntaxNode, simpleName: string): string | null {
return extract(node, { name: simpleName })?.qualifiedName ?? null;
},
shouldSkipClassCapture(context): boolean {
return config.shouldSkipClassCapture?.(context) ?? false;
},
extractTemplateArgumentsFromCapture(context): string[] | undefined {
return config.extractTemplateArgumentsFromCapture?.(context);
},
};
}

View file

@ -10,6 +10,13 @@ export interface ExtractedClassSymbol {
name: string;
type: ClassLikeNodeLabel;
qualifiedName: string;
templateArguments?: string[];
}
export interface ClassCaptureContext {
captureMap: Record<string, SyntaxNode>;
definitionNode: SyntaxNode | null;
nameNode: SyntaxNode | undefined;
}
/**
@ -30,6 +37,10 @@ export interface ClassExtractor {
},
): ExtractedClassSymbol | null;
extractQualifiedName(node: SyntaxNode, simpleName: string): string | null;
shouldSkipClassCapture?(
context: ClassCaptureContext & { nodeLabel: ClassLikeNodeLabel },
): boolean;
extractTemplateArgumentsFromCapture?(context: ClassCaptureContext): string[] | undefined;
}
export interface ClassExtractionConfig {
@ -41,4 +52,9 @@ export interface ClassExtractionConfig {
extractName?: (node: SyntaxNode) => string | undefined;
extractType?: (node: SyntaxNode) => ClassLikeNodeLabel | undefined;
extractScopeSegments?: (node: SyntaxNode) => string[] | null | undefined;
extractTemplateArguments?: (node: SyntaxNode) => string[] | undefined;
shouldSkipClassCapture?(
context: ClassCaptureContext & { nodeLabel: ClassLikeNodeLabel },
): boolean;
extractTemplateArgumentsFromCapture?(context: ClassCaptureContext): string[] | undefined;
}

View file

@ -72,6 +72,13 @@ export const resolveImportPath = (
const resolved = tryResolveWithExtensions(rewritten, allFiles);
if (resolved) return cache(resolved);
// ESM fallback: strip .js/.jsx/.mjs/.cjs and retry with TS equivalents
const strippedAlias = stripJsExtension(rewritten);
if (strippedAlias !== null) {
const esmResolved = tryResolveWithExtensions(strippedAlias, allFiles);
if (esmResolved) return cache(esmResolved);
}
// Try suffix matching as fallback
const parts = rewritten.split('/').filter(Boolean);
const suffixResult = suffixResolve(parts, normalizedFileList, allFileList, index);
@ -132,9 +139,6 @@ export const resolveImportPath = (
// TypeScript ESM: imports use .js/.jsx/.mjs/.cjs but source files are
// .ts/.tsx/.mts/.cts. Strip the JS-family extension and re-resolve.
// NOTE: This fallback only applies to relative imports. Path alias imports
// (e.g. @/utils.js via tsconfig paths) do not yet strip .js extensions —
// that is a known limitation tracked for follow-up.
if (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) {
const stripped = stripJsExtension(basePath);
if (stripped !== null) {

View file

@ -55,6 +55,15 @@ import {
cImportOwningScope,
cReceiverBinding,
} from './c/index.js';
import {
emitCppScopeCaptures,
interpretCppImport,
interpretCppTypeBinding,
cppArityCompatibility,
cppBindingScopeFor,
cppImportOwningScope,
cppReceiverBinding,
} from './cpp/index.js';
const C_BUILT_INS: ReadonlySet<string> = new Set([
'printf',
@ -447,4 +456,14 @@ export const cppProvider = defineLanguage({
heritageExtractor: createHeritageExtractor(SupportedLanguages.CPlusPlus),
labelOverride: cppLabelOverride,
builtInNames: C_BUILT_INS,
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
emitScopeCaptures: emitCppScopeCaptures,
interpretImport: interpretCppImport,
interpretTypeBinding: interpretCppTypeBinding,
bindingScopeFor: cppBindingScopeFor,
importOwningScope: cppImportOwningScope,
receiverBinding: cppReceiverBinding,
arityCompatibility: cppArityCompatibility,
// mergeBindings + resolveImportTarget live on ScopeResolver (see cpp/scope-resolver.ts).
});

View file

@ -0,0 +1,391 @@
/**
* C++ argument-dependent lookup (ADL / Koenig lookup).
*
* When ordinary unqualified lookup fails for a free-call site, ADL also
* considers candidates declared in the **associated namespaces** of the
* call's argument types (ISO C++ `[basic.lookup.argdep]`). The canonical
* pattern V1 unlocks:
*
* namespace audit { struct Event; void record(Event); }
* namespace app { void run() { audit::Event e; record(e); } }
*
* Without ADL: `record(e)` is unresolved because `app::run` doesn't
* `using` anything. With V1 ADL: `audit::record` is discovered via
* `audit::Event`'s associated namespace.
*
* ## Current boundary
*
* The current implementation covers class-typed arguments (value, pointer,
* and reference) and template specializations with explicit type arguments:
* - `audit::Event e`, `audit::Event* p`, `audit::Event** pp`
* - `audit::Event& r`, `audit::Event&& rr`
* - `std::vector<audit::Event>` (template namespace + template-arg namespaces)
*
* Function-pointer arguments and the rest of the full closure are still
* deliberately excluded. V2 additionally walks class ancestors (via MRO),
* so base-class enclosing namespaces also contribute associated namespaces.
*
* The current implementation also short-circuits to ADL only when ordinary lookup is empty
* (`findCallableBindingInScope` returned undefined). ISO C++ would
* normally merge ADL candidates with ordinary-lookup candidates and
* run overload resolution over the union; V1 defers that merge to V2.
*
* ## Parenthesized-name suppression
*
* `(f)(s)` MUST NOT trigger ADL — the parenthesized name forces ordinary
* lookup only. `captures.ts` records sites whose `function` child is a
* `parenthesized_expression` into `noAdlSites`; `pickCppAdlCandidates`
* short-circuits when the site key is present.
*
* ## State lifecycle
*
* Three module-level maps populated per pipeline invocation, cleared via
* `clearCppAdlState()` (called from `clearFileLocalNames`):
*
* - `argInfoBySite` — per-call-site argument shape (capture-time)
* - `noAdlSites` — call sites with parenthesized function (capture-time)
* - `classToNamespaceQualifiedName` — class def → its enclosing namespace
* qualified name (`populateCppAssociatedNamespaces` time)
*
* The class→namespace map uses qualified names (not scope IDs) because
* C++ namespaces are open: `namespace N { ... }` in file A and
* `namespace N { ... }` in file B produce two distinct Namespace scopes
* but logically share the same namespace. ADL must consider candidates
* declared in either file.
*/
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import {
isOverloadAmbiguousAfterNormalization,
narrowOverloadCandidates,
} from '../../scope-resolution/passes/overload-narrowing.js';
/**
* Per-argument shape information collected at capture time. ADL fires for
* arguments where `simpleClassName !== ''`, including class pointers and
* references whose declarator chain resolves to a named class type.
*/
export interface CppAdlArgInfo {
/** Simple class-like type name (last segment of qualified name); empty
* for primitives, literals, function pointers, etc. */
readonly simpleClassName: string;
/** Template's own simple class-like name (e.g. `vector` for
* `std::vector<N::T>`), empty when arg type is not a template spec. */
readonly templateSimpleClassName: string;
/** Template's own enclosing namespace (dot-qualified, e.g. `std`), empty
* when unavailable / unqualified. */
readonly templateNamespace: string;
/** Class-like names extracted from explicit type template arguments,
* recursively bounded. */
readonly templateArgClassNames: readonly string[];
/** Enclosing namespaces extracted from explicit type template arguments,
* recursively bounded. */
readonly templateArgNamespaces: readonly string[];
}
const argInfoBySite = new Map<string, readonly CppAdlArgInfo[]>();
const noAdlSites = new Set<string>();
const classToNamespaceQualifiedName = new Map<string, string>();
/** Sentinel returned by `pickCppAdlCandidates` when ADL surfaces multiple
* candidates that share normalized parameter types — the caller MUST
* suppress (zero edges) rather than pick arbitrarily. Mirrors the
* OVERLOAD_AMBIGUOUS contract from the receiver-bound path. */
export const ADL_AMBIGUOUS = Symbol('ADL_AMBIGUOUS');
export type AdlResult = SymbolDefinition | typeof ADL_AMBIGUOUS | undefined;
function siteKey(filePath: string, line: number, col: number): string {
return `${filePath}:${line}:${col}`;
}
/** Record per-call-site argument info. Called once per call site from
* `emitCppScopeCaptures`. */
export function markCppAdlSiteArgs(
filePath: string,
line: number,
col: number,
args: readonly CppAdlArgInfo[],
): void {
argInfoBySite.set(siteKey(filePath, line, col), args);
}
/** Mark a call site as ADL-suppressed (function child wrapped in
* `parenthesized_expression`, e.g. `(f)(s)`). */
export function markCppAdlSiteNoAdl(filePath: string, line: number, col: number): void {
noAdlSites.add(siteKey(filePath, line, col));
}
/** Clear ADL state. Called from `clearFileLocalNames` so all C++ resolver
* per-pipeline state is reset together. */
export function clearCppAdlState(): void {
argInfoBySite.clear();
noAdlSites.clear();
classToNamespaceQualifiedName.clear();
}
/**
* Walk `parsed.scopes` to record each Class def's enclosing namespace
* qualified name. Run from the cpp resolver's `populateOwners` hook so
* the index is available before any resolution pass consults it.
*
* Computes the namespace's qualified name by walking parent scope chain
* and looking up Namespace defs in each parent's `ownedDefs`. The
* resulting name is dot-joined (matching `populateClassOwnedMembers`'s
* dotted convention; conversion to `::` is consumer-internal).
*/
export function populateCppAssociatedNamespaces(parsed: ParsedFile): void {
const scopesById = new Map<ScopeId, (typeof parsed.scopes)[number]>();
for (const scope of parsed.scopes) scopesById.set(scope.id, scope);
for (const scope of parsed.scopes) {
if (scope.kind !== 'Class') continue;
const nsQName = computeEnclosingNamespaceQName(scope, scopesById);
if (nsQName === '') continue;
for (const def of scope.ownedDefs) {
if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
classToNamespaceQualifiedName.set(def.nodeId, nsQName);
}
}
}
/**
* V1 ADL candidate picker. Returns:
* - `SymbolDefinition` — exactly one ADL candidate (or unique survivor
* after narrowing); caller emits the CALLS edge.
* - `ADL_AMBIGUOUS` — multiple candidates with no disambiguator;
* caller MUST suppress (zero edges).
* - `undefined` — no ADL candidates; caller falls through to ordinary
* `pickUniqueGlobalCallable` fallback.
*
* Fires only when:
* - the call site is not in `noAdlSites` (parenthesized form), AND
* - at least one argument resolves to a named class type (value,
* pointer, or reference; but not function pointer, literal, or primitive).
*/
export function pickCppAdlCandidates(
site: {
readonly name: string;
readonly arity?: number;
readonly argumentTypes?: readonly string[];
readonly atRange: { startLine: number; startCol: number };
},
callerParsed: ParsedFile,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
): AdlResult {
const key = siteKey(callerParsed.filePath, site.atRange.startLine, site.atRange.startCol);
if (noAdlSites.has(key)) return undefined;
const args = argInfoBySite.get(key);
if (args === undefined || args.length === 0) return undefined;
// Collect associated namespace QNames from every participating class-typed arg.
const associatedNamespaces = new Set<string>();
for (const arg of args) {
collectAssociatedNamespacesForAdlArg(arg, scopes, associatedNamespaces);
}
if (associatedNamespaces.size === 0) return undefined;
// Walk every namespace scope in every parsed file; collect callable
// ownedDefs whose enclosing namespace matches one of the associated
// QNames AND whose simple name matches the call's name.
const candidates: SymbolDefinition[] = [];
const seenKey = new Set<string>();
for (const parsed of parsedFiles) {
const scopesById = new Map<ScopeId, (typeof parsed.scopes)[number]>();
for (const sc of parsed.scopes) scopesById.set(sc.id, sc);
for (const scope of parsed.scopes) {
if (scope.kind !== 'Namespace') continue;
const qName = computeNamespaceQName(scope, scopesById);
if (!associatedNamespaces.has(qName)) continue;
for (const def of scope.ownedDefs) {
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') {
continue;
}
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
if (simple !== site.name) continue;
// Dedup by nodeId — using normalized parameter-types as the key
// would collapse `process(int)`/`process(long)`-style overloads
// (both normalize to `['int']`) before
// `isOverloadAmbiguousAfterNormalization` can detect them.
if (seenKey.has(def.nodeId)) continue;
seenKey.add(def.nodeId);
candidates.push(def);
}
}
}
if (candidates.length === 0) return undefined;
if (candidates.length === 1) return candidates[0];
// Multi-candidate: narrow then check ambiguity. Reuses the OVERLOAD_AMBIGUOUS
// sentinel contract from `overload-narrowing.ts` so int/long-collision-style
// ambiguity also suppresses on the ADL path.
const narrowed = narrowOverloadCandidates(candidates, site.arity, site.argumentTypes);
if (narrowed.length === 1) return narrowed[0];
if (narrowed.length === 0) return undefined;
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) return ADL_AMBIGUOUS;
// Multiple surviving candidates that aren't normalization-ambiguous —
// ISO C++ would run overload resolution; V1 lacks conversion ranking so
// suppress rather than pick arbitrarily. Mirrors `pickImplicitThisOverload`'s
// unique-survivor requirement (see `pick-implicit-this-overload.test.ts`).
return ADL_AMBIGUOUS;
}
function collectAssociatedNamespacesForAdlArg(
arg: CppAdlArgInfo,
scopes: ScopeResolutionIndexes,
associatedNamespaces: Set<string>,
): void {
// For template args this may be the template name itself (e.g. `vector`);
// simple-name lookup can match project classes with the same name (known
// V1/V2 simplification).
addAssociatedNamespaceForClassName(arg.simpleClassName, scopes, associatedNamespaces);
// Includes template-owner namespaces (e.g. `std` in std::vector<T>). If
// that surfaces extra candidates, ADL_AMBIGUOUS suppression below prevents
// arbitrary edge emission.
if (arg.templateNamespace.length > 0) associatedNamespaces.add(arg.templateNamespace);
for (const ns of arg.templateArgNamespaces) {
if (ns.length > 0) associatedNamespaces.add(ns);
}
for (const className of arg.templateArgClassNames) {
addAssociatedNamespaceForClassName(className, scopes, associatedNamespaces);
}
}
function addAssociatedNamespaceForClassName(
simpleClassName: string,
scopes: ScopeResolutionIndexes,
associatedNamespaces: Set<string>,
): void {
if (simpleClassName.length === 0) return;
const classLookup = findCppClassDefBySimpleName(simpleClassName, scopes);
if (classLookup === undefined) return;
const { classDef, ambiguous } = classLookup;
const nsQName = classToNamespaceQualifiedName.get(classDef.nodeId);
if (nsQName !== undefined) associatedNamespaces.add(nsQName);
// Preserve V1 collision behavior for the direct class namespace, but avoid
// amplifying a same-simple-name collision by walking an arbitrary class's
// full MRO chain.
if (ambiguous) return;
for (const ancestorDefId of scopes.methodDispatch.mroFor(classDef.nodeId)) {
const ancestorNsQName = classToNamespaceQualifiedName.get(ancestorDefId);
if (ancestorNsQName !== undefined) associatedNamespaces.add(ancestorNsQName);
}
}
/** Walk upward from a Class scope, finding the innermost enclosing
* Namespace scope, and return that namespace's qualified name (dot-
* joined, outermost-first). Returns '' when the class has no enclosing
* namespace (e.g., declared at translation-unit scope). */
function computeEnclosingNamespaceQName(
classScope: { readonly parent: ScopeId | null },
scopesById: ReadonlyMap<
ScopeId,
{
readonly parent: ScopeId | null;
readonly kind: string;
readonly ownedDefs: readonly SymbolDefinition[];
}
>,
): string {
let parentId: ScopeId | null = classScope.parent;
while (parentId !== null) {
const parent = scopesById.get(parentId);
if (parent === undefined) return '';
if (parent.kind === 'Namespace') {
return computeNamespaceQName(parent, scopesById);
}
parentId = parent.parent;
}
return '';
}
/** Walk upward from a Namespace scope collecting each enclosing
* Namespace's simple name (innermost last). Returns the dot-joined
* qualified name (e.g., `outer.inner`). The namespace's own def lives
* in its OWN scope's `ownedDefs` (the C++ extractor stamps the
* namespace-decl def into the namespace scope itself, not the parent
* module scope). */
function computeNamespaceQName(
nsScope: { readonly parent: ScopeId | null; readonly ownedDefs: readonly SymbolDefinition[] },
scopesById: ReadonlyMap<
ScopeId,
{
readonly parent: ScopeId | null;
readonly kind: string;
readonly ownedDefs: readonly SymbolDefinition[];
}
>,
): string {
const segments: string[] = [];
let currentId: ScopeId | null = nsScope.parent;
let current:
| { readonly parent: ScopeId | null; readonly ownedDefs: readonly SymbolDefinition[] }
| undefined = nsScope;
// Outer guard against pathological cycles in malformed scope trees.
let safety = 64;
while (current !== undefined && safety-- > 0) {
const nsDef = findNamespaceDefInScope(current);
if (nsDef === undefined) {
// No name found — bail out. Returning a partial QName would risk
// false ADL associations.
return '';
}
const simple = nsDef.qualifiedName?.split('.').pop() ?? nsDef.qualifiedName ?? '';
segments.unshift(simple);
// Walk up to next enclosing namespace (skipping non-namespace parents).
let nextId: ScopeId | null = currentId;
let nextNs: typeof current | undefined;
while (nextId !== null) {
const nx = scopesById.get(nextId);
if (nx === undefined) break;
if (nx.kind === 'Namespace') {
nextNs = nx;
currentId = nx.parent;
break;
}
nextId = nx.parent;
}
current = nextNs;
}
return segments.join('.');
}
/** Find the Namespace def attached to this scope (the namespace's own
* decl, stamped into its own `ownedDefs` by the C++ extractor). Returns
* the first Namespace-type def encountered — for normal C++ the scope
* carries exactly one Namespace-typed self def. */
function findNamespaceDefInScope(scope: {
readonly ownedDefs: readonly SymbolDefinition[];
}): SymbolDefinition | undefined {
for (const def of scope.ownedDefs) {
if (def.type === 'Namespace') return def;
}
return undefined;
}
/** Find a class-like def by simple name across the workspace. V1
* still arbitrary-picks the first class on collisions (multiple classes
* share the simple name), but reports the collision so callers can avoid
* amplifying that uncertainty (for example by skipping MRO expansion).
* C++ ADL strictness would require full type-driven lookup. */
function findCppClassDefBySimpleName(
simpleName: string,
scopes: ScopeResolutionIndexes,
): { classDef: SymbolDefinition; ambiguous: boolean } | undefined {
let firstMatch: SymbolDefinition | undefined;
for (const def of scopes.defs.byId.values()) {
if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
if (simple !== simpleName) continue;
if (firstMatch === undefined) {
firstMatch = def;
continue;
}
return { classDef: firstMatch, ambiguous: true };
}
if (firstMatch === undefined) return undefined;
return { classDef: firstMatch, ambiguous: false };
}

View file

@ -0,0 +1,185 @@
import type { SyntaxNode } from '../../utils/ast-helpers.js';
export interface CppArityInfo {
parameterCount?: number;
requiredParameterCount?: number;
parameterTypes?: string[];
}
/**
* Compute declaration arity from a C++ function definition or declaration node.
* Extends the C arity computation with support for:
* - optional_parameter_declaration (default parameters)
* - variadic_parameter_declaration / parameter packs
* - (void) explicit zero-parameter form
*/
export function computeCppDeclarationArity(node: SyntaxNode): CppArityInfo {
const funcDecl = findFuncDeclarator(node);
if (funcDecl === null) return {};
const paramList = funcDecl.childForFieldName('parameters');
if (paramList === null) return {};
const params: SyntaxNode[] = [];
// Track whether a C-style variadic `...` anonymous token appears.
// tree-sitter-cpp emits `...` as an anonymous (non-named) child of
// parameter_list, not as `variadic_parameter`.
let hasEllipsis = false;
for (let i = 0; i < paramList.childCount; i++) {
const child = paramList.child(i);
if (child === null) continue;
if (
child.type === 'parameter_declaration' ||
child.type === 'optional_parameter_declaration' ||
child.type === 'variadic_parameter' ||
child.type === 'variadic_parameter_declaration'
) {
params.push(child);
} else if (child.type === '...' || (!child.isNamed && child.text === '...')) {
hasEllipsis = true;
}
}
// Empty parameter list: C++ `void foo()` means zero params (unlike C)
if (params.length === 0 && !hasEllipsis) {
return { parameterCount: 0, requiredParameterCount: 0, parameterTypes: [] };
}
// (void) means zero parameters
if (params.length === 1 && params[0].type === 'parameter_declaration') {
const typeNode = params[0].childForFieldName('type');
const hasDeclarator = params[0].childForFieldName('declarator') !== null;
if (typeNode !== null && typeNode.text === 'void' && !hasDeclarator) {
return { parameterCount: 0, requiredParameterCount: 0, parameterTypes: [] };
}
}
// C-style variadic: `void foo(int x, ...)` — the `...` is an anonymous
// token in tree-sitter-cpp, detected via `hasEllipsis` above.
// C++ parameter packs: `template<typename... Ts> void foo(Ts... args)` —
// detected as `variadic_parameter_declaration`.
const isVariadic =
hasEllipsis ||
params.some(
(p) => p.type === 'variadic_parameter' || p.type === 'variadic_parameter_declaration',
);
const optionalCount = params.filter((p) => p.type === 'optional_parameter_declaration').length;
const requiredCount = params.filter(
(p) =>
p.type === 'parameter_declaration' ||
// variadic_parameter_declaration with a name is a parameter pack — counts as one
p.type === 'variadic_parameter_declaration',
).length;
const totalNonVariadic = requiredCount + optionalCount;
const types: string[] = [];
for (const p of params) {
if (p.type === 'variadic_parameter') {
types.push('...');
} else if (p.type === 'variadic_parameter_declaration') {
// Parameter pack: treated as variadic
types.push('...');
} else {
const typeNode = p.childForFieldName('type');
types.push(normalizeCppParamType(typeNode?.text ?? 'unknown'));
}
}
// Append '...' for C-style variadic if not already in types
if (hasEllipsis && !types.includes('...')) {
types.push('...');
}
return {
parameterCount: isVariadic ? undefined : totalNonVariadic,
requiredParameterCount: requiredCount,
parameterTypes: types,
};
}
/**
* Compute call-site arity from a call_expression node.
*/
export function computeCppCallArity(node: SyntaxNode): number {
const argList = node.childForFieldName('arguments');
if (argList === null) return 0;
let count = 0;
for (let i = 0; i < argList.childCount; i++) {
const child = argList.child(i);
if (child === null) continue;
if (child.type !== ',' && child.type !== '(' && child.type !== ')') {
count++;
}
}
return count;
}
/**
* Normalize a C++ parameter type for overload disambiguation.
* Maps common qualified/aliased types to their canonical short forms
* so that `narrowOverloadCandidates` can match against literal-inferred
* argument types (e.g. `inferCppLiteralType` returns `'string'` for
* string literals, not `'std::string'`).
*/
function normalizeCppParamType(raw: string): string {
let t = raw.trim();
// Strip const, volatile, etc.
t = t.replace(/\b(const|volatile|restrict|mutable|constexpr)\b/g, '').trim();
// Strip reference/pointer markers
t = t.replace(/[&*]+\s*$/, '').trim();
// Strip template parameters (loop handles nested: Map<List<int>> → Map)
while (t.includes('<')) {
const stripped = t.replace(/<[^<>]*>/g, '');
if (stripped === t) break; // avoid infinite loop on malformed input
t = stripped;
}
t = t.trim();
// Map std:: types to canonical short forms
const STD_MAP: Record<string, string> = {
'std::string': 'string',
'std::wstring': 'string',
'std::string_view': 'string',
string: 'string',
char: 'char',
int: 'int',
long: 'int',
short: 'int',
unsigned: 'int',
'unsigned int': 'int',
'long long': 'int',
size_t: 'int',
'std::size_t': 'int',
float: 'double',
double: 'double',
bool: 'bool',
nullptr_t: 'null',
'std::nullptr_t': 'null',
};
return STD_MAP[t] ?? t;
}
function findFuncDeclarator(node: SyntaxNode): SyntaxNode | null {
let decl = node.childForFieldName('declarator');
if (decl === null) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'function_declarator') return c;
}
return null;
}
// Unwrap pointer_declarator / reference_declarator
while (decl.type === 'pointer_declarator' || decl.type === 'reference_declarator') {
const next = decl.childForFieldName('declarator');
if (next === null) {
// reference_declarator may not use field name
for (let i = 0; i < decl.childCount; i++) {
const c = decl.child(i);
if (c?.type === 'function_declarator') return c;
}
break;
}
decl = next;
}
if (decl.type === 'function_declarator') return decl;
return null;
}

View file

@ -0,0 +1,35 @@
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
/**
* C++ arity compatibility: supports overloading and default parameters.
*
* Unlike C (no overloading, exact match only), C++ has:
* - Overloaded functions (same name, different signatures)
* - Default parameters (requiredParameterCount < parameterCount)
* - Variadic functions (C-style `...`)
* - Parameter packs (V1: treated as variadic)
* - Templates (V1: generic-ignored, arity check on non-template params)
*
* Verdict:
* - 'compatible': callsite.arity fits within [required, total] range
* - 'incompatible': callsite.arity is outside the valid range
* - 'unknown': insufficient metadata to determine
*/
export function cppArityCompatibility(
def: SymbolDefinition,
callsite: Callsite,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown';
const variadic = def.parameterTypes?.some((t) => t === '...') ?? false;
// Too few arguments: less than the minimum required
if (min !== undefined && callsite.arity < min) return 'incompatible';
// Too many arguments: more than the maximum and not variadic
if (max !== undefined && callsite.arity > max && !variadic) return 'incompatible';
return 'compatible';
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,302 @@
import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import { isCppInlineNamespaceScope } from './inline-namespaces.js';
/**
* Per-file set of symbol names with file-local linkage.
* In C++ there are two sources of file-local linkage:
* 1. `static` storage class (same as C)
* 2. Anonymous namespace (`namespace { ... }`)
*
* Populated during `emitCppScopeCaptures` and consumed by
* `expandCppWildcardNames` to exclude file-local symbols from
* cross-file wildcard import visibility.
*
* NOTE: module-level state, single-process-single-repo use only.
* Call `clearFileLocalNames()` at the start of each resolution pass.
*
* Key: filePath, Value: Set of file-local symbol names.
*/
const fileLocalNames = new Map<string, Set<string>>();
/**
* Per-file set of `SymbolDefinition.nodeId`s that are NOT visible by
* unqualified lookup from outside the file — class-owned methods/fields
* and namespace-nested symbols. Populated by `populateCppNonGloballyVisible`
* during the per-file `populateOwners` hook; consumed by
* `isCppDefGloballyVisible` from both `expandCppWildcardNames` (wildcard
* propagation) and the global free-call fallback's `isFileLocalDef` hook.
*
* Tracked per filePath rather than as a single global set so cross-file
* lookup correctly compares the candidate's owning file's non-visible
* set without leaking across pipeline invocations (the global free-call
* fallback checks `def.filePath !== callerFilePath` and then asks "is
* this def visible from outside its own file?" — that's exactly what
* this set encodes).
*/
const nonGloballyVisibleNodeIds = new Map<string, Set<string>>();
/**
* Per-file set of source-range keys identifying `namespace { ... }` blocks.
* Resolved to `ScopeId`s in `populateCppAnonymousNamespaceScopes` and
* consumed via `isCppAnonymousNamespaceScope`.
*
* Anonymous namespaces have file-local linkage but, unlike `static`, their
* members propagate to any TU that `#include`s the declaring file — each
* including TU gets its own internal-linkage copy. So for wildcard import
* expansion (`expandCppWildcardNames`) we treat anonymous-namespace owned
* defs as if declared at the enclosing scope. Cross-file unqualified
* lookup that does NOT go through `#include` is still blocked by the
* `isFileLocal` mark recorded on the def's name.
*/
const anonymousNamespaceRangesByFile = new Map<string, Set<string>>();
const anonymousNamespaceScopeIds = new Set<ScopeId>();
interface RangeKeyShape {
readonly startLine: number;
readonly startCol: number;
readonly endLine: number;
readonly endCol: number;
}
function rangeKey(r: RangeKeyShape): string {
return `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`;
}
/** Record a symbol name as file-local (static or anonymous namespace). */
export function markFileLocal(filePath: string, name: string): void {
let names = fileLocalNames.get(filePath);
if (names === undefined) {
names = new Set<string>();
fileLocalNames.set(filePath, names);
}
names.add(name);
}
/** Check whether a symbol name has file-local linkage in the given file. */
export function isFileLocal(filePath: string, name: string): boolean {
return fileLocalNames.get(filePath)?.has(name) ?? false;
}
/** Capture-time: record an anonymous `namespace_definition` source range. */
export function markCppAnonymousNamespaceRange(filePath: string, range: RangeKeyShape): void {
let set = anonymousNamespaceRangesByFile.get(filePath);
if (set === undefined) {
set = new Set();
anonymousNamespaceRangesByFile.set(filePath, set);
}
set.add(rangeKey(range));
}
/** Predicate consumed by `populateCppNonGloballyVisible` and
* `expandCppWildcardNames` to exempt anonymous-namespace scopes from
* the cross-file unqualified-lookup exclusion that applies to ordinary
* named namespaces. */
export function isCppAnonymousNamespaceScope(scopeId: ScopeId): boolean {
return anonymousNamespaceScopeIds.has(scopeId);
}
/** Clear tracked file-local names (call at start of each resolution pass). */
export function clearFileLocalNames(): void {
fileLocalNames.clear();
nonGloballyVisibleNodeIds.clear();
anonymousNamespaceRangesByFile.clear();
anonymousNamespaceScopeIds.clear();
}
/** Resolve recorded anonymous-namespace source ranges to `ScopeId`s.
* Must run inside `populateOwners` BEFORE `populateCppNonGloballyVisible`
* consults the resolved set. */
export function populateCppAnonymousNamespaceScopes(parsed: {
readonly filePath: string;
readonly scopes: readonly {
readonly id: ScopeId;
readonly kind: string;
readonly range: RangeKeyShape;
}[];
}): void {
const ranges = anonymousNamespaceRangesByFile.get(parsed.filePath);
if (ranges === undefined || ranges.size === 0) return;
for (const scope of parsed.scopes) {
if (scope.kind !== 'Namespace') continue;
if (ranges.has(rangeKey(scope.range))) {
anonymousNamespaceScopeIds.add(scope.id);
}
}
}
/**
* Populate per-file "not globally visible" nodeIds by walking the parsed
* file's scopes. Run as part of the `populateOwners` hook so every C++
* scope is reflected before any cross-file resolution pass consults the
* set.
*
* A def is "not globally visible" when its nearest structurally enclosing
* scope is a `Namespace` or `Class` — those require qualification
* (`ns::name`, `Class::method`) for cross-file unqualified lookup.
* Module-scoped defs remain globally visible.
*/
export function populateCppNonGloballyVisible(parsed: {
readonly filePath: string;
readonly scopes: readonly {
readonly id: ScopeId;
readonly kind: string;
readonly ownedDefs: readonly { readonly nodeId: string }[];
}[];
}): void {
let set = nonGloballyVisibleNodeIds.get(parsed.filePath);
if (set === undefined) {
set = new Set<string>();
nonGloballyVisibleNodeIds.set(parsed.filePath, set);
}
for (const scope of parsed.scopes) {
if (scope.kind !== 'Namespace' && scope.kind !== 'Class') continue;
// Inline namespaces (`inline namespace v1 { ... }`) propagate their
// members to the enclosing namespace's unqualified-lookup scope per
// ISO C++ `[namespace.def]/p4`. Skip them here so cross-file
// unqualified lookup can still see their callable defs.
if (scope.kind === 'Namespace' && isCppInlineNamespaceScope(scope.id)) continue;
// Anonymous namespaces give internal linkage but their contents are
// visible at the enclosing scope within the same TU and propagate to
// any TU that `#include`s the declaring file. The `isFileLocal` mark
// (recorded on the def's name in this file) still blocks cross-file
// unqualified lookup that does not go through #include, so dropping
// the structural visibility exclusion here is safe.
if (scope.kind === 'Namespace' && anonymousNamespaceScopeIds.has(scope.id)) continue;
for (const def of scope.ownedDefs) {
set.add(def.nodeId);
}
}
}
/**
* Check whether a def is visible by unqualified lookup from outside its
* own file. Returns `false` for class-owned and namespace-nested defs.
*
* Used by the global free-call fallback's `isFileLocalDef` hook (which
* historically meant "static / anonymous-namespace" but semantically
* stands for "logically invisible cross-file"). Including class methods
* and namespace members under the same negative answer fixes the leak
* where unqualified `save()` resolved to `User::save` through a shared
* workspace registry walk.
*/
export function isCppDefGloballyVisible(filePath: string, nodeId: string): boolean {
return nonGloballyVisibleNodeIds.get(filePath)?.has(nodeId) !== true;
}
/**
* Return the names visible through a C++ wildcard import (`#include` or
* `using namespace`).
*
* ## Contract
*
* C++ unqualified name lookup only sees names at the importer's enclosing
* scope. Class members and namespace-nested symbols are NOT visible by
* unqualified lookup from a free function in an including TU — they must
* be reached via `Class::method`, `ns::name`, or a working `using`
* declaration. The filter below enforces that contract for header
* propagation: only defs whose nearest enclosing scope is the header's
* `Module` scope are emitted as wildcard-binding names.
*
* ## Why scope-aware and not predicate-on-qualifiedName
*
* A naive `def.qualifiedName.indexOf('.') === -1` check is unreliable
* because `populateClassOwnedMembers`
* (`gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts`)
* only dot-qualifies `qualifiedName` for `Class` scopes. Namespace-nested
* defs (`namespace ns { void foo(); }`) arrive in `localDefs` with
* `qualifiedName === 'foo'` and `ownerId === undefined`, indistinguishable
* from a top-level free function. The structural truth lives in
* `Scope.ownedDefs`: each scope lists what it structurally owns; the
* Module scope owns only top-level symbols. We look the def up by
* `nodeId` against the scope tree to identify its owning kind.
*
* ## `localDefs` consumer survey (recorded for future maintainers)
*
* Other consumers of `ParsedFile.localDefs` were audited at the time
* this filter was introduced (see PR #1520 / plan
* `docs/plans/2026-05-12-002-fix-cpp-resolver-followups-plan.md`):
*
* - `finalize-orchestrator.ts:113,163` — flattens defs into a workspace
* registry keyed by `ownerId` + `qualifiedName`; class-owned and
* namespace-owned symbols are registered under their owner, not as
* unqualified names. Not a leak surface.
* - `csharp/namespace-siblings.ts:307`, `go/expand-wildcards.ts:86`,
* `php/scope-resolver.ts:141,151`, `c/static-linkage.ts:51` — other
* languages' own wildcard / sibling expansions. Each owns its own
* visibility contract.
* - `receiver-bound-calls.ts:99`, `reconcile-ownership.ts:66,119`,
* `mro.ts:61` — keyed by `ownerId` for member lookup, never used
* as unqualified bindings.
* - `go/interface-impls.ts:40,53`, `go/package-siblings.ts:41` — Go-
* specific, sibling-package scoped.
*
* No other consumer treats `localDefs` as a flat unqualified-binding
* set the way this function did before the fix. If a future consumer
* does, mirror this filter or harden registration so class/namespace
* members never enter `localDefs` unqualified.
*/
export function expandCppWildcardNames(
targetModuleScope: ScopeId,
parsedFiles: readonly ParsedFile[],
): readonly string[] {
const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope);
if (target === undefined) return [];
// Build nodeId → owning Scope map from the structural scope tree.
// `Scope.ownedDefs` is the canonical source of structural ownership;
// `localDefs` is its flattened union, which is why the original code
// leaked: walking only `localDefs` discards the owning-scope context.
const ownerScopeByNodeId = new Map<string, Scope>();
for (const scope of target.scopes) {
for (const ownedDef of scope.ownedDefs) {
ownerScopeByNodeId.set(ownedDef.nodeId, scope);
}
}
const seen = new Set<string>();
const names: string[] = [];
for (const def of target.localDefs) {
// Defense-in-depth: class methods carry a non-undefined ownerId after
// `populateClassOwnedMembers` runs. Skip them outright.
if (def.ownerId !== undefined) continue;
// Structural visibility check: exclude defs whose owning scope is a
// Namespace or Class — these require qualification (`ns::name`,
// `Class::method`) and are NOT reachable by unqualified lookup in an
// including TU. When the owning scope is unknown we default to
// include (preserves prior behavior for any def whose structural
// ownership wasn't recorded in `Scope.ownedDefs`).
//
// Anonymous namespaces are exempt: their members propagate to the
// enclosing scope of any TU that #includes the declaring file (each
// including TU gets its own internal-linkage copy per ISO C++).
const ownerScope = ownerScopeByNodeId.get(def.nodeId);
const ownerIsAnonymousNamespace =
ownerScope !== undefined &&
ownerScope.kind === 'Namespace' &&
anonymousNamespaceScopeIds.has(ownerScope.id);
if (
ownerScope !== undefined &&
!ownerIsAnonymousNamespace &&
(ownerScope.kind === 'Namespace' || ownerScope.kind === 'Class')
) {
continue;
}
const name = simpleName(def);
if (name === '') continue;
// Same exemption for the `isFileLocal` mark — anonymous-namespace
// names are recorded as file-local to suppress the global free-call
// fallback's cross-file leak, but they MUST still propagate through
// wildcard import expansion to including TUs.
if (!ownerIsAnonymousNamespace && isFileLocal(target.filePath, name)) continue;
if (seen.has(name)) continue;
seen.add(name);
names.push(name);
}
return names;
}
function simpleName(def: SymbolDefinition): string {
return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
}

View file

@ -0,0 +1,53 @@
import { readdirSync, type Dirent } from 'fs';
import { join, relative } from 'path';
/** C++ header extensions to scan for in the workspace. */
const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh']);
/**
* Walk `repoPath` recursively and return relative paths of all C++ header files.
* Used by `loadResolutionConfig` so the C++ resolver can resolve `#include`
* targets that live in header files.
*
* Scans for: .h, .hpp, .hxx, .hh
*/
export function scanCppHeaderFiles(repoPath: string): ReadonlySet<string> {
const headers = new Set<string>();
walk(repoPath, repoPath, headers);
return headers;
}
function walk(dir: string, root: string, out: Set<string>): void {
let entries: Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' });
} catch {
return; // permission denied, etc.
}
for (const entry of entries) {
const name = entry.name;
const full = join(dir, name);
if (entry.isDirectory()) {
if (
name === 'node_modules' ||
name === '.git' ||
name === 'vendor' ||
name === 'dist' ||
name === 'build' ||
name === 'out' ||
name === 'target' ||
name === '_build' ||
name === '.next' ||
name.startsWith('cmake-build')
) {
continue;
}
walk(full, root, out);
} else if (entry.isFile()) {
const ext = name.slice(name.lastIndexOf('.'));
if (HEADER_EXTENSIONS.has(ext)) {
out.add(relative(root, full).replace(/\\/g, '/'));
}
}
}
}

View file

@ -0,0 +1,120 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
/**
* Decompose a `preproc_include` node into a CaptureMatch with structured
* import captures. C++ #include maps to a wildcard import (all symbols
* from the header are visible). Identical to C's splitCInclude.
*/
export function splitCppInclude(node: SyntaxNode): CaptureMatch | null {
const pathNode = node.childForFieldName?.('path') ?? null;
if (pathNode === null) {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child === null) continue;
if (child.type === 'string_literal' || child.type === 'system_lib_string') {
return buildIncludeCapture(node, child);
}
}
return null;
}
return buildIncludeCapture(node, pathNode);
}
function buildIncludeCapture(node: SyntaxNode, pathNode: SyntaxNode): CaptureMatch {
let raw: string;
if (pathNode.type === 'string_literal') {
const content = pathNode.namedChildren.find((c) => c.type === 'string_content');
raw = content?.text ?? pathNode.text.replace(/^"|"$/g, '');
} else {
raw = pathNode.text;
if (raw.startsWith('<') && raw.endsWith('>')) {
raw = raw.slice(1, -1);
}
}
const isSystem = pathNode.type === 'system_lib_string';
const result: Record<string, Capture> = {
'@import.statement': nodeToCapture('@import.statement', node),
'@import.kind': syntheticCapture('@import.kind', node, 'wildcard'),
'@import.source': syntheticCapture('@import.source', node, raw),
};
if (isSystem) {
result['@import.system'] = syntheticCapture('@import.system', node, 'true');
}
return result;
}
/**
* Decompose a `using_declaration` node into a CaptureMatch.
*
* tree-sitter-cpp produces:
* using namespace std; → using_declaration { "using", "namespace", identifier("std"), ";" }
* using std::vector; → using_declaration { "using", qualified_identifier("std::vector"), ";" }
*
* The first form is a wildcard import (all names from namespace).
* The second form is a named import (single symbol).
*/
export function splitCppUsingDecl(node: SyntaxNode): CaptureMatch | null {
if (node.type !== 'using_declaration') return null;
// Check for "namespace" keyword among anonymous children
let hasNamespaceKeyword = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child !== null && !child.isNamed && child.text === 'namespace') {
hasNamespaceKeyword = true;
break;
}
}
if (hasNamespaceKeyword) {
// using namespace <name>;
// The namespace name can be an identifier or qualified_identifier
let namespaceName: string | null = null;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child === null) continue;
if (child.type === 'identifier' || child.type === 'qualified_identifier') {
namespaceName = child.text;
break;
}
}
if (namespaceName === null) return null;
return {
'@import.statement': nodeToCapture('@import.statement', node),
'@import.kind': syntheticCapture('@import.kind', node, 'wildcard'),
'@import.source': syntheticCapture('@import.source', node, namespaceName),
'@import.using-namespace': syntheticCapture('@import.using-namespace', node, 'true'),
};
}
// using <qualified_identifier>; (e.g. using std::vector)
let qualId: SyntaxNode | null = null;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child !== null && child.type === 'qualified_identifier') {
qualId = child;
break;
}
}
if (qualId === null) return null;
// Extract the imported name (last identifier) and source (namespace part)
const nameNode = qualId.childForFieldName?.('name') ?? null;
const scopeNode = qualId.childForFieldName?.('scope') ?? null;
const importedName = nameNode?.text ?? qualId.text.split('::').pop() ?? '';
const source = scopeNode?.text ?? qualId.text.replace(new RegExp('::' + importedName + '$'), '');
return {
'@import.statement': nodeToCapture('@import.statement', node),
'@import.kind': syntheticCapture('@import.kind', node, 'named'),
'@import.source': syntheticCapture('@import.source', node, source),
'@import.name': syntheticCapture('@import.name', node, importedName),
};
}

View file

@ -0,0 +1,18 @@
import { resolveCImportTarget } from '../c/import-target.js';
/**
* Resolve a C++ #include path to a file in the workspace.
* C++ #include path resolution is identical to C:
* 1. Same-directory sibling (relative lookup)
* 2. Exact match
* 3. Suffix match with depth + lexicographic tiebreak
*
* Re-exports the C implementation since the #include semantics are shared.
*/
export function resolveCppImportTarget(
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
): string | null {
return resolveCImportTarget(targetRaw, fromFile, allFilePaths);
}

View file

@ -0,0 +1,16 @@
/**
* C++ scope-resolution hooks (RFC #909 Ring 3).
*/
export { emitCppScopeCaptures } from './captures.js';
export { interpretCppImport, interpretCppTypeBinding, normalizeCppTypeName } from './interpret.js';
export { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js';
export { cppArityCompatibility } from './arity.js';
export { cppMergeBindings } from './merge-bindings.js';
export { cppBindingScopeFor, cppImportOwningScope, cppReceiverBinding } from './simple-hooks.js';
export { resolveCppImportTarget } from './import-target.js';
export {
markFileLocal,
isFileLocal,
clearFileLocalNames,
expandCppWildcardNames,
} from './file-local-linkage.js';

View file

@ -0,0 +1,170 @@
/**
* C++ inline namespace support (U5 of plan 2026-05-13-001).
*
* `inline namespace v1 { void foo(); }` has two ISO C++ semantics that
* GitNexus must model:
*
* 1. **Transitive unqualified visibility.** Names declared in an inline
* namespace are reachable by unqualified lookup from the enclosing
* namespace's scope, as if they were declared directly there.
* `populateCppNonGloballyVisible` (file-local-linkage.ts) treats
* inline-namespace members as globally visible for cross-file
* unqualified lookup.
*
* 2. **Transitive qualified visibility.** `outer::foo()` resolves to
* `outer::v1::foo()` when `v1` is inline. The qualified-namespace
* receiver resolver (`resolveCppQualifiedNamespaceMember`) walks
* inline-namespace children transitively when collecting candidates.
*
* State lifecycle: capture-time `markCppInlineNamespaceRange` records each
* inline namespace's source range; `populateCppInlineNamespaceScopes`
* resolves ranges to `ScopeId`s during `populateOwners`. Cleared via
* `clearCppInlineNamespaces`, called from `clearFileLocalNames`.
*
* STL idiom this enables: `std::__1::vector` (libc++) and `std::__cxx11`
* (libstdc++) are inline namespaces of `std`. With this support,
* `std::vector` qualified calls resolve to the inline-namespace
* declaration transparently.
*/
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
interface RangeKey {
readonly startLine: number;
readonly startCol: number;
readonly endLine: number;
readonly endCol: number;
}
const inlineNamespaceRangesByFile = new Map<string, Set<string>>();
const inlineNamespaceScopeIds = new Set<ScopeId>();
function rangeKey(r: RangeKey): string {
return `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`;
}
/** Capture-time: record a namespace_definition's range as inline.
* Called from `emitCppScopeCaptures` when the tree-sitter AST shows an
* `inline` keyword child on `namespace_definition`. */
export function markCppInlineNamespaceRange(filePath: string, range: RangeKey): void {
let set = inlineNamespaceRangesByFile.get(filePath);
if (set === undefined) {
set = new Set();
inlineNamespaceRangesByFile.set(filePath, set);
}
set.add(rangeKey(range));
}
/** Clear all inline-namespace state. Called from `clearFileLocalNames`. */
export function clearCppInlineNamespaces(): void {
inlineNamespaceRangesByFile.clear();
inlineNamespaceScopeIds.clear();
}
/** Resolve captured ranges to actual ScopeIds by matching scope ranges
* against the inline-namespace ranges recorded for this file. Run from
* the cpp resolver's `populateOwners` hook so the per-pipeline Set is
* populated before any resolution pass consults it. */
export function populateCppInlineNamespaceScopes(parsed: ParsedFile): void {
const ranges = inlineNamespaceRangesByFile.get(parsed.filePath);
if (ranges === undefined || ranges.size === 0) return;
for (const scope of parsed.scopes) {
if (scope.kind !== 'Namespace') continue;
if (ranges.has(rangeKey(scope.range))) {
inlineNamespaceScopeIds.add(scope.id);
}
}
}
/** Predicate consumed by `populateCppNonGloballyVisible` to exempt
* inline-namespace members from cross-file unqualified-lookup
* exclusion (they remain reachable as if declared at the enclosing
* namespace's level). */
export function isCppInlineNamespaceScope(scopeId: ScopeId): boolean {
return inlineNamespaceScopeIds.has(scopeId);
}
/**
* Walk every parsed file looking for a Namespace scope whose qualified
* name matches `receiverName`, collect its callable ownedDefs matching
* `memberName`, transitively descending into any inline-namespace
* children (since they're members of the enclosing namespace under ISO
* C++).
*
* Returns the most specific (innermost) match — for `outer::foo()`
* where `inline namespace v1` declares `foo`, returns `v1::foo`. When
* multiple inline-namespace children declare the same name, ISO C++
* leaves the call ambiguous; V1 returns the first match in source
* order (stable across runs).
*/
export function resolveCppQualifiedNamespaceMember(
receiverName: string,
memberName: string,
parsedFiles: readonly ParsedFile[],
_scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
for (const parsed of parsedFiles) {
const scopesById = new Map<ScopeId, (typeof parsed.scopes)[number]>();
for (const sc of parsed.scopes) scopesById.set(sc.id, sc);
for (const scope of parsed.scopes) {
if (scope.kind !== 'Namespace') continue;
const nsDef = findNamespaceDefInScope(scope);
if (nsDef === undefined) continue;
const nsName = nsDef.qualifiedName?.split('.').pop() ?? nsDef.qualifiedName ?? '';
if (nsName !== receiverName) continue;
// Found a matching namespace scope in this file. Collect the
// member transitively through any inline-namespace children.
const hit = findMemberInNamespaceTransitive(scope, scopesById, memberName);
if (hit !== undefined) return hit;
}
}
return undefined;
}
/** Recursively search a namespace scope and any inline-namespace
* descendants for a callable def with the given simple name. Non-inline
* nested namespaces are NOT traversed — they require explicit
* qualification (`outer::nested::foo`). */
function findMemberInNamespaceTransitive(
scope: {
readonly id: ScopeId;
readonly ownedDefs: readonly SymbolDefinition[];
readonly parent: ScopeId | null;
},
scopesById: ReadonlyMap<
ScopeId,
{
readonly id: ScopeId;
readonly kind: string;
readonly parent: ScopeId | null;
readonly ownedDefs: readonly SymbolDefinition[];
}
>,
memberName: string,
): SymbolDefinition | undefined {
// Check this scope's own ownedDefs first.
for (const def of scope.ownedDefs) {
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue;
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
if (simple === memberName) return def;
}
// Descend into inline-namespace children.
for (const childScope of scopesById.values()) {
if (childScope.parent !== scope.id) continue;
if (childScope.kind !== 'Namespace') continue;
if (!inlineNamespaceScopeIds.has(childScope.id)) continue;
const hit = findMemberInNamespaceTransitive(childScope, scopesById, memberName);
if (hit !== undefined) return hit;
}
return undefined;
}
function findNamespaceDefInScope(scope: {
readonly ownedDefs: readonly SymbolDefinition[];
}): SymbolDefinition | undefined {
for (const def of scope.ownedDefs) {
if (def.type === 'Namespace') return def;
}
return undefined;
}

View file

@ -0,0 +1,110 @@
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
/**
* Interpret a C++ import capture into a ParsedImport.
*
* C++ has three import forms:
* 1. #include "file.h" → wildcard import (all symbols from header)
* 2. using namespace X; → wildcard import (all symbols from namespace X)
* 3. using X::name; → named import (single symbol from namespace X)
*
* System headers (#include <...>) are not resolved to local files.
*/
export function interpretCppImport(captures: CaptureMatch): ParsedImport | null {
const source = captures['@import.source']?.text;
if (source === undefined) return null;
// System headers are not resolved to local files
if (captures['@import.system'] !== undefined) return null;
const kind = captures['@import.kind']?.text;
if (kind === 'named') {
// using X::name — named import
const importedName = captures['@import.name']?.text;
if (importedName === undefined) return null;
return { kind: 'named', targetRaw: source, localName: importedName, importedName };
}
// #include or using namespace — wildcard import
return { kind: 'wildcard', targetRaw: source };
}
/**
* Interpret a C++ type-binding capture into a ParsedTypeBinding.
*
* Source classification (strongest → weakest):
* - `'parameter-annotation'` — function parameter type
* - `'annotation'` — explicit type declaration (`User user;`)
* - `'assignment-inferred'` — typed init (`User user = ...`)
* - `'constructor'` — constructor call (`auto u = User(...)` / `User{}`)
* - `'return'` — function return type
* - `'field'` — class field type
* - `'alias'` — `auto x = existingVar`
*/
export function interpretCppTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const name = captures['@type-binding.name']?.text;
const type = captures['@type-binding.type']?.text;
if (name === undefined || type === undefined) return null;
let source: TypeRef['source'] = 'annotation';
if (captures['@type-binding.parameter'] !== undefined) {
source = 'parameter-annotation';
} else if (captures['@type-binding.constructor'] !== undefined) {
source = 'constructor-inferred';
} else if (captures['@type-binding.return'] !== undefined) {
source = 'return-annotation';
} else if (captures['@type-binding.field'] !== undefined) {
// Field types are structurally equivalent to annotations — the type
// is explicitly written, not inferred.
source = 'annotation';
} else if (captures['@type-binding.member-access'] !== undefined) {
// auto addr = user.address — the type is inferred from the member access.
// Synthesize a dotted rawName ("receiver.field") so compound-receiver
// can resolve the chain: look up receiver's class, then field's type.
const receiver = captures['@type-binding.member-access-receiver']?.text;
if (receiver !== undefined) {
return { boundName: name, rawTypeName: `${receiver}.${type}`, source: 'assignment-inferred' };
}
source = 'assignment-inferred';
} else if (captures['@type-binding.alias'] !== undefined) {
// auto alias = existingVar — the type is inferred from the RHS variable.
source = 'assignment-inferred';
} else if (captures['@type-binding.assignment'] !== undefined) {
source = 'assignment-inferred';
} else if (captures['@type-binding.annotation'] !== undefined) {
source = 'annotation';
}
return { boundName: name, rawTypeName: normalizeCppTypeName(type), source };
}
/**
* Normalize a C++ type name: strip pointer/array/reference syntax,
* qualifiers, while preserving template arguments for specialization-aware
* receiver binding (`List<User>` vs `List<Order>`).
*
* Keeping template arguments here allows receiver-bound fallback to match
* specialization-specific class defs first; non-template behavior is preserved
* by base-name fallback in resolveClassBindingForName.
*/
export function normalizeCppTypeName(text: string): string {
let t = text.trim();
// Strip const, volatile, restrict, static, extern, inline, mutable, constexpr
t = t
.replace(/\b(const|volatile|restrict|static|extern|inline|mutable|constexpr|consteval)\b/g, '')
.trim();
// Strip pointer stars
while (t.endsWith('*')) t = t.slice(0, -1).trim();
while (t.startsWith('*')) t = t.slice(1).trim();
// Strip reference markers
while (t.endsWith('&')) t = t.slice(0, -1).trim();
// Strip array brackets
t = t.replace(/\[.*?\]/g, '').trim();
// Strip struct/union/enum/class prefixes
t = t.replace(/^(struct|union|enum|class)\s+/, '');
// Strip leading :: (global namespace qualifier)
t = t.replace(/^::/, '');
return t;
}

View file

@ -0,0 +1,38 @@
import type { BindingRef } from 'gitnexus-shared';
const TIER: Record<BindingRef['origin'], number> = {
local: 0,
namespace: 1,
import: 2,
reexport: 3,
wildcard: 4,
};
/**
* C++ merge bindings: first-wins by tier.
*
* C++ tier precedence:
* local(0) > namespace(1) > import(2) > reexport(3) > wildcard(4)
*
* Unlike C (no namespaces), C++ uses the `namespace` tier for symbols
* brought in via `using namespace X;` that are then locally referenced.
* The tier ordering ensures local definitions shadow namespace imports,
* which in turn shadow wildcard #include imports.
*/
export function cppMergeBindings(
existing: readonly BindingRef[],
incoming: readonly BindingRef[],
_scopeId: string,
): BindingRef[] {
const seen = new Set<string>();
return [...existing, ...incoming]
.sort(
(a, b) =>
(TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId),
)
.filter((binding) => {
if (seen.has(binding.def.nodeId)) return false;
seen.add(binding.def.nodeId);
return true;
});
}

View file

@ -0,0 +1,512 @@
import Parser from 'tree-sitter';
import CPP from 'tree-sitter-cpp';
const CPP_SCOPE_QUERY = `
;; ─── Scopes ──────────────────────────────────────────────────────────
(translation_unit) @scope.module
(namespace_definition) @scope.namespace
(class_specifier) @scope.class
(struct_specifier) @scope.class
(function_definition) @scope.function
(lambda_expression) @scope.function
(compound_statement) @scope.block
(if_statement) @scope.block
(for_statement) @scope.block
(for_range_loop) @scope.block
(while_statement) @scope.block
(do_statement) @scope.block
(switch_statement) @scope.block
(case_statement) @scope.block
(try_statement) @scope.block
(catch_clause) @scope.block
;; ─── Declarations — namespace ────────────────────────────────────────
(namespace_definition
name: (namespace_identifier) @declaration.name) @declaration.namespace
;; Anonymous namespace (no name child) — captured as scope only, names
;; inside are marked file-local by captures.ts.
;; ─── Declarations — class / struct (named) ───────────────────────────
(class_specifier
name: (type_identifier) @declaration.name
body: (field_declaration_list)) @declaration.class
(class_specifier
name: (template_type
(type_identifier) @declaration.name
(template_argument_list) @declaration.template-arguments)
body: (field_declaration_list)) @declaration.class
(struct_specifier
name: (type_identifier) @declaration.name
body: (field_declaration_list)) @declaration.struct
(struct_specifier
name: (template_type
(type_identifier) @declaration.name
(template_argument_list) @declaration.template-arguments)
body: (field_declaration_list)) @declaration.struct
;; ─── Declarations — class / struct inside template_declaration ───────
(template_declaration
(class_specifier
name: (type_identifier) @declaration.name
body: (field_declaration_list)) @declaration.class)
(template_declaration
(class_specifier
name: (template_type
(type_identifier) @declaration.name
(template_argument_list) @declaration.template-arguments)
body: (field_declaration_list)) @declaration.class)
(template_declaration
(struct_specifier
name: (type_identifier) @declaration.name
body: (field_declaration_list)) @declaration.struct)
(template_declaration
(struct_specifier
name: (template_type
(type_identifier) @declaration.name
(template_argument_list) @declaration.template-arguments)
body: (field_declaration_list)) @declaration.struct)
;; ─── Declarations — enum ─────────────────────────────────────────────
(enum_specifier
name: (type_identifier) @declaration.name) @declaration.enum
;; ─── Declarations — enum constants ───────────────────────────────────
(enumerator
name: (identifier) @declaration.name) @declaration.const
;; ─── Declarations — function definition (plain identifier) ──────────
(function_definition
declarator: (function_declarator
declarator: (identifier) @declaration.name)) @declaration.function
;; ─── Declarations — function definition with pointer return ─────────
(function_definition
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (identifier) @declaration.name))) @declaration.function
;; ─── Declarations — out-of-class method (qualified_identifier) ──────
(function_definition
declarator: (function_declarator
declarator: (qualified_identifier
name: (identifier) @declaration.name))) @declaration.method
;; ─── Declarations — out-of-class method with pointer return ─────────
(function_definition
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (qualified_identifier
name: (identifier) @declaration.name)))) @declaration.method
;; ─── Declarations — out-of-class method (destructor_name) ───────────
(function_definition
declarator: (function_declarator
declarator: (qualified_identifier
name: (destructor_name) @declaration.name))) @declaration.method
;; ─── Declarations — template function definition ────────────────────
(template_declaration
(function_definition
declarator: (function_declarator
declarator: (identifier) @declaration.name)) @declaration.function)
;; ─── Declarations — template method (qualified) ─────────────────────
(template_declaration
(function_definition
declarator: (function_declarator
declarator: (qualified_identifier
name: (identifier) @declaration.name))) @declaration.method)
;; ─── Declarations — inline method in class body (field_identifier) ──
;; tree-sitter-cpp uses field_identifier for names inside class bodies
(function_definition
declarator: (function_declarator
declarator: (field_identifier) @declaration.name)) @declaration.method
;; ─── Declarations — inline method with pointer return (field_identifier) ──
;; Covers: User* lookup(int id) { ... } inside a class body
;; AST: function_definition > pointer_declarator > function_declarator > field_identifier
(function_definition
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (field_identifier) @declaration.name))) @declaration.method
;; ─── Declarations — inline method with reference return (field_identifier) ──
;; Covers: User& getRef() { ... } inside a class body
(function_definition
declarator: (reference_declarator
(function_declarator
declarator: (field_identifier) @declaration.name))) @declaration.method
;; ─── Declarations — function prototype (forward declaration) ────────
(declaration
declarator: (function_declarator
declarator: (identifier) @declaration.name)) @declaration.function
;; ─── Declarations — function prototype with pointer return ──────────
(declaration
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (identifier) @declaration.name))) @declaration.function
;; ─── Declarations — typedef ─────────────────────────────────────────
(type_definition
declarator: (type_identifier) @declaration.name) @declaration.typedef
;; ─── Declarations — type alias (using Name = Type) ──────────────────
(alias_declaration
name: (type_identifier) @declaration.name) @declaration.typedef
;; ─── Declarations — method prototype in class body (forward decl) ────
;; Covers: class User { void save(); std::string getName(); };
;; AST: field_declaration > function_declarator > field_identifier
(field_declaration
declarator: (function_declarator
declarator: (field_identifier) @declaration.name)) @declaration.method
;; Method prototype with pointer return: User* lookup(int id);
(field_declaration
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (field_identifier) @declaration.name))) @declaration.method
;; Method prototype with reference return: User& getRef();
(field_declaration
declarator: (reference_declarator
(function_declarator
declarator: (field_identifier) @declaration.name))) @declaration.method
;; ─── Declarations — fields ──────────────────────────────────────────
(field_declaration
declarator: (field_identifier) @declaration.name) @declaration.field
;; Declarations — fields (pointer)
(field_declaration
declarator: (pointer_declarator
declarator: (field_identifier) @declaration.name)) @declaration.field
;; Declarations — fields (reference)
(field_declaration
declarator: (reference_declarator
(field_identifier) @declaration.name)) @declaration.field
;; ─── Declarations — variables (with initializer) ────────────────────
(declaration
declarator: (init_declarator
declarator: (identifier) @declaration.name)) @declaration.variable
;; ─── Declarations — macro definitions ───────────────────────────────
(preproc_def
name: (identifier) @declaration.name) @declaration.macro
(preproc_function_def
name: (identifier) @declaration.name) @declaration.macro
;; ─── Imports — #include ─────────────────────────────────────────────
(preproc_include) @import.statement
;; ─── Imports — using declaration ─────────────────────────────────────
;; Both "using namespace std;" and "using std::vector;" are
;; using_declaration nodes in tree-sitter-cpp. The captures.ts
;; differentiates between them by checking for a "namespace" anonymous
;; child token.
(using_declaration) @import.using-decl
;; ─── Type bindings — parameter annotations ──────────────────────────
(parameter_declaration
type: (_) @type-binding.type
declarator: (identifier) @type-binding.name) @type-binding.parameter
;; Type bindings — reference parameter (const std::string& name)
(parameter_declaration
type: (_) @type-binding.type
declarator: (reference_declarator
(identifier) @type-binding.name)) @type-binding.parameter
;; Type bindings — pointer parameter (User* ptr)
(parameter_declaration
type: (_) @type-binding.type
declarator: (pointer_declarator
declarator: (identifier) @type-binding.name)) @type-binding.parameter
;; ─── Type bindings — variable with type (init_declarator) ───────────
;; Covers: User user("alice"), User user = ..., int x = 0
(declaration
type: (_) @type-binding.type
declarator: (init_declarator
declarator: (identifier) @type-binding.name)) @type-binding.assignment
;; ─── Type bindings — plain declaration (no initializer) ─────────────
;; Covers: User user;
(declaration
type: (type_identifier) @type-binding.type
declarator: (identifier) @type-binding.name) @type-binding.annotation
;; Covers: List<User> users;
(declaration
type: (template_type) @type-binding.type
declarator: (identifier) @type-binding.name) @type-binding.annotation
;; ─── Type bindings — pointer variable declaration ───────────────────
;; Covers: User* ptr = new User()
(declaration
type: (type_identifier) @type-binding.type
declarator: (init_declarator
declarator: (pointer_declarator
declarator: (identifier) @type-binding.name))) @type-binding.annotation
;; ─── Type bindings — auto + constructor call ────────────────────────
;; Covers: auto user = User("alice")
;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + call_expression > identifier
(declaration
type: (placeholder_type_specifier)
declarator: (init_declarator
declarator: (identifier) @type-binding.name
value: (call_expression
function: (identifier) @type-binding.type))) @type-binding.constructor
;; ─── Type bindings — auto + brace-init (compound_literal_expression) ─
;; Covers: auto user = User{}, auto user = User{args}
;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + compound_literal_expression > type_identifier
(declaration
type: (placeholder_type_specifier)
declarator: (init_declarator
declarator: (identifier) @type-binding.name
value: (compound_literal_expression
type: (type_identifier) @type-binding.type))) @type-binding.constructor
;; ─── Type bindings — auto + scoped brace-init (qualified) ───────────
;; Covers: auto client = ns::HttpClient{}
;; AST: compound_literal_expression > qualified_identifier > type_identifier
(declaration
type: (placeholder_type_specifier)
declarator: (init_declarator
declarator: (identifier) @type-binding.name
value: (compound_literal_expression
type: (qualified_identifier
name: (type_identifier) @type-binding.type)))) @type-binding.constructor
;; ─── Type bindings — auto + new expression ──────────────────────────
;; Covers: auto user = new User(name)
;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + new_expression > type_identifier
(declaration
type: (placeholder_type_specifier)
declarator: (init_declarator
declarator: (identifier) @type-binding.name
value: (new_expression
type: (type_identifier) @type-binding.type))) @type-binding.constructor
;; ─── Type bindings — auto + qualified template factory (std::make_shared<Dog>()) ─
;; AST: declaration(1 > placeholder_type_specifier(2)2 > init_declarator(3 >
;; identifier(4)4 > call_expression(5 > qualified_identifier(6 >
;; template_function(7 > template_argument_list(8 > type_descriptor(9 >
;; type_identifier(10)10 )9 )8 )7 )6 )5 )3 )1
(declaration
type: (placeholder_type_specifier)
declarator: (init_declarator
declarator: (identifier) @type-binding.name
value: (call_expression
function: (qualified_identifier
name: (template_function
arguments: (template_argument_list
(type_descriptor
type: (type_identifier) @type-binding.type))))))) @type-binding.constructor
;; ─── Type bindings — auto + bare template factory (make_shared<Dog>()) ───────
;; Same but without qualified_identifier wrapper — one fewer nesting level
(declaration
type: (placeholder_type_specifier)
declarator: (init_declarator
declarator: (identifier) @type-binding.name
value: (call_expression
function: (template_function
arguments: (template_argument_list
(type_descriptor
type: (type_identifier) @type-binding.type)))))) @type-binding.constructor
;; ─── Type bindings — auto alias assignment ──────────────────────────
;; Covers: auto alias = existingVar (RHS is a plain identifier)
;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + identifier
(declaration
type: (placeholder_type_specifier)
declarator: (init_declarator
declarator: (identifier) @type-binding.name
value: (identifier) @type-binding.type)) @type-binding.alias
;; ─── Type bindings — auto + member access (field_expression) ────────
;; Covers: auto addr = user.address (RHS is obj.field)
;; AST: declaration > placeholder_type_specifier > init_declarator > identifier + field_expression
;; We capture the field name as @type-binding.type so the compound-receiver
;; chain resolver can look it up on the receiver class scope.
;; The full obj.field text is synthesized by interpret.ts into a dotted
;; rawName for chain-follow resolution.
(declaration
type: (placeholder_type_specifier)
declarator: (init_declarator
declarator: (identifier) @type-binding.name
value: (field_expression
argument: (_) @type-binding.member-access-receiver
field: (field_identifier) @type-binding.type))) @type-binding.member-access
;; ─── Type bindings — function return type ───────────────────────────
;; Covers: User getUser() { ... }
;; AST: function_definition > type_identifier + function_declarator > identifier
(function_definition
type: (type_identifier) @type-binding.type
declarator: (function_declarator
declarator: (identifier) @type-binding.name)) @type-binding.return
;; Return type — out-of-class method: User Class::getUser() { ... }
(function_definition
type: (type_identifier) @type-binding.type
declarator: (function_declarator
declarator: (qualified_identifier
name: (identifier) @type-binding.name))) @type-binding.return
;; Return type — pointer return: User* getUser() { ... }
(function_definition
type: (type_identifier) @type-binding.type
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (identifier) @type-binding.name))) @type-binding.return
;; ─── Type bindings — inline method return type ──────────────────────
;; Covers: class Foo { User getUser() { ... } };
(function_definition
type: (type_identifier) @type-binding.type
declarator: (function_declarator
declarator: (field_identifier) @type-binding.name)) @type-binding.return
;; Inline method pointer return type: class Foo { User* lookup(int) { ... } };
(function_definition
type: (type_identifier) @type-binding.type
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (field_identifier) @type-binding.name))) @type-binding.return
;; ─── Type bindings — method prototype return type in class body ──────
;; Covers: class User { User* lookup(int); std::string getName(); };
;; AST: field_declaration > function_declarator > field_identifier
(field_declaration
type: (type_identifier) @type-binding.type
declarator: (function_declarator
declarator: (field_identifier) @type-binding.name)) @type-binding.return
;; Method prototype pointer return type: User* lookup(int id);
(field_declaration
type: (type_identifier) @type-binding.type
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (field_identifier) @type-binding.name))) @type-binding.return
;; ─── Type bindings — field type declarations (class members) ────────
;; Covers: class User { Address address; };
(field_declaration
type: (type_identifier) @type-binding.type
declarator: (field_identifier) @type-binding.name) @type-binding.field
;; Field pointer type: Address* address;
(field_declaration
type: (type_identifier) @type-binding.type
declarator: (pointer_declarator
declarator: (field_identifier) @type-binding.name)) @type-binding.field
;; Field reference type: Address& address;
(field_declaration
type: (type_identifier) @type-binding.type
declarator: (reference_declarator
(field_identifier) @type-binding.name)) @type-binding.field
;; ─── References — constructor calls (new Foo()) ─────────────────────
(new_expression
type: (type_identifier) @reference.name) @reference.call.constructor
;; Constructor call with qualified type: new ns::Foo()
(new_expression
type: (qualified_identifier
name: (type_identifier) @reference.name)) @reference.call.constructor
;; ─── References — free calls ────────────────────────────────────────
(call_expression
function: (identifier) @reference.name) @reference.call.free
;; ─── References — qualified calls (Namespace func or Class method) ───
;; Capture the LHS of scope-resolution as the explicit receiver so
;; qualified static member calls route through receiver-bound-calls
;; Case 2 (class-name receiver) path. Without the receiver capture,
;; qualified calls have no explicit receiver and class methods cannot
;; resolve through receiver-bound paths.
(call_expression
function: (qualified_identifier
scope: (_) @reference.receiver
name: (identifier) @reference.name)) @reference.call.qualified
;; Nested qualified receiver: outer::v1::Base<T>::f()
;; tree-sitter-cpp nests this as qualified_identifier(name:
;; qualified_identifier(scope: qualified_identifier(...), name: identifier)).
;; Capturing the innermost receiver still gives isSuperReceiverInContext
;; enough text to strip qualifiers/template args down to Base.
(call_expression
function: (qualified_identifier
name: (qualified_identifier
scope: (_) @reference.receiver
name: (identifier) @reference.name))) @reference.call.qualified
;; Double-nested qualified receiver: outer::v1::Base<T>::f()
(call_expression
function: (qualified_identifier
name: (qualified_identifier
name: (qualified_identifier
scope: (_) @reference.receiver
name: (identifier) @reference.name)))) @reference.call.qualified
;; ─── References — member calls (obj.method() / ptr->method()) ───────
(call_expression
function: (field_expression
argument: (_) @reference.receiver
field: (field_identifier) @reference.name)) @reference.call.member
;; ─── References — template calls (func<T>()) ────────────────────────
(call_expression
function: (template_function
name: (identifier) @reference.name)) @reference.call.free
;; Note: Ns::func<T>() is parsed as qualified_identifier by tree-sitter-cpp,
;; already captured by the qualified calls pattern above.
;; ─── References — field reads ───────────────────────────────────────
(field_expression
argument: (_) @reference.receiver
field: (field_identifier) @reference.name) @reference.read
;; ─── References — field writes (assignment) ─────────────────────────
(assignment_expression
left: (field_expression
argument: (_) @reference.receiver
field: (field_identifier) @reference.name)) @reference.write
`;
let _parser: Parser | null = null;
let _query: Parser.Query | null = null;
export function getCppParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(CPP as Parameters<Parser['setLanguage']>[0]);
}
return _parser;
}
export function getCppScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(CPP as Parameters<Parser['setLanguage']>[0], CPP_SCOPE_QUERY);
}
return _query;
}

View file

@ -0,0 +1,255 @@
import type { ParsedFile, Scope, TypeRef } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { getCppParser } from './query.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
/**
* Populate range-for loop variable type bindings for C++.
*
* Handles three patterns:
* 1. `for (auto& user : users)` — simple range-for
* 2. `for (auto& [key, user] : userMap)` — structured binding
* 3. `for (auto& user : *usersPtr)` — dereference range-for
*
* Strategy: look up the range source variable's type in scope
* typeBindings, extract the last template argument as the element
* type, and inject a typeBinding for the loop variable.
*/
export function populateCppRangeBindings(
parsedFiles: readonly ParsedFile[],
_indexes: ScopeResolutionIndexes,
ctx: {
readonly fileContents: ReadonlyMap<string, string>;
readonly treeCache?: { get(filePath: string): unknown };
},
): void {
const parser = getCppParser();
for (const parsed of parsedFiles) {
const sourceText = ctx.fileContents.get(parsed.filePath);
if (sourceText === undefined) continue;
const cachedTree = ctx.treeCache?.get(parsed.filePath);
const tree =
(cachedTree as ReturnType<typeof parser.parse> | undefined) ??
parseSourceSafe(parser, sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
if (moduleScope === undefined) continue;
const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s]));
// Build a map from parameter name → AST parameter_declaration node
// so we can extract the un-normalized template type from the AST.
const paramTypeMap = buildParamTemplateMap(tree.rootNode);
for (const rangeNode of tree.rootNode.descendantsOfType('for_range_loop')) {
// Get the declarator (loop variable)
const declarator = rangeNode.childForFieldName('declarator');
if (declarator === null) continue;
// Get the range source expression (right side of ':')
const right = rangeNode.childForFieldName('right');
if (right === null) continue;
// Determine the loop variable name(s) and whether this is a structured binding
const varNames = extractLoopVarNames(declarator);
if (varNames.length === 0) continue;
// Determine the range source variable name (handle dereference)
const sourceVarName = extractSourceVarName(right);
if (sourceVarName === null) continue;
// Look up the source variable's full template type from the AST
// (scope typeBindings have been normalized and lost template params)
const fullType = paramTypeMap.get(sourceVarName);
if (fullType === undefined) continue;
// Extract element type from the container type
const elementType = extractCppElementType(fullType);
if (elementType === null) continue;
// Find the enclosing function scope
const functionScope = findEnclosingFunctionScope(rangeNode, scopeMap);
const targetScope = functionScope ?? moduleScope;
const mutable = targetScope.typeBindings as Map<string, TypeRef>;
// For structured binding [key, user], bind the last identifier to the element type
// For simple range-for, bind the single variable
const bindVar = varNames[varNames.length - 1];
mutable.set(bindVar, {
rawName: elementType,
declaredAtScope: targetScope.id,
source: 'annotation',
});
}
}
}
/** Minimal tree-sitter node shape needed by range-binding helpers. */
interface TsNode {
readonly type: string;
readonly text: string;
readonly childCount: number;
child(index: number): TsNode | null;
descendantsOfType(type: string): readonly TsNode[];
childForFieldName(name: string): TsNode | null;
}
/**
* Build a map from parameter name → full (un-normalized) type text
* by walking the AST for all `parameter_declaration` nodes.
*
* This bypasses `normalizeCppTypeName` which strips template params,
* giving us the raw `std::vector<User>` text needed for element-type
* extraction.
*/
function buildParamTemplateMap(rootNode: TsNode): Map<string, string> {
const map = new Map<string, string>();
for (const paramNode of rootNode.descendantsOfType('parameter_declaration')) {
const typeNode = paramNode.childForFieldName('type');
if (typeNode === null) continue;
// Extract the parameter name from the declarator subtree.
// The declarator may be: identifier, reference_declarator > identifier,
// or pointer_declarator > identifier.
const declNode = paramNode.childForFieldName('declarator');
if (declNode === null) continue;
const idents = declNode.descendantsOfType('identifier');
if (idents.length === 0) continue;
const paramName = idents[idents.length - 1].text;
// Use the full type node text (preserving template params)
map.set(paramName, typeNode.text);
}
return map;
}
/**
* Extract loop variable name(s) from the declarator node.
* Handles both simple `identifier` and `structured_binding_declarator`.
*/
function extractLoopVarNames(declarator: TsNode): string[] {
// The declarator is typically reference_declarator or pointer_declarator wrapping
// either an identifier or a structured_binding_declarator.
const structBindings = declarator.descendantsOfType('structured_binding_declarator');
if (structBindings.length > 0) {
// structured_binding_declarator contains identifiers like [key, user]
const idents = structBindings[0].descendantsOfType('identifier');
return idents.map((id) => id.text).filter((t) => t !== '_');
}
// Simple case: reference_declarator > identifier or just identifier
const idents = declarator.descendantsOfType('identifier');
if (idents.length > 0) {
return [idents[idents.length - 1].text];
}
return [];
}
/**
* Extract the source variable name from the range expression.
* Handles plain identifiers and dereference expressions (*ptr).
*/
function extractSourceVarName(right: TsNode): string | null {
if (right.type === 'identifier') {
return right.text;
}
if (right.type === 'pointer_expression') {
// *usersPtr → get the argument (usersPtr)
const arg = right.childForFieldName('argument');
if (arg !== null) return arg.text;
}
return null;
}
/**
* Extract the element type from a C++ container type string.
*
* Examples:
* - `vector<User>` → `User`
* - `std::vector<User>` → `User`
* - `map<std::string, User>` → `User` (last template arg)
* - `map<string, User>` → `User`
*
* For structured bindings with maps, the last template arg is the value type.
* For vectors/sets, the first (and only) template arg is the element type.
*/
function extractCppElementType(rawType: string): string | null {
// Find the outermost template argument list
const ltIdx = rawType.indexOf('<');
if (ltIdx === -1) return null;
// Extract the template argument string (handle nested templates)
let depth = 0;
let lastCommaOrStart = ltIdx + 1;
let lastArg = '';
for (let i = ltIdx; i < rawType.length; i++) {
const ch = rawType[i];
if (ch === '<') {
depth++;
} else if (ch === '>') {
depth--;
if (depth === 0) {
lastArg = rawType.slice(lastCommaOrStart, i).trim();
break;
}
} else if (ch === ',' && depth === 1) {
lastCommaOrStart = i + 1;
}
}
if (lastArg === '') return null;
// Strip pointer/reference qualifiers and const
let elementType = lastArg
.replace(/^const\s+/, '')
.replace(/\s*[*&]+\s*$/, '')
.trim();
// Strip namespace prefix (std::string → string)
const lastColon = elementType.lastIndexOf('::');
if (lastColon !== -1) {
elementType = elementType.slice(lastColon + 2);
}
return elementType || null;
}
/**
* Find the enclosing Function scope for a tree-sitter node by
* walking up the AST and matching source positions.
*/
function findEnclosingFunctionScope(
node: unknown,
scopeMap: ReadonlyMap<string, Scope>,
): Scope | null {
const tsNode = node as {
readonly parent: unknown;
readonly type: string;
readonly startPosition: { readonly row: number; readonly column: number };
};
let current: typeof tsNode | null = tsNode;
while (current !== null) {
if (current.type === 'function_definition') {
for (const scope of scopeMap.values()) {
if (
scope.kind === 'Function' &&
scope.range.startLine === current.startPosition.row &&
scope.range.startCol === current.startPosition.column
) {
return scope;
}
}
break;
}
current = (current.parent as typeof tsNode) ?? null;
}
return null;
}

View file

@ -0,0 +1,272 @@
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import {
findClassBindingInScope,
findEnclosingClassDef,
} from '../../scope-resolution/scope/walkers.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { cppProvider } from '../c-cpp.js';
import { cppArityCompatibility } from './arity.js';
import { cppMergeBindings } from './merge-bindings.js';
import { resolveCppImportTarget } from './import-target.js';
import { scanCppHeaderFiles } from './header-scan.js';
import {
expandCppWildcardNames,
isFileLocal,
clearFileLocalNames,
populateCppAnonymousNamespaceScopes,
populateCppNonGloballyVisible,
isCppDefGloballyVisible,
} from './file-local-linkage.js';
import {
populateCppDependentBases,
clearCppDependentBases,
isCppDependentBaseMember,
} from './two-phase-lookup.js';
import {
populateCppAssociatedNamespaces,
clearCppAdlState,
pickCppAdlCandidates,
ADL_AMBIGUOUS,
} from './adl.js';
import {
clearCppInlineNamespaces,
populateCppInlineNamespaceScopes,
resolveCppQualifiedNamespaceMember,
} from './inline-namespaces.js';
import { populateCppRangeBindings } from './range-bindings.js';
import {
isOverloadAmbiguousAfterNormalization,
narrowOverloadCandidates,
} from '../../scope-resolution/passes/overload-narrowing.js';
/**
* C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
* the generic `runScopeResolution` orchestrator (RFC #909 Ring 3).
*
* C++ extends C's scope resolution with:
* - Namespaces (`namespace foo { ... }`)
* - Classes with methods and multiple inheritance
* - `using namespace` (wildcard import from namespace)
* - `using X::name` (named import from namespace)
* - Anonymous namespace (file-local linkage, like C `static`)
* - Default parameters (requiredParameterCount < parameterCount)
* - Overloading (arity-based disambiguation)
* - Templates (V1: generic-ignored, `List<User>` ≡ `List`)
* - Leftmost-base MRO for multiple inheritance
*/
export const cppScopeResolver: ScopeResolver = {
language: SupportedLanguages.CPlusPlus,
languageProvider: cppProvider,
importEdgeReason: 'cpp-scope: include',
loadResolutionConfig: (repoPath: string) => {
// Clear stale per-pipeline state from any previous invocation.
clearFileLocalNames();
clearCppDependentBases();
clearCppAdlState();
clearCppInlineNamespaces();
return scanCppHeaderFiles(repoPath);
},
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
// Augment allFilePaths with header files discovered via loadResolutionConfig.
// C++ .h/.hpp/.hxx/.hh files may be classified differently by language
// detection but are importable from .cpp files via #include.
const headerPaths = resolutionConfig as ReadonlySet<string> | undefined;
if (headerPaths !== undefined && headerPaths.size > 0) {
const augmented = new Set(allFilePaths);
for (const h of headerPaths) augmented.add(h);
return resolveCppImportTarget(targetRaw, fromFile, augmented);
}
return resolveCppImportTarget(targetRaw, fromFile, allFilePaths);
},
expandsWildcardTo: (targetModuleScope, parsedFiles) =>
expandCppWildcardNames(targetModuleScope, parsedFiles),
mergeBindings: (existing, incoming, scopeId) => cppMergeBindings(existing, incoming, scopeId),
// Adapter: cppArityCompatibility predates ScopeResolver and uses
// (def, callsite). ScopeResolver contract is (callsite, def).
arityCompatibility: (callsite, def) => cppArityCompatibility(def, callsite),
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
populateOwners: (parsed: ParsedFile) => {
populateClassOwnedMembers(parsed);
// Resolve inline- and anonymous-namespace ranges (recorded at capture
// time) to ScopeIds BEFORE `populateCppNonGloballyVisible` runs, so
// both exemptions see the populated Sets.
populateCppInlineNamespaceScopes(parsed);
populateCppAnonymousNamespaceScopes(parsed);
// Track namespace-nested and class-nested defs so the global free-call
// fallback and wildcard expansion can suppress them as unqualified
// cross-file callables.
populateCppNonGloballyVisible(parsed);
// Build the class-def → enclosing-namespace-qualified-name map used
// by ADL (U2 of plan 2026-05-13-001) to identify each argument type's
// associated namespace for Koenig lookup.
populateCppAssociatedNamespaces(parsed);
},
// Resolve recorded template-class → dependent-base simple names to
// class nodeIds for two-phase template lookup (U3 of plan
// 2026-05-13-001). Runs AFTER all files have had `populateOwners`
// applied so that cross-file base classes (e.g. Base in base.h,
// Derived in derived.h) are reachable in the workspace index.
populateWorkspaceOwners: (parsedFiles: readonly ParsedFile[]) => {
populateCppDependentBases(parsedFiles);
},
// Simple `isSuperReceiver` returns false for C++. Real super
// classification is caller-context-dependent and lives in
// `isSuperReceiverInContext` below — without scope context the
// previous regex `/^[A-Z]\w*::/` misclassified namespace-qualified
// calls (e.g., `Singleton::getInstance()`) as super calls and routed
// them through the wrong resolution branch.
isSuperReceiver: () => false,
isSuperReceiverInContext: (text, callerScope, scopes) => {
// The receiver text comes from the LHS of `::` in `qualified_identifier`
// (e.g., for `Base<T>::method()`, text is `Base<T>`). Strip template
// arguments (V1: name-only matching, generics ignored) and any leading
// namespace qualifier so the lookup matches the bare class def's
// simple name. `Base<T>::method()` → `Base`; `outer::v1::Base<T>` →
// `Base`. This handles the Phase 5 cross-unit composition where
// qualified base-method calls appear inside template bodies.
let lhs = text;
const sepIdx = lhs.indexOf('::');
if (sepIdx > 0) lhs = lhs.slice(0, sepIdx).trim();
// Strip trailing template-argument list (greedy: drop everything from
// the first `<` onward — V1 ignores generics).
const lt = lhs.indexOf('<');
if (lt > 0) lhs = lhs.slice(0, lt).trim();
// Strip nested namespace prefix from the receiver text itself (the
// `outer::v1::Base` shape that appears in derived-list `base_class_clause`).
const lastDoubleColon = lhs.lastIndexOf('::');
if (lastDoubleColon >= 0) lhs = lhs.slice(lastDoubleColon + 2).trim();
if (lhs.length === 0) return false;
// Resolve the LHS in the caller's scope chain. Only class-like
// resolutions can be super receivers; Namespace and unresolved
// names are not super calls.
const lhsDef = findClassBindingInScope(callerScope, lhs, scopes);
if (lhsDef === undefined) return false;
// The caller must have an enclosing class — super calls only make
// sense inside a class body. Free functions can use `ClassName::`
// for namespace-qualified calls but those are not super.
const enclosing = findEnclosingClassDef(callerScope, scopes);
if (enclosing === undefined) return false;
// `lhsDef` must be in the caller's MRO (i.e., the caller's enclosing
// class derives from it). The class itself counts as its own MRO
// root — `Self::method()` is a qualified self-call, not a super
// call, so exclude the caller's own class.
if (lhsDef.nodeId === enclosing.nodeId) return false;
const mro = scopes.methodDispatch.mroFor(enclosing.nodeId);
return mro.includes(lhsDef.nodeId);
},
// C++ is statically typed — disable field fallback heuristic
fieldFallbackOnMethodLookup: false,
// C++ needs return type propagation across #include boundaries
propagatesReturnTypesAcrossImports: true,
// C++ #include brings in all symbols — enable global free call fallback
allowGlobalFreeCallFallback: true,
// Range-for element type inference: for (auto& user : users) → bind user to User
populateRangeBindings: populateCppRangeBindings,
// C++ method return-type bindings need to be visible from module scope
// for cross-file propagation and compound-receiver chain resolution.
// cppBindingScopeFor hoists @type-binding.return to Module scope.
hoistTypeBindingsToModule: true,
// Enable receiver-bound explicit-`this` fallback only for C++.
resolveThisViaEnclosingClass: true,
// The `isFileLocalDef` hook on the global free-call fallback names
// file-local linkage historically, but semantically gates "logically
// invisible cross-file" defs. C++ extends this to also reject class-
// owned methods/fields and namespace-nested symbols — an unqualified
// call from a free function MUST NOT resolve to `User::save` or
// `ns::foo` (Cppreference, "Unqualified name lookup"). Without this
// gate, the global fallback walks every callable in the workspace
// registry and matches any class method or namespace function by
// simple name.
isFileLocalDef: (def: SymbolDefinition) => {
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
if (isFileLocal(def.filePath, simple)) return true;
// Class-owned (Method/Field) — `populateClassOwnedMembers` already
// stamps `ownerId`; cheap fast-path before consulting the scope map.
if (def.ownerId !== undefined) return true;
// Namespace-nested defs — require qualification cross-file. Scope-
// walked at `populateOwners` time into a per-file nodeId set.
if (!isCppDefGloballyVisible(def.filePath, def.nodeId)) return true;
return false;
},
// C++ two-phase template lookup: inside a class template body,
// unqualified calls MUST NOT bind to members of a dependent base
// class. The standard requires `this->name()` or `Base<T>::name()`
// forms to make the lookup dependent. Without this gate the global
// free-call fallback walks the workspace registry and silently binds
// unqualified calls to dependent-base members, producing CALLS edges
// the compiler would reject. See plan 2026-05-13-001 U3.
isCallableVisibleFromCaller: ({ candidate, callerScope, scopes }) => {
if (callerScope === undefined || scopes === undefined) return true;
// Reject when the candidate is a member of a dependent base of the
// caller's enclosing template class. Otherwise allow.
return !isCppDependentBaseMember(callerScope, candidate, scopes);
},
// C++ argument-dependent / Koenig lookup (U2 of plan 2026-05-13-001).
// Fires after `findCallableBindingInScope` returns undefined; surfaces
// candidates from the associated namespaces of class-typed arguments.
// Current boundary: class-typed value/pointer/reference args and template
// specializations with explicit type arguments contribute associated
// namespaces. Function-pointer args, base-class associated namespaces,
// and full ordinary+ADL merge remain excluded.
resolveAdlCandidates: (site, callerParsed, scopes, parsedFiles) => {
// `using ns::name;` introduces `name` into ordinary unqualified lookup.
// For template-class method bodies, lexical scope walks can miss this
// named-using visibility; recover by resolving the imported namespace
// member directly when the local call name matches a named using import.
const usingNamedHits: SymbolDefinition[] = [];
const seenUsing = new Set<string>();
for (const imp of callerParsed.parsedImports) {
if (imp.kind !== 'named') continue;
if (imp.localName !== site.name) continue;
const member = resolveCppQualifiedNamespaceMember(
imp.targetRaw,
imp.importedName,
parsedFiles,
scopes,
);
if (member === undefined) continue;
if (seenUsing.has(member.nodeId)) continue;
seenUsing.add(member.nodeId);
usingNamedHits.push(member);
}
if (usingNamedHits.length > 0) {
const narrowed = narrowOverloadCandidates(usingNamedHits, site.arity, site.argumentTypes);
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) return 'ambiguous';
if (narrowed.length === 1) return narrowed[0];
if (narrowed.length > 1) return 'ambiguous';
}
const result = pickCppAdlCandidates(site, callerParsed, scopes, parsedFiles);
if (result === ADL_AMBIGUOUS) return 'ambiguous';
return result;
},
// C++ qualified namespace-member resolution (U5 of plan 2026-05-13-001).
// Handles `outer::foo()` where `outer` is a namespace (not a class).
// Walks each parsed file's namespace scopes by simple name, then
// descends transitively through inline-namespace children when
// searching for the called member. Returns undefined for non-namespace
// receivers so receiver-bound-calls Case 2 still gets a chance.
resolveQualifiedReceiverMember: (receiverName, memberName, _callerScope, scopes, parsedFiles) =>
resolveCppQualifiedNamespaceMember(receiverName, memberName, parsedFiles, scopes),
};

View file

@ -0,0 +1,79 @@
import type {
CaptureMatch,
ParsedImport,
Scope,
ScopeId,
ScopeTree,
TypeRef,
} from 'gitnexus-shared';
/**
* C++ binding scope: default auto-hoist (null) for most declarations.
*
* For `for` statement init-scope variables (e.g. `for (int i = 0; ...)`),
* the variable is scoped to the for-block, not the enclosing function.
* The tree-sitter scope query already captures for_statement as @scope.block,
* so tree-sitter's scope nesting handles this automatically — we return null
* to let the default auto-hoist apply.
*/
export function cppBindingScopeFor(
decl: CaptureMatch,
innermost: Scope,
tree: ScopeTree,
): ScopeId | null {
// Hoist return-type bindings to Module scope so:
// 1. propagateImportedReturnTypes can mirror them across files
// 2. compound-receiver can find method return types via hoistTypeBindingsToModule
if (decl['@type-binding.return'] !== undefined) {
let cur: Scope | undefined = innermost;
while (cur !== undefined && cur.kind !== 'Module') {
const parentId: ScopeId | null = cur.parent ?? null;
if (parentId === null) break;
cur = tree.getScope(parentId);
}
if (cur !== undefined && cur.kind === 'Module') return cur.id;
}
return null; // default auto-hoist for other bindings
}
/**
* C++ import owning scope: default (null).
* #include and using declarations are file-scoped in C++.
*/
export function cppImportOwningScope(
_imp: ParsedImport,
_innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
return null;
}
/**
* C++ receiver binding: return `this` TypeRef for methods inside a class.
*
* When a function scope is inside a class scope, the implicit `this` pointer
* refers to the enclosing class. This enables `this->method()` and implicit
* `this` member access resolution.
*/
export function cppReceiverBinding(functionScope: Scope): TypeRef | null {
// Walk up the scope tree to find an enclosing class scope
if (functionScope.parent === null) return null;
// The scope tree structure nests function scopes inside class scopes.
// The orchestrator provides the function scope; we need to check if
// its parent chain contains a class scope.
//
// However, the ScopeResolver.receiverBinding contract receives only
// the function Scope (not the full ScopeTree), and the Scope type
// includes `parent` (a ScopeId) but not a reference to the parent
// Scope object.
//
// The orchestrator already handles this by looking up the class owner
// via populateOwners. We return null here and let the shared infra
// handle receiver resolution through the class-ownership mechanism.
//
// This is consistent with how C# and Go handle it — the receiver
// binding is established through populateOwners + the MRO chain,
// not through this hook.
return null;
}

View file

@ -0,0 +1,205 @@
/**
* C++ two-phase template lookup support.
*
* Inside a class template body, names from a dependent base class are NOT
* found by ordinary unqualified lookup. The standard requires the
* `this->name` or `Base<T>::name` forms to make the lookup dependent.
* GitNexus's global free-call fallback otherwise binds such names to the
* dependent base's members, producing CALLS edges the compiler would
* reject.
*
* This module records — during `emitCppScopeCaptures` — which template
* class declarations have which dependent base class names (per file).
* `populateCppDependentBases` then resolves those names to class nodeIds
* using a workspace-wide registry, building the per-class set the
* `isCppDependentBaseMember` predicate consumes.
*
* Cross-file resolution: `Base<T>` may be declared in a different header
* than `Derived<T>`. `populateCppDependentBases` therefore runs as a
* workspace-wide pass (`populateWorkspaceOwners` hook) after every file
* has had `populateOwners` applied, so all class defs are reachable.
*
* Namespace disambiguation: when multiple classes share a simple name
* (e.g., `Box` in two namespaces), the resolver prefers the candidate
* whose qualified-name prefix (namespace path) matches the deriving
* class's prefix. If no namespace match is found, a unique simple-name
* match is accepted; ambiguous matches (multiple candidates, no
* namespace winner) are skipped conservatively.
*
* NOTE: module-level state, single-process-single-repo use only.
* `clearFileLocalNames()` clears this state alongside file-local linkage
* (see `file-local-linkage.ts`).
*/
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { findEnclosingClassDef } from '../../scope-resolution/scope/walkers.js';
/**
* Capture-time record: for each template class declaration in a file,
* the simple names of its dependent base classes.
*
* Key: filePath
* Value: Map<className, Set<dependentBaseSimpleName>>
*/
const dependentBasesByFile = new Map<string, Map<string, Set<string>>>();
/**
* Post-`populateOwners` resolution: per-class-nodeId, the set of
* dependent-base-class nodeIds. Built by `populateCppDependentBases`
* from `dependentBasesByFile` + the workspace registry.
*/
const dependentBaseNodeIds = new Map<string, Set<string>>();
/**
* Record a dependent-base relationship discovered during scope-capture
* emission. `className` is the simple name of the template class;
* `baseName` is the simple name of the dependent base class.
*
* The capture-time recorder uses simple names because the registry
* resolution that maps names → nodeIds runs later (in
* `populateCppDependentBases`).
*/
export function markCppDependentBase(filePath: string, className: string, baseName: string): void {
let perFile = dependentBasesByFile.get(filePath);
if (perFile === undefined) {
perFile = new Map();
dependentBasesByFile.set(filePath, perFile);
}
let bases = perFile.get(className);
if (bases === undefined) {
bases = new Set();
perFile.set(className, bases);
}
bases.add(baseName);
}
/** Clear two-phase-lookup state. Called from `clearFileLocalNames`. */
export function clearCppDependentBases(): void {
dependentBasesByFile.clear();
dependentBaseNodeIds.clear();
}
/**
* Resolve recorded dependent-base simple names to class nodeIds using a
* workspace-wide index. Run as `populateWorkspaceOwners` after every
* file has had `populateOwners` applied, so class defs from ALL files
* are reachable.
*
* Disambiguation strategy (multiple classes sharing a simple name):
* 1. Prefer the candidate whose qualified-name namespace prefix matches
* the deriving class's namespace prefix (same-namespace bias).
* 2. Fall back to accepting a unique simple-name match.
* 3. Skip when multiple candidates exist and no namespace match is
* found (conservative: avoids false associations).
*/
export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): void {
if (dependentBasesByFile.size === 0) return;
// Build workspace-wide index: simpleName → {nodeId, nsPrefix}[]
// nsPrefix is the dot-joined namespace path (qualifiedName without the
// last segment). Classes at global scope have nsPrefix = ''.
const classesBySimpleName = new Map<string, { nodeId: string; nsPrefix: string }[]>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
const qn = def.qualifiedName ?? '';
const lastDot = qn.lastIndexOf('.');
const simple = lastDot >= 0 ? qn.slice(lastDot + 1) : qn;
if (simple === '') continue;
const nsPrefix = lastDot >= 0 ? qn.slice(0, lastDot) : '';
let entries = classesBySimpleName.get(simple);
if (entries === undefined) {
entries = [];
classesBySimpleName.set(simple, entries);
}
entries.push({ nodeId: def.nodeId, nsPrefix });
}
}
// Build a filePath → ParsedFile lookup for fast per-file access.
const parsedByFile = new Map<string, ParsedFile>();
for (const parsed of parsedFiles) parsedByFile.set(parsed.filePath, parsed);
for (const [filePath, perFile] of dependentBasesByFile) {
const parsed = parsedByFile.get(filePath);
if (parsed === undefined) continue;
// Build a simple-name → {nodeId, nsPrefix} map for THIS file's
// class-like defs so we can identify each template class precisely
// (avoids cross-file name collisions for the deriving class itself).
const localClassByName = new Map<string, { nodeId: string; nsPrefix: string }>();
for (const def of parsed.localDefs) {
if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
const qn = def.qualifiedName ?? '';
const lastDot = qn.lastIndexOf('.');
const simple = lastDot >= 0 ? qn.slice(lastDot + 1) : qn;
if (simple === '') continue;
const nsPrefix = lastDot >= 0 ? qn.slice(0, lastDot) : '';
localClassByName.set(simple, { nodeId: def.nodeId, nsPrefix });
}
for (const [className, baseNames] of perFile) {
const classEntry = localClassByName.get(className);
if (classEntry === undefined) continue;
let bases = dependentBaseNodeIds.get(classEntry.nodeId);
if (bases === undefined) {
bases = new Set();
dependentBaseNodeIds.set(classEntry.nodeId, bases);
}
for (const baseName of baseNames) {
const candidates = classesBySimpleName.get(baseName);
if (candidates === undefined || candidates.length === 0) continue;
if (candidates.length === 1) {
// Unique simple-name match — accept regardless of namespace.
bases.add(candidates[0].nodeId);
continue;
}
// Multiple classes share the same simple name — prefer the one
// whose namespace matches the deriving class's namespace.
// V1: exact dot-prefix match only. Cross-namespace inheritance
// (e.g., `ns::outer::Derived` extending bare `Inner` defined in
// `ns::outer::inner`) and inline-namespace cases are deferred to
// V2; the conservative skip-on-ambiguity below avoids false
// associations in those edge cases.
const nsMatch = candidates.find((c) => c.nsPrefix === classEntry.nsPrefix);
if (nsMatch !== undefined) {
bases.add(nsMatch.nodeId);
}
// else: ambiguous (multiple candidates, no namespace match) → skip.
}
}
}
}
/**
* Two-phase lookup predicate: is the candidate def a member of a
* dependent base of the caller's enclosing template class?
*
* Used as an additional reject-filter in `pickUniqueGlobalCallable` and
* the receiver-bound member chain walk. ONLY apply for unqualified
* call forms — `this->name` and `Base<T>::name` are dependent lookup
* forms that the standard allows.
*
* Conservative bias: when the caller's enclosing class can't be
* identified, return `false` (let normal resolution proceed). Over-
* rejection is acceptable for the template case because the standard
* itself requires `this->` or qualified forms for dependent base
* access; missing edges here match the compiler's diagnostic shape.
*/
export function isCppDependentBaseMember(
callerScopeId: ScopeId,
candidateDef: SymbolDefinition,
scopes: ScopeResolutionIndexes,
): boolean {
if (candidateDef.ownerId === undefined) return false;
const enclosing = findEnclosingClassDef(callerScopeId, scopes);
if (enclosing === undefined) return false;
const bases = dependentBaseNodeIds.get(enclosing.nodeId);
if (bases === undefined) return false;
return bases.has(candidateDef.ownerId);
}

View file

@ -128,6 +128,7 @@ export interface AddMetadata {
parameterTypes?: string[];
returnType?: string;
declaredType?: string;
templateArguments?: string[];
ownerId?: string;
qualifiedName?: string;
}
@ -277,6 +278,9 @@ export const createSymbolTable = (): InternalSymbolTable => {
: {}),
...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}),
...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}),
...(metadata?.templateArguments !== undefined
? { templateArguments: metadata.templateArguments }
: {}),
...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}),
};

View file

@ -30,6 +30,7 @@ import {
constTagForId,
buildCollisionGroups,
} from './utils/method-props.js';
import { extractTemplateArguments, templateArgumentsIdTag } from './utils/template-arguments.js';
import type { LanguageProvider } from './language-provider.js';
import type { ParsedFile } from 'gitnexus-shared';
import { WorkerPool } from './workers/worker-pool.js';
@ -129,6 +130,7 @@ export const mergeChunkResults = (
parameterTypes: sym.parameterTypes,
returnType: sym.returnType,
declaredType: sym.declaredType,
templateArguments: sym.templateArguments,
ownerId: sym.ownerId,
qualifiedName: sym.qualifiedName,
});
@ -483,6 +485,23 @@ const processParsingSequential = async (
})
: null;
const nodeLabel = extractedClassSymbol?.type ?? defaultNodeLabel;
const isClassLikeLabel =
nodeLabel === 'Class' ||
nodeLabel === 'Struct' ||
nodeLabel === 'Interface' ||
nodeLabel === 'Enum' ||
nodeLabel === 'Record';
if (
isClassLikeLabel &&
provider.classExtractor?.shouldSkipClassCapture?.({
captureMap,
definitionNode,
nameNode,
nodeLabel,
}) === true
) {
return;
}
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
if (!nameNode && nodeLabel !== 'Constructor' && !extractedClassSymbol) return;
const nodeName = extractedClassSymbol?.name ?? (nameNode ? nameNode.text : 'init');
@ -610,7 +629,31 @@ const processParsingSequential = async (
cached.groups,
);
}
const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}${arityTag}`);
const classTemplateArguments =
extractedClassSymbol?.templateArguments ??
provider.classExtractor?.extractTemplateArgumentsFromCapture?.({
captureMap,
definitionNode,
nameNode,
}) ??
(captureMap['template-arguments']
? extractTemplateArguments(captureMap['template-arguments'].text)
: undefined) ??
(nameNode && nameNode.text ? extractTemplateArguments(nameNode.text) : undefined);
const classTemplateTag =
(nodeLabel === 'Class' ||
nodeLabel === 'Struct' ||
nodeLabel === 'Interface' ||
nodeLabel === 'Enum' ||
nodeLabel === 'Record') &&
classTemplateArguments !== undefined &&
classTemplateArguments.length > 0
? templateArgumentsIdTag(classTemplateArguments)
: '';
const nodeId = generateId(
nodeLabel,
`${file.path}:${qualifiedName}${classTemplateTag}${arityTag}`,
);
const classNodeForSymbol = definitionNodeForRange || definitionNode || nameNode;
const qualifiedTypeName =
extractedClassSymbol?.qualifiedName ??
@ -643,6 +686,9 @@ const processParsingSequential = async (
nodeName,
),
...(qualifiedTypeName !== undefined ? { qualifiedName: qualifiedTypeName } : {}),
...(classTemplateArguments !== undefined && classTemplateArguments.length > 0
? { templateArguments: classTemplateArguments }
: {}),
...(frameworkHint
? {
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
@ -700,6 +746,7 @@ const processParsingSequential = async (
parameterTypes: methodProps.parameterTypes as string[] | undefined,
returnType: methodProps.returnType as string | undefined,
declaredType,
templateArguments: classTemplateArguments,
ownerId: enclosingClassId ?? undefined,
qualifiedName: qualifiedTypeName,
});

View file

@ -72,6 +72,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
SupportedLanguages.TypeScript,
SupportedLanguages.Go,
SupportedLanguages.C,
SupportedLanguages.CPlusPlus,
SupportedLanguages.PHP,
]);

View file

@ -76,6 +76,7 @@ import type {
} from 'gitnexus-shared';
import { buildPositionIndex, buildScopeTree, canParentScope, makeScopeId } from 'gitnexus-shared';
import type { LanguageProvider } from './language-provider.js';
import { extractTemplateArguments } from './utils/template-arguments.js';
// ─── Narrow hook surface the extractor actually uses ───────────────────────
@ -533,6 +534,9 @@ function buildDefFromDeclarationMatch(
const qualifiedCap = match['@declaration.qualified_name'];
const qualifiedName = qualifiedCap?.text;
const templateArguments =
extractTemplateArguments(match['@declaration.template-arguments']?.text ?? '') ??
extractTemplateArguments(qualifiedName ?? nameCap.text);
// Optional arity metadata — producers (e.g. Python emit-captures)
// synthesize these on function/method declarations. Their absence is
@ -554,6 +558,7 @@ function buildDefFromDeclarationMatch(
...(parameterTypes !== undefined ? { parameterTypes } : {}),
...(declaredType !== undefined ? { declaredType } : {}),
...(returnType !== undefined ? { returnType } : {}),
...(templateArguments !== undefined ? { templateArguments } : {}),
};
}

View file

@ -87,6 +87,9 @@
* attempting emission (even on dedup-collapse), because the
* per-(caller, target) collapse semantics require multiple call
* sites in the same caller body not produce multiple edges.
* `preEmitInheritanceEdges` also pre-marks every `inherits` site so
* the generic bridge cannot remap class heritage into method-owned
* EXTENDS edges via `resolveCallerGraphId`.
*
* - **I3 — `propagateImportedReturnTypes` mutation timing + ordering.**
* The pass mutates `Scope.typeBindings` (a plain `new Map(...)` from
@ -432,9 +435,47 @@ export interface ScopeResolver {
* `/^super\s*\(/.test(t)`. Java returns `t === 'super'`. C++ may
* also need `this` capture. Languages without inheritance return
* constant `false`.
*
* For languages where the answer depends on caller context (e.g.
* C++, where `Base::method()` is a super call ONLY when `Base` is
* actually a base of the caller's enclosing class, and namespace-
* qualified calls like `Singleton::getInstance()` must NOT be
* misclassified), implement the optional `isSuperReceiverInContext`
* variant below. The receiver-bound-calls pass prefers the context-
* aware variant when both are defined.
*/
isSuperReceiver(receiverText: string): boolean;
/**
* Optional context-aware variant of `isSuperReceiver`. When defined,
* the receiver-bound-calls pass prefers this hook over the simple
* `isSuperReceiver(text)` form. Languages where super classification
* is purely text-driven (Python, Java, PHP) omit this hook and the
* simple form is used unchanged.
*
* C++ uses this to distinguish `Base::method()` (super call when
* `Base` is in the caller's MRO) from `Singleton::getInstance()`
* (ordinary namespace-qualified call). Without this, the regex
* heuristic `/^[A-Z]\w*::/` misclassifies any uppercase-qualified
* call as a super-receiver call and routes it through the wrong
* resolution branch.
*
* Returns `true` ONLY when:
* - the receiver text parses as `<Name>::<...>` (or another super-
* form the language recognizes), AND
* - `<Name>` resolves (via scope chain) to a class-like def, AND
* - that class is in the MRO of the caller's enclosing class.
*
* Returns `false` for namespace-qualified calls, unresolved names,
* class-qualified calls where the class is NOT in the caller's MRO,
* and any text the simple `isSuperReceiver` hook also rejects.
*/
readonly isSuperReceiverInContext?: (
receiverText: string,
callerScope: ScopeId,
scopes: ScopeResolutionIndexes,
) => boolean;
// ─── Optional toggles ──────────────────────────────────────────────────────
/**
@ -522,8 +563,82 @@ export interface ScopeResolver {
readonly isCallableVisibleFromCaller?: (ctx: {
readonly callerParsed: ParsedFile;
readonly candidate: SymbolDefinition;
/** Caller's enclosing scope id. Languages that gate visibility on
* caller scope (e.g. C++ two-phase template lookup) consult it;
* others ignore. Optional so existing implementations stay valid. */
readonly callerScope?: ScopeId;
/** ScopeResolutionIndexes for scope-tree walks. Optional for the
* same reason as `callerScope`. */
readonly scopes?: ScopeResolutionIndexes;
}) => boolean;
/**
* Optional argument-dependent-lookup (ADL / Koenig lookup) hook for
* languages with C++-style associated-namespace candidate addition.
*
* Runs in the free-call fallback AFTER `findCallableBindingInScope`
* returns `undefined` and BEFORE `pickUniqueGlobalCallable`. The hook
* inspects the call site's argument types, computes the associated
* namespace set, and returns either:
* - a unique `SymbolDefinition` — emit the CALLS edge to it.
* - `'ambiguous'` — multiple candidates share normalized parameter
* types; the caller MUST suppress (zero edges). Mirrors the
* OVERLOAD_AMBIGUOUS sentinel from `overload-narrowing.ts`.
* - `undefined` — no ADL candidates; caller falls through to the
* global free-call fallback (`pickUniqueGlobalCallable`).
*
* Languages without C++-style ADL leave this undefined. The
* cross-language contract is "additive tier" — defining the hook never
* removes candidates the prior tier would have produced.
*/
readonly resolveAdlCandidates?: (
site: {
readonly name: string;
readonly arity?: number;
readonly argumentTypes?: readonly string[];
readonly atRange: { readonly startLine: number; readonly startCol: number };
},
callerParsed: ParsedFile,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
) => SymbolDefinition | 'ambiguous' | undefined;
/**
* Optional resolver for qualified-receiver member calls where the
* receiver is a namespace (not a class) and ordinary scope-chain /
* import resolution doesn't find the member. C++ uses this for
* `outer::foo()` style calls and to walk through inline-namespace
* children transitively (`outer::v1::foo` reachable as `outer::foo`).
*
* Languages whose qualified-name semantics are already covered by the
* receiver-bound-calls Case-1 namespace-targets path (e.g., Python's
* `import X; X.foo()`) leave this undefined.
*
* Receiver-bound-calls invokes this hook AFTER Case 1 (namespace
* imports) and AFTER Case 2 (class-name receiver) fail to resolve.
* Returns the target def, or `undefined` to fall through to the
* remaining cases.
*/
readonly resolveQualifiedReceiverMember?: (
receiverName: string,
memberName: string,
callerScope: ScopeId,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
) => SymbolDefinition | undefined;
/**
* Enable the receiver-bound Case 0.5 fallback for explicit `this`
* receivers (`this->m()` / `this.m()`) that resolves against the
* enclosing class + MRO even when no explicit `this` typeBinding is
* present in scope.
*
* Keep disabled for languages where the existing type-binding path
* (Case 4) already handles `this` correctly and overload ambiguity
* suppression must remain unchanged.
*/
readonly resolveThisViaEnclosingClass?: boolean;
/**
* Optional post-finalize hook to inject cross-file bindings that
* aren't modeled via explicit imports. Runs after

View file

@ -71,7 +71,12 @@ function isCallerAnchorLabel(label: NodeLabel): boolean {
*/
export function resolveDefGraphId(
filePath: string,
def: { qualifiedName?: string; type?: NodeLabel; parameterTypes?: readonly string[] },
def: {
qualifiedName?: string;
type?: NodeLabel;
parameterTypes?: readonly string[];
templateArguments?: readonly string[];
},
nodeLookup: GraphNodeLookup,
): string | undefined {
const qn = def.qualifiedName;
@ -89,6 +94,19 @@ export function resolveDefGraphId(
const pHit = nodeLookup.get(pKey);
if (pHit !== undefined) return pHit;
}
if (
(def.type === 'Class' ||
def.type === 'Struct' ||
def.type === 'Interface' ||
def.type === 'Enum' ||
def.type === 'Record') &&
def.templateArguments !== undefined &&
def.templateArguments.length > 0
) {
const tKey = qualifiedKey(filePath, def.type, `${qn}~${def.templateArguments.join(',')}`);
const tHit = nodeLookup.get(tKey);
if (tHit !== undefined) return tHit;
}
const qualifiedHit = nodeLookup.get(qualifiedKey(filePath, def.type, qn));
if (qualifiedHit !== undefined) return qualifiedHit;
}

View file

@ -67,6 +67,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
filePath?: string;
name?: string;
qualifiedName?: string;
templateArguments?: readonly string[];
};
if (props.filePath === undefined || props.name === undefined) continue;
if (!isLinkableLabel(node.label)) continue;
@ -96,6 +97,22 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
// Each overload is unique — set unconditionally.
lookup.set(pKey, node.id);
}
if (
(node.label === 'Class' ||
node.label === 'Struct' ||
node.label === 'Interface' ||
node.label === 'Enum' ||
node.label === 'Record') &&
props.templateArguments !== undefined &&
props.templateArguments.length > 0
) {
const tKey = qualifiedKey(
props.filePath,
node.label,
`${qualified}~${props.templateArguments.join(',')}`,
);
if (!lookup.has(tKey)) lookup.set(tKey, node.id);
}
}
// Fallback key: simple name. First-wins within a file — used when

View file

@ -42,7 +42,20 @@ export function emitFreeCallFallback(
readonly isCallableVisibleFromCaller?: (ctx: {
readonly callerParsed: ParsedFile;
readonly candidate: SymbolDefinition;
readonly callerScope?: ScopeId;
readonly scopes?: ScopeResolutionIndexes;
}) => boolean;
readonly resolveAdlCandidates?: (
site: {
readonly name: string;
readonly arity?: number;
readonly argumentTypes?: readonly string[];
readonly atRange: { readonly startLine: number; readonly startCol: number };
},
callerParsed: ParsedFile,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
) => SymbolDefinition | 'ambiguous' | undefined;
} = {},
): number {
let emitted = 0;
@ -75,6 +88,35 @@ export function emitFreeCallFallback(
if (fnDef === undefined) {
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
}
// V1 ADL tier (C++ Koenig lookup, opt-in via provider.resolveAdlCandidates).
// Fires only when ordinary lookup is empty — V1 limitation per
// plan 2026-05-13-001 U2; ISO C++ would merge ADL with ordinary lookup
// and run overload resolution over the union.
//
// Sentinel 'ambiguous': ADL surfaced multiple candidates with
// identical normalized parameter types (mirrors OVERLOAD_AMBIGUOUS).
// We mark the site handled so `emit-references` does not retry, and
// continue to the next site without emitting an edge.
if (fnDef === undefined && options.resolveAdlCandidates !== undefined) {
const adlResult = options.resolveAdlCandidates(
{
name: site.name,
arity: site.arity,
argumentTypes: site.argumentTypes,
atRange: { startLine: site.atRange.startLine, startCol: site.atRange.startCol },
},
parsed,
scopes,
parsedFiles,
);
if (adlResult === 'ambiguous') {
handledSites.add(`${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`);
continue;
}
if (adlResult !== undefined) {
fnDef = adlResult;
}
}
// V1: pickUniqueGlobalCallable ignores import context — resolves to any
// globally-unique callable. False cross-package edges are possible when
// the caller does not import the target package. Same-package calls are
@ -89,7 +131,12 @@ export function emitFreeCallFallback(
site.arity,
options.isCallableVisibleFromCaller !== undefined
? (candidate) =>
options.isCallableVisibleFromCaller!({ callerParsed: parsed, candidate })
options.isCallableVisibleFromCaller!({
callerParsed: parsed,
candidate,
callerScope: site.inScope,
scopes,
})
: undefined,
);
}

View file

@ -151,7 +151,8 @@ export function propagateImportedReturnTypes(
const refs = lookupBindingsAt(importerModule.id, localName, indexes);
for (const ref of refs) {
if (ref.origin !== 'import' && ref.origin !== 'reexport') continue;
if (ref.origin !== 'import' && ref.origin !== 'reexport' && ref.origin !== 'wildcard')
continue;
const sourceModule = moduleScopeByFile.get(ref.def.filePath);
if (sourceModule === undefined) continue;

View file

@ -88,3 +88,63 @@ export function narrowOverloadCandidates(
return candidates;
}
/**
* Detect when >1 candidate share identical `parameterTypes` after the
* per-language normalizer has collapsed distinct underlying types. This
* signals "the resolver cannot pick the right overload — the
* normalization that helps single-candidate flows now hides a real
* ambiguity" and lets callers suppress the edge rather than pick
* arbitrarily.
*
* Concrete trigger (PR #1520 review follow-up plan U2, Claude review
* Finding 5): the C++ `arity-metadata.ts` normalizer collapses `int`,
* `long`, `short`, `unsigned`, and `size_t` to `'int'`. Without this
* check, `process(int)` and `process(long)` both end up with
* `parameterTypes === ['int']`, and `pickOverload` arbitrarily picks
* the first — emitting a false CALLS edge to the wrong overload.
*
* Returns false when:
* - 0 or 1 candidates (no ambiguity to detect)
* - any candidate has undefined `parameterTypes` (can't compare)
* - candidates differ in arity or in any parameter-type slot
*
* Other languages: this check is a precondition gate, not a behavior
* change for normal narrowing. Languages whose normalizers do not
* collapse distinct types (verified by grep over `*-arity-metadata.ts`
* — no `int → int` collapse outside C++) will never produce >1
* candidate with identical `parameterTypes` from genuinely distinct
* declarations, so this returns false for them. The branch is
* effectively C++-only in practice.
*/
export function isOverloadAmbiguousAfterNormalization(
candidates: readonly SymbolDefinition[],
argCount?: number,
): boolean {
if (candidates.length < 2) return false;
const first = candidates[0].parameterTypes;
if (first === undefined) return false;
// When argCount is provided, compare only the first `argCount` slots —
// this catches default-argument ambiguity: `void f(int); void f(int, int = 0);`
// called with `f(1)` (argCount=1) leaves both candidates viable because
// default args make them arity-compatible, and their first slot is
// identical even though full parameterTypes lengths differ.
// Without argCount, fall back to full-sequence comparison (the original
// int/long normalization-collapse case).
const compareUpTo = argCount !== undefined ? argCount : first.length;
if (compareUpTo === 0) return false;
if (first.length < compareUpTo) return false;
for (let i = 1; i < candidates.length; i++) {
const p = candidates[i].parameterTypes;
if (p === undefined) return false;
if (p.length < compareUpTo) return false;
for (let j = 0; j < compareUpTo; j++) {
if (p[j] !== first[j]) return false;
}
// When argCount is NOT provided, also require length equality so
// distinct-arity candidates that happen to share a prefix don't
// collapse to ambiguous (preserves the original int/long contract).
if (argCount === undefined && p.length !== first.length) return false;
}
return true;
}

View file

@ -51,7 +51,14 @@ import {
import { tryEmitEdge } from '../graph-bridge/edges.js';
import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js';
import { resolveDefGraphId } from '../graph-bridge/ids.js';
import { narrowOverloadCandidates } from './overload-narrowing.js';
import {
narrowOverloadCandidates,
isOverloadAmbiguousAfterNormalization,
} from './overload-narrowing.js';
import {
extractTemplateArguments,
stripTemplateArguments,
} from '../../utils/template-arguments.js';
/** Subset of `ScopeResolver` consumed by this pass. Accepting the
* subset rather than the full provider keeps tests and partial
@ -59,12 +66,62 @@ import { narrowOverloadCandidates } from './overload-narrowing.js';
type ReceiverBoundProviderSubset = Pick<
ScopeResolver,
| 'isSuperReceiver'
| 'isSuperReceiverInContext'
| 'fieldFallbackOnMethodLookup'
| 'collapseMemberCallsByCallerTarget'
| 'unwrapCollectionAccessor'
| 'hoistTypeBindingsToModule'
| 'resolveQualifiedReceiverMember'
| 'resolveThisViaEnclosingClass'
>;
function normalizeTemplateArgToken(value: string): string {
return value.replace(/\s+/g, '');
}
function resolveClassBindingForName(
scopeId: string,
rawClassName: string,
scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
const direct = findClassBindingInScope(scopeId, rawClassName, scopes);
if (direct !== undefined) return direct;
if (!rawClassName.includes('<')) return undefined;
const baseName = stripTemplateArguments(rawClassName).replace(/\s+/g, '');
if (baseName.length === 0) return undefined;
const wantedArgs = extractTemplateArguments(rawClassName)?.map(normalizeTemplateArgToken);
if (wantedArgs !== undefined && wantedArgs.length > 0) {
// qualifiedNames is a Map and may not contain the stripped base name at all
// (e.g., unresolved type binding or only template-qualified entries), so
// default to [] before checking `.length`.
const qnameIds = scopes.qualifiedNames.get(baseName) ?? [];
if (qnameIds.length === 0) {
return findClassBindingInScope(scopeId, baseName, scopes);
}
const matches: SymbolDefinition[] = [];
for (const id of qnameIds) {
const def = scopes.defs.get(id);
if (def === undefined || !isClassLike(def.type)) continue;
const defArgs = def.templateArguments?.map(normalizeTemplateArgToken);
if (
defArgs !== undefined &&
defArgs.length === wantedArgs.length &&
defArgs.every((value, i) => value === wantedArgs[i])
) {
matches.push(def);
}
}
if (matches.length === 1) return matches[0];
// Scope extractor only records class definitions with bodies in C++, so
// forward declarations are not expected here. Keep fallback behavior for
// safety in non-ODR or mixed-language edge cases.
}
return findClassBindingInScope(scopeId, baseName, scopes);
}
export function emitReceiverBoundCalls(
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
@ -162,7 +219,14 @@ export function emitReceiverBoundCalls(
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
// ── super branch ─────────────────────────────────────────────
if (provider.isSuperReceiver(receiverName)) {
// Languages with caller-context-dependent super classification
// (C++) define `isSuperReceiverInContext`; we prefer it. Simple
// text-only languages (Python, Java, PHP) use the plain hook.
const isSuper =
provider.isSuperReceiverInContext !== undefined
? provider.isSuperReceiverInContext(receiverName, site.inScope, scopes)
: provider.isSuperReceiver(receiverName);
if (isSuper) {
const enclosingClass = findEnclosingClassDef(site.inScope, scopes);
if (enclosingClass !== undefined) {
// For super-receiver dispatch (`parent::`, `base.`, `super()`),
@ -258,6 +322,88 @@ export function emitReceiverBoundCalls(
}
}
// ── Case 0.5: implicit `this` receiver ───────────────────────
// C++ `this->member()` (and same-shape receivers in other OO
// languages) should resolve against the enclosing class + MRO
// even when there is no explicit `this` typeBinding in scope.
if (provider.resolveThisViaEnclosingClass === true && receiverName === 'this') {
const enclosingClass = findEnclosingClassDef(site.inScope, scopes);
if (enclosingClass !== undefined) {
const chain = [
enclosingClass.nodeId,
...scopes.methodDispatch.mroFor(enclosingClass.nodeId),
];
let memberDef: SymbolDefinition | undefined;
let ambiguous = false;
let hiddenByName = false;
for (const ownerId of chain) {
const methodOverloads = model.methods.lookupAllByOwner(ownerId, memberName);
if (methodOverloads.length > 0) {
const narrowed = narrowOverloadCandidates(
methodOverloads,
site.arity,
site.argumentTypes,
);
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) {
ambiguous = true;
break;
}
if (narrowed.length === 0) {
// C++ name hiding: if the derived class declares `f`, base-class
// overloads named `f` are hidden for member lookup
// ([basic.lookup.classref]). A non-viable derived overload set
// therefore terminates lookup instead of falling through to base.
hiddenByName = true;
break;
}
memberDef = narrowed[0] ?? methodOverloads[0];
break;
}
// Field/property lookup intentionally runs only after the method
// lookup above: in C++ member-name lookup, functions with this
// name hide same-named base members; we therefore prefer method
// candidates first and only target a field when no methods with
// this name exist on the current owner.
memberDef = model.fields.lookupFieldByOwner(ownerId, memberName);
if (memberDef !== undefined) {
break;
}
}
if (ambiguous) {
handledSites.add(siteKey);
continue;
}
if (hiddenByName) {
handledSites.add(siteKey);
continue;
}
if (memberDef !== undefined) {
const reason =
site.kind === 'write' || site.kind === 'read'
? site.kind
: memberDef.filePath !== parsed.filePath
? 'import-resolved'
: 'global';
const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85;
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
memberDef,
reason,
seen,
confidence,
collapse,
);
if (ok) emitted++;
handledSites.add(siteKey);
continue;
}
}
}
// ── Case 1: namespace receiver ───────────────────────────────
const targetFiles = namespaceTargets.get(receiverName);
if (targetFiles !== undefined) {
@ -285,6 +431,38 @@ export function emitReceiverBoundCalls(
if (found) continue;
}
// ── Case 1.5: qualified namespace-receiver (language-specific) ───
// Languages whose qualified-name semantics need workspace-wide
// namespace-scope walking (C++ `outer::foo()`, including inline-
// namespace transitive traversal) implement `resolveQualifiedReceiverMember`.
// Runs before Case 2 so namespace receivers don't accidentally match a
// class with the same simple name.
if (provider.resolveQualifiedReceiverMember !== undefined) {
const memberDef = provider.resolveQualifiedReceiverMember(
receiverName,
memberName,
site.inScope,
scopes,
parsedFiles,
);
if (memberDef !== undefined) {
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
memberDef,
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
seen,
0.85,
collapse,
);
if (ok) emitted++;
handledSites.add(siteKey);
continue;
}
}
// ── Case 2: class-name receiver ──────────────────────────────
const classDef = findClassBindingInScope(site.inScope, receiverName, scopes);
if (classDef !== undefined) {
@ -426,7 +604,7 @@ export function emitReceiverBoundCalls(
// ── Case 4: simple typeBinding (`u: U`) ──────────────────────
if (typeRef !== undefined && !typeRef.rawName.includes('.')) {
let ownerDef = findClassBindingInScope(site.inScope, typeRef.rawName, scopes);
let ownerDef = resolveClassBindingForName(site.inScope, typeRef.rawName, scopes);
// `findClassBindingInScope(..., typeRef.rawName)` only works when
// rawName is itself a class symbol reachable through scope bindings.
// For languages with namespace-style imports (Go), imported types
@ -454,9 +632,24 @@ export function emitReceiverBoundCalls(
if (ownerDef !== undefined) {
const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
let memberDef: SymbolDefinition | undefined;
let ambiguous = false;
for (const ownerId of chain) {
memberDef = pickOverload(ownerId, memberName, site, model);
if (memberDef !== undefined) break;
const picked = pickOverload(ownerId, memberName, site, model);
if (picked === OVERLOAD_AMBIGUOUS) {
ambiguous = true;
break;
}
if (picked !== undefined) {
memberDef = picked;
break;
}
}
if (ambiguous) {
// Suppress and mark handled so `emitReferencesViaLookup`
// doesn't re-emit the pre-resolved reference. See
// OVERLOAD_AMBIGUOUS docstring for the upstream cause.
handledSites.add(siteKey);
continue;
}
if (memberDef !== undefined) {
// For read/write ACCESSES, mirror the legacy DAG's reason
@ -509,7 +702,7 @@ function pickOverload(
memberName: string,
site: ParsedFile['referenceSites'][number],
model: SemanticModel,
): SymbolDefinition | undefined {
): SymbolDefinition | typeof OVERLOAD_AMBIGUOUS | undefined {
const overloads = model.methods.lookupAllByOwner(ownerId, memberName);
if (overloads.length === 0) {
// Non-callable member (field / property / variable) — ACCESSES
@ -520,5 +713,22 @@ function pickOverload(
if (overloads.length === 1) return overloads[0];
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes);
// When narrowing leaves >1 candidate that share identical normalized
// parameter-types (e.g., C++ `f(int)` vs `f(long)` both collapsed to
// `['int']` by `normalizeCppParamType`), suppress the edge entirely.
// The graph schema has no ambiguous-target edge model, so emitting one
// would arbitrarily pick a candidate and lie about the call's target.
// PR #1520 review follow-up plan U2 / Claude review Finding 5.
if (isOverloadAmbiguousAfterNormalization(candidates, site.arity)) return OVERLOAD_AMBIGUOUS;
return candidates[0] ?? overloads[0];
}
/**
* Sentinel returned by `pickOverload` when narrowing leaves >1 candidate
* sharing identical normalized parameter-types. Callers should suppress
* the CALLS edge AND mark the site as handled so `emitReferencesViaLookup`
* does not re-emit from the pre-resolved reference index. See
* `pickOverload` JSDoc for the upstream cause (per-language normalizer
* collapses distinct types in arity-metadata).
*/
export const OVERLOAD_AMBIGUOUS = Symbol('overload-ambiguous');

View file

@ -17,6 +17,7 @@ import { typescriptScopeResolver } from '../../languages/typescript/scope-resolv
import { goScopeResolver } from '../../languages/go/scope-resolver.js';
import { javaScopeResolver } from '../../languages/java/scope-resolver.js';
import { cScopeResolver } from '../../languages/c/scope-resolver.js';
import { cppScopeResolver } from '../../languages/cpp/scope-resolver.js';
import { phpScopeResolver } from '../../languages/php/scope-resolver.js';
/** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates
@ -33,5 +34,6 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.Go, goScopeResolver],
[SupportedLanguages.Java, javaScopeResolver],
[SupportedLanguages.C, cScopeResolver],
[SupportedLanguages.CPlusPlus, cppScopeResolver],
[SupportedLanguages.PHP, phpScopeResolver],
]);

View file

@ -32,16 +32,88 @@ import { extractParsedFile } from '../../scope-extractor-bridge.js';
import { finalizeScopeModel } from '../../finalize-orchestrator.js';
import { resolveReferenceSites, type ResolveStats } from '../../resolve-references.js';
import { buildGraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { resolveDefGraphId } from '../graph-bridge/ids.js';
import { buildPopulatedMethodDispatch } from '../graph-bridge/method-dispatch.js';
import { tryEmitEdge } from '../graph-bridge/edges.js';
import { propagateImportedReturnTypes } from '../passes/imported-return-types.js';
import { emitReceiverBoundCalls } from '../passes/receiver-bound-calls.js';
import { emitFreeCallFallback } from '../passes/free-call-fallback.js';
import { emitReferencesViaLookup } from '../graph-bridge/references-to-edges.js';
import { emitImportEdges } from '../graph-bridge/imports-to-edges.js';
import type { ScopeResolver } from '../contract/scope-resolver.js';
import { findClassBindingInScope, findEnclosingClassDef } from '../scope/walkers.js';
import { buildWorkspaceResolutionIndex } from '../workspace-index.js';
import { logger } from '../../../logger.js';
/**
* Resolve inheritance reference sites early and pre-emit their EXTENDS edges
* before MRO construction. This lets template-base captures contribute to the
* graph in time for `buildMro`, while `handledSites` prevents the generic
* reference-edge bridge from re-emitting the same sites later.
*
* @returns Site keys to seed the downstream handled-site skip set.
*/
function preEmitInheritanceEdges(
graph: KnowledgeGraph,
scopes: ReturnType<typeof finalizeScopeModel>,
nodeLookup: ReturnType<typeof buildGraphNodeLookup>,
): Set<string> {
const handledSites = new Set<string>();
const seen = new Set<string>();
const existing = new Set<string>();
for (const rel of graph.iterRelationshipsByType('EXTENDS')) {
existing.add(`${rel.sourceId}->${rel.targetId}`);
}
for (const site of scopes.referenceSites) {
if (site.kind !== 'inherits') continue;
const scope = scopes.scopeTree.getScope(site.inScope);
const siteKey =
scope?.filePath !== undefined
? `${scope.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`
: undefined;
if (siteKey !== undefined) {
// Intentionally suppress every `inherits` site from the generic
// reference bridge, even when this pre-pass can't emit an EXTENDS
// edge. The shared bridge resolves the source via
// `resolveCallerGraphId`, which can degrade class-heritage sites into
// method-owned EXTENDS edges once methods exist on the class. This
// pre-pass is the authoritative inheritance emitter, so broad
// suppression keeps `buildMro` and the final graph class-owned.
handledSites.add(siteKey);
}
const targetDef = findClassBindingInScope(site.inScope, site.name, scopes);
if (targetDef === undefined) continue;
const callerClass = findEnclosingClassDef(site.inScope, scopes);
if (callerClass === undefined) continue;
const callerGraphId = resolveDefGraphId(callerClass.filePath, callerClass, nodeLookup);
const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup);
if (callerGraphId === undefined || targetGraphId === undefined) continue;
const edgeKey = `${callerGraphId}->${targetGraphId}`;
if (existing.has(edgeKey)) continue;
if (
tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
targetDef,
'scope-resolution: inherits',
seen,
0.85,
)
) {
existing.add(edgeKey);
}
}
return handledSites;
}
interface RunScopeResolutionInput {
readonly graph: KnowledgeGraph;
/**
@ -183,8 +255,6 @@ export function runScopeResolution(
// ── Phase 2: finalize → ScopeResolutionIndexes ─────────────────────────
const allFilePaths = new Set(parsedFiles.map((f) => f.filePath));
const nodeLookup = buildGraphNodeLookup(graph);
const mroByClassDefId = provider.buildMro(graph, parsedFiles, nodeLookup);
const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.(graph, parsedFiles, nodeLookup);
const resolutionConfig = input.resolutionConfig;
const finalized = finalizeScopeModel(parsedFiles, {
@ -197,6 +267,9 @@ export function runScopeResolution(
provider.mergeBindings(existing, incoming, scopeId),
},
});
const preEmittedInheritanceSites = preEmitInheritanceEdges(graph, finalized, nodeLookup);
const mroByClassDefId = provider.buildMro(graph, parsedFiles, nodeLookup);
const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.(graph, parsedFiles, nodeLookup);
// Replace the empty MethodDispatchIndex that finalizeScopeModel
// builds by design with the populated one derived from the
@ -273,7 +346,7 @@ export function runScopeResolution(
const tResolve = PROF ? process.hrtime.bigint() : 0n;
// ── Phase 4: emit graph edges (LOAD-BEARING ORDER — see I1) ────────────
const handledSites = new Set<string>();
const handledSites = new Set<string>(preEmittedInheritanceSites);
const receiverExtras = emitReceiverBoundCalls(
graph,
indexes,
@ -308,6 +381,7 @@ export function runScopeResolution(
allowGlobalFallback: provider.allowGlobalFreeCallFallback === true,
isFileLocalDef: provider.isFileLocalDef,
isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller,
resolveAdlCandidates: provider.resolveAdlCandidates,
},
);
const { emitted, skipped } = emitReferencesViaLookup(

View file

@ -680,7 +680,15 @@ export const GO_QUERIES = `
export const CPP_QUERIES = `
; Classes, Structs, Namespaces
(class_specifier name: (type_identifier) @name) @definition.class
(class_specifier
name: (template_type
(type_identifier) @name
(template_argument_list) @template-arguments)) @definition.class
(struct_specifier name: (type_identifier) @name) @definition.struct
(struct_specifier
name: (template_type
(type_identifier) @name
(template_argument_list) @template-arguments)) @definition.struct
(namespace_definition name: (namespace_identifier) @name) @definition.namespace
(enum_specifier name: (type_identifier) @name) @definition.enum
@ -762,6 +770,11 @@ export const CPP_QUERIES = `
; Templates
(template_declaration (class_specifier name: (type_identifier) @name)) @definition.template
(template_declaration
(class_specifier
name: (template_type
(type_identifier) @name
(template_argument_list) @template-arguments))) @definition.template
(template_declaration (function_definition declarator: (function_declarator declarator: (identifier) @name))) @definition.template
; Includes

View file

@ -2,6 +2,11 @@ import type Parser from 'tree-sitter';
import type { Capture, NodeLabel, Range } from 'gitnexus-shared';
import type { LanguageProvider } from '../language-provider.js';
import { generateId } from '../../../lib/utils.js';
import {
extractTemplateArguments,
stripTemplateArguments,
templateArgumentsIdTag,
} from './template-arguments.js';
/** Tree-sitter AST node. Re-exported for use across ingestion modules. */
export type SyntaxNode = Parser.SyntaxNode;
@ -390,8 +395,13 @@ export const findEnclosingClassInfo = (
) {
label = 'Interface';
}
const templateArguments = extractTemplateArguments(nameNode.text);
const classIdName =
templateArguments !== undefined
? `${stripTemplateArguments(nameNode.text)}${templateArgumentsIdTag(templateArguments)}`
: nameNode.text;
return {
classId: generateId(label, `${filePath}:${nameNode.text}`),
classId: generateId(label, `${filePath}:${classIdName}`),
className: nameNode.text,
};
}

View file

@ -0,0 +1,57 @@
/**
* Parse top-level generic/template arguments from a type-like string.
*
* Examples:
* - `List<int>` -> ['int']
* - `Map<string, vector<int>>` -> ['string', 'vector<int>']
* - `List<T*>` -> ['T*']
*/
export function extractTemplateArguments(text: string): string[] | undefined {
const start = text.indexOf('<');
if (start === -1) return undefined;
let depth = 0;
let end = -1;
for (let i = start; i < text.length; i += 1) {
const ch = text[i];
if (ch === '<') depth += 1;
else if (ch === '>') {
depth -= 1;
if (depth === 0) {
end = i;
break;
}
if (depth < 0) return undefined;
}
}
if (end === -1) return undefined;
const inner = text.slice(start + 1, end);
if (inner.trim().length === 0) return undefined;
const args: string[] = [];
let tokenStart = 0;
let nested = 0;
for (let i = 0; i < inner.length; i += 1) {
const ch = inner[i];
if (ch === '<') nested += 1;
else if (ch === '>') nested -= 1;
else if (ch === ',' && nested === 0) {
const token = inner.slice(tokenStart, i).replace(/\s+/g, '');
if (token.length > 0) args.push(token);
tokenStart = i + 1;
}
}
const last = inner.slice(tokenStart).replace(/\s+/g, '');
if (last.length > 0) args.push(last);
return args.length > 0 ? args : undefined;
}
export function stripTemplateArguments(text: string): string {
const start = text.indexOf('<');
if (start === -1) return text;
return text.slice(0, start);
}
export function templateArgumentsIdTag(templateArguments?: readonly string[]): string {
if (templateArguments === undefined || templateArguments.length === 0) return '';
return `~${templateArguments.join(',')}`;
}

View file

@ -82,6 +82,7 @@ import {
constTagForId,
buildCollisionGroups,
} from '../utils/method-props.js';
import { extractTemplateArguments, templateArgumentsIdTag } from '../utils/template-arguments.js';
import type { LanguageProvider } from '../language-provider.js';
import type { ParsedFile } from 'gitnexus-shared';
import { extractParsedFile } from '../scope-extractor-bridge.js';
@ -129,6 +130,7 @@ interface ParsedSymbol {
parameterTypes?: string[];
returnType?: string;
declaredType?: string;
templateArguments?: string[];
ownerId?: string;
visibility?: string;
isStatic?: boolean;
@ -181,6 +183,8 @@ export interface ExtractedAssignment {
propertyName: string;
/** Resolved type name of the receiver if available from TypeEnv */
receiverTypeName?: string;
/** 1-indexed line number of the assignment site (used for per-site dedup) */
line?: number;
}
// `ExtractedHeritage` now lives in `../model/heritage-map.ts` and is
@ -1580,6 +1584,7 @@ const processFileGroup = (
sourceId: srcId,
receiverText,
propertyName,
line: captureMap['assignment'].startPosition.row + 1,
...(receiverTypeName ? { receiverTypeName } : {}),
});
}
@ -1998,6 +2003,23 @@ const processFileGroup = (
})
: null;
const nodeLabel = extractedClassSymbol?.type ?? defaultNodeLabel;
const isClassLikeLabel =
nodeLabel === 'Class' ||
nodeLabel === 'Struct' ||
nodeLabel === 'Interface' ||
nodeLabel === 'Enum' ||
nodeLabel === 'Record';
if (
isClassLikeLabel &&
provider.classExtractor?.shouldSkipClassCapture?.({
captureMap,
definitionNode,
nameNode,
nodeLabel,
}) === true
) {
continue;
}
// Dedup: variable captures (Const/Static/Variable) may overlap with higher-priority
// captures (e.g. `const fn = () => {}` matches both @definition.function and @definition.const).
@ -2111,7 +2133,31 @@ const processFileGroup = (
);
arityTag += constTagForId(defMethodMap, nodeName, arityForId, defMethodInfo, groups);
}
const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}${arityTag}`);
const classTemplateArguments =
extractedClassSymbol?.templateArguments ??
provider.classExtractor?.extractTemplateArgumentsFromCapture?.({
captureMap,
definitionNode,
nameNode,
}) ??
(captureMap['template-arguments']
? extractTemplateArguments(captureMap['template-arguments'].text)
: undefined) ??
(nameNode && nameNode.text ? extractTemplateArguments(nameNode.text) : undefined);
const classTemplateTag =
(nodeLabel === 'Class' ||
nodeLabel === 'Struct' ||
nodeLabel === 'Interface' ||
nodeLabel === 'Enum' ||
nodeLabel === 'Record') &&
classTemplateArguments !== undefined &&
classTemplateArguments.length > 0
? templateArgumentsIdTag(classTemplateArguments)
: '';
const nodeId = generateId(
nodeLabel,
`${file.path}:${qualifiedName}${classTemplateTag}${arityTag}`,
);
const classNodeForSymbol = definitionNode || nameNode;
const qualifiedTypeName =
extractedClassSymbol?.qualifiedName ??
@ -2234,6 +2280,9 @@ const processFileGroup = (
? isVueSetupTopLevel(nameNode || definitionNode)
: cachedExportCheck(provider.exportChecker, nameNode || definitionNode, nodeName),
...(qualifiedTypeName !== undefined ? { qualifiedName: qualifiedTypeName } : {}),
...(classTemplateArguments !== undefined && classTemplateArguments.length > 0
? { templateArguments: classTemplateArguments }
: {}),
...(frameworkHint
? {
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
@ -2259,6 +2308,9 @@ const processFileGroup = (
parameterTypes: methodProps.parameterTypes as string[] | undefined,
returnType: methodProps.returnType as string | undefined,
...(declaredType !== undefined ? { declaredType } : {}),
...(classTemplateArguments !== undefined && classTemplateArguments.length > 0
? { templateArguments: classTemplateArguments }
: {}),
...(enclosingClassId ? { ownerId: enclosingClassId } : {}),
visibility: methodProps.visibility as string | undefined,
isStatic: methodProps.isStatic as boolean | undefined,

View file

@ -11,6 +11,7 @@ import { realpathSync } from 'fs';
import path from 'path';
import os from 'os';
import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js';
import { logger } from '../core/logger.js';
/**
* Normalise a repo path for registry comparison across platforms
@ -281,14 +282,44 @@ export const findRepo = async (startPath: string): Promise<IndexedRepo | null> =
return null;
};
function isReadOnlyFilesystemError(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException)?.code;
return code === 'EROFS' || code === 'EACCES' || code === 'EPERM';
}
/**
* Keep generated index files ignored without modifying the user's root .gitignore.
*/
export const ensureGitNexusIgnored = async (repoPath: string): Promise<void> => {
const gitignorePath = path.join(getStoragePath(repoPath), '.gitignore');
const desired = '*\n';
await fs.mkdir(path.dirname(gitignorePath), { recursive: true });
await fs.writeFile(gitignorePath, '*\n', 'utf-8');
// Idempotent fast path: skip the write entirely when the file already has
// the expected content. Lets this run cleanly on read-only mounts (e.g.
// the documented Docker workflow with WORKSPACE_DIR bound :ro) when an
// earlier `analyze` already created the file. See issue #1549.
try {
if ((await fs.readFile(gitignorePath, 'utf-8')) === desired) {
await ensureGitInfoExclude(repoPath);
return;
}
} catch (err: any) {
if (err?.code !== 'ENOENT') throw err;
}
try {
await fs.mkdir(path.dirname(gitignorePath), { recursive: true });
await fs.writeFile(gitignorePath, desired, 'utf-8');
} catch (err: any) {
if (isReadOnlyFilesystemError(err)) {
logger.warn(
{ path: gitignorePath, code: err.code },
'GitNexus storage filesystem is not writable; skipping .gitnexus/.gitignore. Generated files may appear as untracked in this repo locally.',
);
} else {
throw err;
}
}
await ensureGitInfoExclude(repoPath);
};
@ -304,8 +335,6 @@ const ensureGitInfoExclude = async (repoPath: string): Promise<void> => {
return;
}
await fs.mkdir(path.dirname(excludePath), { recursive: true });
let content = '';
try {
content = await fs.readFile(excludePath, 'utf-8');
@ -320,7 +349,19 @@ const ensureGitInfoExclude = async (repoPath: string): Promise<void> => {
if (excludes.includes(GITNEXUS_DIR) || excludes.includes(GITNEXUS_EXCLUDE_ENTRY)) return;
const separator = content.length === 0 || content.endsWith('\n') ? '' : '\n';
await fs.writeFile(excludePath, `${content}${separator}${GITNEXUS_EXCLUDE_ENTRY}\n`, 'utf-8');
try {
await fs.mkdir(path.dirname(excludePath), { recursive: true });
await fs.writeFile(excludePath, `${content}${separator}${GITNEXUS_EXCLUDE_ENTRY}\n`, 'utf-8');
} catch (err: any) {
if (isReadOnlyFilesystemError(err)) {
logger.warn(
{ path: excludePath, code: err.code },
'GitNexus storage filesystem is not writable; skipping .git/info/exclude update. .gitnexus/ may appear as untracked in `git status` locally.',
);
} else {
throw err;
}
}
};
// ─── Global Registry (~/.gitnexus/registry.json) ───────────────────────

View file

@ -0,0 +1,7 @@
#pragma once
namespace alpha {
struct Token {};
void process(Token t, int n);
void process(Token t, long n);
}

View file

@ -0,0 +1,8 @@
#include "alpha.h"
namespace app {
void run() {
alpha::Token t;
process(t, 42);
}
}

View file

@ -0,0 +1,14 @@
#include "base_lib.h"
namespace app {
struct Token : base_one::Base {};
void run() {
Token t;
collide(t);
}
}
namespace other {
struct Token : base_two::Base {};
}

View file

@ -0,0 +1,11 @@
#pragma once
namespace base_one {
struct Base {};
void collide(Base);
}
namespace base_two {
struct Base {};
void collide(Base);
}

View file

@ -0,0 +1,16 @@
#include "base_lib.h"
namespace app {
struct HiddenDerived : HiddenBase {};
struct MissingDerived : missing_ns::UnknownBase {};
void run_hidden() {
HiddenDerived d;
hidden_probe(d);
}
void run_missing() {
MissingDerived d;
unresolved_probe(d);
}
}

View file

@ -0,0 +1,6 @@
#pragma once
namespace {
struct HiddenBase {};
void hidden_probe(HiddenBase);
}

View file

@ -0,0 +1,22 @@
#include "base_lib.h"
namespace app {
struct Derived : base_lib::Base {};
struct MultiLevel : middle_lib::Mid {};
struct DiamondDerived : diamond_lib::LeftBranch, diamond_lib::RightBranch {};
void run_single() {
Derived d;
log(d);
}
void run_multi() {
MultiLevel m;
trace(m);
}
void run_diamond() {
DiamondDerived d;
ping(d);
}
}

View file

@ -0,0 +1,20 @@
#pragma once
namespace base_lib {
struct Base {};
void log(Base);
struct Root {};
void trace(Root);
}
namespace middle_lib {
struct Mid : base_lib::Root {};
}
namespace diamond_lib {
struct DiamondBase {};
struct LeftBranch : DiamondBase {};
struct RightBranch : DiamondBase {};
void ping(DiamondBase);
}

View file

@ -0,0 +1,8 @@
#include "audit.h"
namespace app {
void run() {
audit::Event e;
record(e);
}
}

View file

@ -0,0 +1,6 @@
#pragma once
namespace audit {
struct Event {};
void record(Event e);
}

View file

@ -0,0 +1,8 @@
#include "audit.h"
namespace app {
void run() {
void (*g)();
record(g);
}
}

View file

@ -0,0 +1,5 @@
#pragma once
namespace audit {
void record(void (*g)());
}

View file

@ -0,0 +1,9 @@
#include "audit.h"
namespace app {
void run() {
void (*fp)();
audit::Event e;
record(e);
}
}

View file

@ -0,0 +1,6 @@
#pragma once
namespace audit {
struct Event {};
void record(Event e);
}

View file

@ -0,0 +1,8 @@
#include "audit.h"
namespace app {
void run() {
audit::Event (*factory)();
record(factory);
}
}

View file

@ -0,0 +1,6 @@
#pragma once
namespace audit {
struct Event {};
void record(Event (*factory)());
}

View file

@ -0,0 +1,8 @@
#include "audit.h"
namespace app {
void run() {
audit::Event* p;
record(p);
}
}

View file

@ -0,0 +1,6 @@
#pragma once
namespace audit {
struct Event {};
void record(Event* e);
}

View file

@ -0,0 +1,8 @@
#include "audit.h"
namespace app {
void run() {
audit::Event** pp;
record(pp);
}
}

View file

@ -0,0 +1,6 @@
#pragma once
namespace audit {
struct Event {};
void record(Event** e);
}

View file

@ -0,0 +1,21 @@
#include "audit.h"
namespace app {
void runRef() {
audit::Event e;
audit::Event& s = e;
record(s);
}
void runConstRef() {
audit::Event e;
const audit::Event& constEventRef = e;
recordConst(constEventRef);
}
void runPrimitiveRef() {
int n = 0;
int& r = n;
note(r);
}
}

View file

@ -0,0 +1,5 @@
#pragma once
namespace audit {
struct Event {};
}

View file

@ -0,0 +1,8 @@
#pragma once
#include "audit.h"
namespace audit {
void record(Event& e);
void recordConst(const Event& e);
}

View file

@ -0,0 +1,9 @@
#include "audit.h"
namespace app {
void runRvalueRef() {
audit::Event e;
audit::Event&& rr = static_cast<audit::Event&&>(e);
record(rr);
}
}

View file

@ -0,0 +1,5 @@
#pragma once
namespace audit {
struct Event {};
}

View file

@ -0,0 +1,7 @@
#pragma once
#include "audit.h"
namespace audit {
void record(Event&& e);
}

View file

@ -0,0 +1,8 @@
#include "audit.h"
namespace app {
void run() {
audit::Event e;
(record)(e);
}
}

View file

@ -0,0 +1,6 @@
#pragma once
namespace audit {
struct Event {};
void record(Event e);
}

View file

@ -0,0 +1,23 @@
#include "audit.h"
namespace app {
void run() {
std::vector<N::T> v;
apply(v);
}
void runNested() {
std::map<std::string, std::vector<N::T>> m;
applyNested(m);
}
void runArray() {
std::array<N::T, 4> a;
applyArray(a);
}
void runStdConflict() {
std::vector<N::T> v;
applyStdConflict(v);
}
}

View file

@ -0,0 +1,19 @@
#pragma once
#include <array>
#include <map>
#include <string>
#include <vector>
namespace N {
struct T {};
void apply(std::vector<T> v);
void applyNested(std::map<std::string, std::vector<T>> m);
void applyArray(std::array<T, 4> a);
void applyStdConflict(std::vector<T> v);
}
namespace std {
void applyStdConflict(vector<N::T> v);
}

View file

@ -0,0 +1,5 @@
void worker();
void run() {
worker();
}

View file

@ -0,0 +1,7 @@
namespace {
void worker() {}
}
void helper_entry() {
worker();
}

View file

@ -0,0 +1,7 @@
namespace {
void w() {}
}
void run() {
w();
}

View file

@ -0,0 +1,5 @@
#include "user.h"
void run() {
save();
}

View file

@ -0,0 +1,6 @@
#pragma once
class User {
public:
void save();
};

View file

@ -0,0 +1,5 @@
#include "lib.h"
void run() {
foo();
}

View file

@ -0,0 +1,5 @@
#pragma once
namespace ns {
void foo();
}

View file

@ -0,0 +1,8 @@
#include "audit.h"
namespace app {
void run() {
audit::Event e;
record(e);
}
}

View file

@ -0,0 +1,8 @@
#pragma once
namespace audit {
inline namespace v1 {
struct Event {};
void record(Event e);
}
}

View file

@ -0,0 +1,5 @@
#include "lib.h"
void run() {
outer::foo();
}

View file

@ -0,0 +1,9 @@
#pragma once
namespace outer {
inline namespace v1 {
inline namespace experimental {
void foo();
}
}
}

View file

@ -0,0 +1,5 @@
#include "lib.h"
void run() {
outer::foo();
}

View file

@ -0,0 +1,7 @@
#pragma once
namespace outer {
inline namespace v1 {
void foo();
}
}

View file

@ -0,0 +1,5 @@
#include "lib.h"
void run() {
outer::foo();
}

View file

@ -0,0 +1,10 @@
#pragma once
namespace outer {
inline namespace v1 {
void foo();
}
namespace v0 {
void foo();
}
}

View file

@ -0,0 +1,5 @@
#include "singleton.h"
void run() {
Singleton::getInstance();
}

View file

@ -0,0 +1,6 @@
#pragma once
class Singleton {
public:
static Singleton* getInstance();
};

View file

@ -0,0 +1,6 @@
#include "service.h"
void run() {
S s;
s.f(1);
}

Some files were not shown because too many files have changed in this diff Show more