mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-23 00:41:36 +00:00
Merge branch 'main' into feat/Desktop-app
This commit is contained in:
commit
7fdfd91f3c
281 changed files with 20001 additions and 910 deletions
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
|
|
@ -158,6 +158,8 @@ jobs:
|
|||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: '*'
|
||||
show_full_output: true
|
||||
# Review posts use Bash (`gh`, etc.); default mode asks for approval — impossible in CI.
|
||||
claude_args: '--dangerously-skip-permissions'
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review https://github.com/${{ github.repository }}/pull/${{ steps.pr.outputs.number }} --comment'
|
||||
|
|
|
|||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
|
|
@ -135,7 +135,7 @@ jobs:
|
|||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
|
|
|
|||
21
AGENTS.md
21
AGENTS.md
|
|
@ -156,11 +156,16 @@ npx gitnexus analyze
|
|||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze # basic refresh; preserves any existing embeddings
|
||||
npx gitnexus analyze # incremental by default; preserves embeddings
|
||||
npx gitnexus analyze --force # full rebuild from scratch (opt out of incremental)
|
||||
npx gitnexus analyze --embeddings # also generate embeddings for new/changed nodes
|
||||
npx gitnexus analyze --drop-embeddings # explicit opt-in to wipe existing embeddings
|
||||
```
|
||||
|
||||
`analyze` runs **incrementally by default**. The pipeline still parses every file every run (cross-file resolution requires it), but tree-sitter parsing is **served from a content-addressed cache** at `.gitnexus/parse-cache.json` for chunks whose file contents haven't changed since the last run. Only changed-file rows (and their importers) are rewritten in LadybugDB; unchanged-file rows are preserved. Output is byte-equivalent to a full rebuild. Pass `--force` to wipe and re-index from scratch (e.g., to recover from a corrupt index, or after upgrading GitNexus).
|
||||
|
||||
The parse cache key is **content-addressed and version-tagged**: it survives `--force` runs, and is automatically invalidated by a `gitnexus` package upgrade (so a new tree-sitter grammar doesn't silently replay stale parse output). Safe to delete `.gitnexus/parse-cache.json` at any time — it'll be rebuilt on the next analyze.
|
||||
|
||||
Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). A plain `analyze` no longer drops existing vectors — pass `--drop-embeddings` to wipe.
|
||||
|
||||
> Claude Code: PostToolUse hook detects a stale index after `git commit` and `git merge` and prompts the agent to run `analyze`. The hook does not invoke `analyze` itself.
|
||||
|
|
@ -176,6 +181,20 @@ Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). A plain `analyze` no
|
|||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.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
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ RUN npm prune --omit=dev --prefix gitnexus
|
|||
# node:22-bookworm-slim
|
||||
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
|
||||
|
||||
# curl for the healthcheck; git so `gitnexus` can clone repos at runtime.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl git && rm -rf /var/lib/apt/lists/* \
|
||||
# curl for the healthcheck; git for cloning; ca-certificates for TLS verification.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates && rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /usr/local/lib/node_modules/npm \
|
||||
&& rm -rf /usr/local/lib/node_modules/corepack \
|
||||
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
|
||||
|
|
@ -58,6 +58,15 @@ COPY --from=builder --chown=node:node /app/gitnexus/package.json ./gitnexus/pack
|
|||
COPY --from=builder --chown=node:node /app/gitnexus/scripts/install-duckdb-extension.mjs ./gitnexus/scripts/install-duckdb-extension.mjs
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor
|
||||
|
||||
# Expose the `gitnexus` binary on PATH so the documented Docker workflow
|
||||
# (`docker compose exec gitnexus-server gitnexus index /workspace/<repo>`)
|
||||
# works without users having to invoke `node /app/gitnexus/dist/cli/index.js`.
|
||||
# `npm prune --omit=dev` in the builder stage strips `node_modules/.bin/`
|
||||
# entries, so the `gitnexus` bin declared in package.json (`dist/cli/index.js`,
|
||||
# which already carries `#!/usr/bin/env node` and 755 perms) is otherwise
|
||||
# unreachable from $PATH.
|
||||
RUN ln -s /app/gitnexus/dist/cli/index.js /usr/local/bin/gitnexus
|
||||
|
||||
USER node
|
||||
|
||||
# The web UI defaults to http://localhost:4747 - keep that contract.
|
||||
|
|
|
|||
|
|
@ -30,9 +30,15 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
|
|||
### Stale graph after edits
|
||||
|
||||
- **Trigger:** MCP warns index is behind `HEAD`, or search doesn't match latest commit.
|
||||
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used).
|
||||
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used). Runs incrementally by default — the pipeline parses every file every run (cross-file resolution requires it), but tree-sitter dispatch is skipped for unchanged file chunks via the content-addressed cache, and only changed-file rows (plus their importers, transitively) are rewritten in LadybugDB.
|
||||
- **Why:** Tools query LadybugDB from last analyze; git changes are invisible until re-indexed.
|
||||
|
||||
### Index seems corrupt or "incremental" is misbehaving
|
||||
|
||||
- **Trigger:** `analyze` produces unexpected results, or `meta.json.incrementalInProgress` is set, or the index is in a half-state after a crash.
|
||||
- **Do:** `npx gitnexus analyze --force` to rebuild from scratch. The dirty-flag check forces this automatically when a previous incremental run didn't complete cleanly, but `--force` is the manual escape hatch. Safe to delete `.gitnexus/parse-cache.json` at any time — content-addressed, will be regenerated.
|
||||
- **Why:** Incremental writeback is selective DB row replacement; if the on-disk state is inconsistent for any reason, a full rebuild is the cheapest path back to a known-good index.
|
||||
|
||||
### Embeddings vanished after analyze
|
||||
|
||||
- **Trigger:** Semantic search quality drops; `stats.embeddings` in `meta.json` is 0 after refresh.
|
||||
|
|
|
|||
|
|
@ -722,6 +722,12 @@ gitnexus wiki --base-url https://api.anthropic.com/v1
|
|||
|
||||
# Force full regeneration
|
||||
gitnexus wiki --force
|
||||
|
||||
|
||||
# Increase the timeout or retries for large codebase or slow LLM providers
|
||||
gitnexus wiki --timeout <seconds> # Per-attempt LLM request timeout in seconds (default: 60)
|
||||
gitnexus wiki --retries <n> # Max LLM retry attempts per request (default: 3)
|
||||
|
||||
```
|
||||
|
||||
The wiki generator reads the indexed graph structure, groups files into modules via LLM, generates per-module documentation pages, and creates an overview page — all with cross-references to the knowledge graph.
|
||||
|
|
|
|||
6
eval/uv.lock
generated
6
eval/uv.lock
generated
|
|
@ -2278,11 +2278,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
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.
|
||||
|
|
@ -102,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.
|
||||
*/
|
||||
|
|
@ -169,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;
|
||||
|
|
@ -217,7 +250,8 @@ function sendHookResponse(hookEventName, message) {
|
|||
function handlePreToolUse(input) {
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return;
|
||||
if (!findGitNexusDir(cwd)) return;
|
||||
const gitNexusDir = findGitNexusDir(cwd);
|
||||
if (!gitNexusDir) return;
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const toolInput = input.tool_input || {};
|
||||
|
|
@ -226,19 +260,28 @@ 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;
|
||||
|
||||
let result = '';
|
||||
try {
|
||||
const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000);
|
||||
if (!child.error && child.status === 0) {
|
||||
result = child.stderr || '';
|
||||
result = extractAugmentContext(child.stderr || '');
|
||||
}
|
||||
} catch {
|
||||
/* graceful failure */
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
|
||||
if (result && result.trim()) {
|
||||
sendHookResponse('PreToolUse', result.trim());
|
||||
if (result) {
|
||||
sendHookResponse('PreToolUse', result);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
238
gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs
Normal file
238
gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs
Normal 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,
|
||||
};
|
||||
119
gitnexus-claude-plugin/hooks/hook-lock.js
Normal file
119
gitnexus-claude-plugin/hooks/hook-lock.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const HOOK_LOCK_SUBDIR = '.hook-locks';
|
||||
const HOOK_LOCK_MAX_INFLIGHT = 3;
|
||||
const HOOK_LOCK_STALE_MS = 30000;
|
||||
|
||||
function acquireHookSlot(gitNexusDir) {
|
||||
const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR);
|
||||
try {
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
} catch {
|
||||
// Cannot create lock dir (read-only fs, cross-user perm denial, out of
|
||||
// inodes, etc.) — fail closed by returning null. Caller skips augment.
|
||||
// Fail-open here would let N concurrent hooks all proceed unguarded and
|
||||
// reintroduce the #1486 fan-out the guard exists to prevent.
|
||||
return null;
|
||||
}
|
||||
|
||||
const myPidStr = String(process.pid);
|
||||
|
||||
for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) {
|
||||
const slotPath = path.join(lockDir, `slot-${slot}.lock`);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' });
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
try {
|
||||
// Only unlink if we still own the slot. If we appeared stale and
|
||||
// another hook took over, the file now belongs to it — leave alone.
|
||||
const content = fs.readFileSync(slotPath, 'utf-8').trim();
|
||||
if (content === myPidStr) fs.unlinkSync(slotPath);
|
||||
} catch {
|
||||
/* already removed or unreadable */
|
||||
}
|
||||
};
|
||||
process.on('exit', release);
|
||||
return release;
|
||||
} catch {
|
||||
// Slot exists. Decide whether to take it over.
|
||||
// Open once and inspect mtime + content via the same fd so there's
|
||||
// no TOCTOU between the metadata check and the content read
|
||||
// (codeql js/file-system-race).
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(slotPath, 'r');
|
||||
} catch {
|
||||
continue; // Vanished between EEXIST and open — retry this slot.
|
||||
}
|
||||
let isLive = false;
|
||||
let mtimeMs = Date.now();
|
||||
try {
|
||||
mtimeMs = fs.fstatSync(fd).mtimeMs;
|
||||
const buf = Buffer.alloc(32);
|
||||
const n = fs.readSync(fd, buf, 0, 32, 0);
|
||||
const ownerStr = buf.slice(0, n).toString('utf-8').trim();
|
||||
if (ownerStr === '') {
|
||||
// Owner created the file but hasn't written its PID yet. The
|
||||
// wx open+write window is microseconds; give it the benefit
|
||||
// of the doubt and treat as live.
|
||||
isLive = true;
|
||||
} else {
|
||||
const owner = Number.parseInt(ownerStr, 10);
|
||||
if (Number.isFinite(owner) && owner > 0) {
|
||||
try {
|
||||
process.kill(owner, 0);
|
||||
isLive = true;
|
||||
} catch (e) {
|
||||
// ESRCH = process gone → treat as dead. EPERM = process exists
|
||||
// but owned by another user (cross-user lock dir) → still alive,
|
||||
// keep the slot. Anything else: be conservative, assume alive.
|
||||
if (e && e.code === 'ESRCH') {
|
||||
isLive = false;
|
||||
} else {
|
||||
isLive = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* unreadable — treat as dead */
|
||||
} finally {
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
// For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins —
|
||||
// a slow-but-alive hook is never wrongly evicted. For older slots,
|
||||
// age is the final arbiter as a defense against PID reuse on long-
|
||||
// abandoned slots. 30s >> the 7s augment timeout, so a healthy run
|
||||
// never crosses this threshold.
|
||||
if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) {
|
||||
isLive = false;
|
||||
}
|
||||
if (isLive) break; // Try the next slot.
|
||||
try {
|
||||
fs.unlinkSync(slotPath);
|
||||
} catch {
|
||||
/* another hook beat us to it — retry will hit EEXIST */
|
||||
}
|
||||
// Loop and retry this slot.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HOOK_LOCK_SUBDIR,
|
||||
HOOK_LOCK_MAX_INFLIGHT,
|
||||
HOOK_LOCK_STALE_MS,
|
||||
acquireHookSlot,
|
||||
};
|
||||
76
gitnexus-claude-plugin/hooks/win-rm-list-json.ps1
Normal file
76
gitnexus-claude-plugin/hooks/win-rm-list-json.ps1
Normal 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
|
||||
|
|
@ -62,6 +62,8 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
|
|||
| `--api-key <key>` | LLM API key |
|
||||
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
||||
| `--gist` | Publish wiki as a public GitHub Gist |
|
||||
| `--timeout <seconds>` | Per-attempt LLM request timeout in seconds (default: 60) |
|
||||
| `--retries <n>` | Max LLM retry attempts per request (default: 3) |
|
||||
|
||||
### list — Show all indexed repos
|
||||
|
||||
|
|
|
|||
|
|
@ -10,20 +10,21 @@ Static config that adds GitNexus knowledge-graph augmentation and skill files to
|
|||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| **MCP** | `gitnexus` MCP server with 16 tools (`query`, `context`, `impact`, `detect_changes`, `rename`, …) | `npx gitnexus setup` writes `~/.cursor/mcp.json` automatically. |
|
||||
| **Skills** | `/gitnexus-exploring`, `/gitnexus-debugging`, `/gitnexus-impact-analysis`, `/gitnexus-refactoring`, `/gitnexus-pr-review` markdown skills | `npx gitnexus setup` copies them to `~/.cursor/skills/gitnexus/`. |
|
||||
| **Hooks** _(this README)_ | `postToolUse` hook that enriches `Shell` / `Read` / `Grep` tool calls with graph context — same augmentation Claude Code gets | **Manual** — copy the two files described below into your project's `.cursor/`. |
|
||||
| **Hooks** _(this README)_ | `postToolUse` hook that enriches `Shell` / `Read` / `Grep` tool calls with graph context — same augmentation Claude Code gets | **Manual** — copy the files described below into your project's `.cursor/`. |
|
||||
|
||||
## Hook install
|
||||
|
||||
Cursor 2.4+ reads `.cursor/hooks.json` from the project root and runs hook commands with the project root as the working directory ([docs](https://cursor.com/docs/agent/hooks)).
|
||||
|
||||
From this repo's `gitnexus-cursor-integration/hooks/`, copy the two files into your **project root**:
|
||||
From this repo's `gitnexus-cursor-integration/hooks/`, copy the files below into your **project root**:
|
||||
|
||||
```text
|
||||
<your-project>/
|
||||
├── .cursor/
|
||||
│ └── hooks.json ← from gitnexus-cursor-integration/hooks/hooks.json
|
||||
└── hooks/
|
||||
└── gitnexus-hook.cjs ← from gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
|
||||
├── gitnexus-hook.cjs ← from gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
|
||||
└── hook-lock.cjs ← from gitnexus-cursor-integration/hooks/hook-lock.cjs
|
||||
```
|
||||
|
||||
Equivalent shell commands (run from your project root, with `$GITNEXUS_REPO` pointing at a clone of this repo):
|
||||
|
|
@ -32,6 +33,7 @@ Equivalent shell commands (run from your project root, with `$GITNEXUS_REPO` poi
|
|||
mkdir -p .cursor hooks
|
||||
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hooks.json" .cursor/hooks.json
|
||||
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs" hooks/gitnexus-hook.cjs
|
||||
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hook-lock.cjs" hooks/hook-lock.cjs
|
||||
```
|
||||
|
||||
If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array rather than overwriting.
|
||||
|
|
@ -49,7 +51,7 @@ If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array
|
|||
| -------------------------------------------------------------------- | ------------------------------ |
|
||||
| `~/.cursor/mcp.json` | ✅ |
|
||||
| `~/.cursor/skills/gitnexus/*` | ✅ |
|
||||
| `<project>/.cursor/hooks.json` + `<project>/hooks/gitnexus-hook.cjs` | ❌ — copy manually (see above) |
|
||||
| `<project>/.cursor/hooks.json` + `<project>/hooks/gitnexus-hook.cjs` + `<project>/hooks/hook-lock.cjs` | ❌ — copy manually (see above) |
|
||||
|
||||
Hook install is per-project (Cursor scopes hooks to a project root); skills and MCP config are global.
|
||||
|
||||
|
|
@ -84,6 +86,6 @@ Empty stdout means "no augmentation, continue normally" — the hook never block
|
|||
|
||||
## Troubleshooting
|
||||
|
||||
- **Nothing happens** — Confirm Cursor is on 2.4+ and the project root has both `.cursor/hooks.json` and the script at `hooks/gitnexus-hook.cjs`. Then `npx gitnexus list` to confirm the project is indexed.
|
||||
- **Nothing happens** — Confirm Cursor is on 2.4+ and the project root has `.cursor/hooks.json` plus both hook files at `hooks/gitnexus-hook.cjs` and `hooks/hook-lock.cjs`. Then `npx gitnexus list` to confirm the project is indexed.
|
||||
- **`gitnexus` not found** — The hook prefers a locally-resolvable `gitnexus/dist/cli/index.js` and falls back to `npx -y gitnexus`. Install globally with `npm i -g gitnexus` to skip the npx cold-start latency.
|
||||
- **Wrong pattern extracted** — Set `GITNEXUS_DEBUG=1` and run a tool call. The raw stdin payload is logged to stderr; use it to confirm Cursor's actual `tool_input` field names against the table above. If they differ, file an issue with the captured payload.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { acquireHookSlot } = require('./hook-lock.cjs');
|
||||
|
||||
function readInput() {
|
||||
try {
|
||||
|
|
@ -227,7 +228,8 @@ function main() {
|
|||
}
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return;
|
||||
if (!findGitNexusDir(cwd)) return;
|
||||
const gitNexusDir = findGitNexusDir(cwd);
|
||||
if (!gitNexusDir) return;
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const toolInput = input.tool_input || {};
|
||||
|
|
@ -235,6 +237,9 @@ function main() {
|
|||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) return;
|
||||
|
||||
const cliPath = resolveCliPath();
|
||||
let result = '';
|
||||
try {
|
||||
|
|
@ -244,6 +249,8 @@ function main() {
|
|||
}
|
||||
} catch {
|
||||
/* graceful failure */
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
|
||||
if (result && result.trim()) {
|
||||
|
|
|
|||
119
gitnexus-cursor-integration/hooks/hook-lock.cjs
Normal file
119
gitnexus-cursor-integration/hooks/hook-lock.cjs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const HOOK_LOCK_SUBDIR = '.hook-locks';
|
||||
const HOOK_LOCK_MAX_INFLIGHT = 3;
|
||||
const HOOK_LOCK_STALE_MS = 30000;
|
||||
|
||||
function acquireHookSlot(gitNexusDir) {
|
||||
const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR);
|
||||
try {
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
} catch {
|
||||
// Cannot create lock dir (read-only fs, cross-user perm denial, out of
|
||||
// inodes, etc.) — fail closed by returning null. Caller skips augment.
|
||||
// Fail-open here would let N concurrent hooks all proceed unguarded and
|
||||
// reintroduce the #1486 fan-out the guard exists to prevent.
|
||||
return null;
|
||||
}
|
||||
|
||||
const myPidStr = String(process.pid);
|
||||
|
||||
for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) {
|
||||
const slotPath = path.join(lockDir, `slot-${slot}.lock`);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' });
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
try {
|
||||
// Only unlink if we still own the slot. If we appeared stale and
|
||||
// another hook took over, the file now belongs to it — leave alone.
|
||||
const content = fs.readFileSync(slotPath, 'utf-8').trim();
|
||||
if (content === myPidStr) fs.unlinkSync(slotPath);
|
||||
} catch {
|
||||
/* already removed or unreadable */
|
||||
}
|
||||
};
|
||||
process.on('exit', release);
|
||||
return release;
|
||||
} catch {
|
||||
// Slot exists. Decide whether to take it over.
|
||||
// Open once and inspect mtime + content via the same fd so there's
|
||||
// no TOCTOU between the metadata check and the content read
|
||||
// (codeql js/file-system-race).
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(slotPath, 'r');
|
||||
} catch {
|
||||
continue; // Vanished between EEXIST and open — retry this slot.
|
||||
}
|
||||
let isLive = false;
|
||||
let mtimeMs = Date.now();
|
||||
try {
|
||||
mtimeMs = fs.fstatSync(fd).mtimeMs;
|
||||
const buf = Buffer.alloc(32);
|
||||
const n = fs.readSync(fd, buf, 0, 32, 0);
|
||||
const ownerStr = buf.slice(0, n).toString('utf-8').trim();
|
||||
if (ownerStr === '') {
|
||||
// Owner created the file but hasn't written its PID yet. The
|
||||
// wx open+write window is microseconds; give it the benefit
|
||||
// of the doubt and treat as live.
|
||||
isLive = true;
|
||||
} else {
|
||||
const owner = Number.parseInt(ownerStr, 10);
|
||||
if (Number.isFinite(owner) && owner > 0) {
|
||||
try {
|
||||
process.kill(owner, 0);
|
||||
isLive = true;
|
||||
} catch (e) {
|
||||
// ESRCH = process gone → treat as dead. EPERM = process exists
|
||||
// but owned by another user (cross-user lock dir) → still alive,
|
||||
// keep the slot. Anything else: be conservative, assume alive.
|
||||
if (e && e.code === 'ESRCH') {
|
||||
isLive = false;
|
||||
} else {
|
||||
isLive = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* unreadable — treat as dead */
|
||||
} finally {
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
// For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins —
|
||||
// a slow-but-alive hook is never wrongly evicted. For older slots,
|
||||
// age is the final arbiter as a defense against PID reuse on long-
|
||||
// abandoned slots. 30s >> the 7s augment timeout, so a healthy run
|
||||
// never crosses this threshold.
|
||||
if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) {
|
||||
isLive = false;
|
||||
}
|
||||
if (isLive) break; // Try the next slot.
|
||||
try {
|
||||
fs.unlinkSync(slotPath);
|
||||
} catch {
|
||||
/* another hook beat us to it — retry will hit EEXIST */
|
||||
}
|
||||
// Loop and retry this slot.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HOOK_LOCK_SUBDIR,
|
||||
HOOK_LOCK_MAX_INFLIGHT,
|
||||
HOOK_LOCK_STALE_MS,
|
||||
acquireHookSlot,
|
||||
};
|
||||
|
|
@ -32,6 +32,8 @@ extraResources:
|
|||
- '**/*'
|
||||
- from: '${env.GITNEXUS_DESKTOP_GITNEXUS_PACKAGE_JSON}'
|
||||
to: gitnexus/package.json
|
||||
- from: 'build/icon.png'
|
||||
to: icon.png
|
||||
asar: false
|
||||
npmRebuild: false
|
||||
afterPack: scripts/after-pack.mjs
|
||||
|
|
|
|||
|
|
@ -169,6 +169,10 @@ const builderCliArgs = [
|
|||
];
|
||||
|
||||
const runCommand = (command, args, cwd, extraEnv = {}) => {
|
||||
// On Windows, only .cmd files (e.g. npm.cmd) require shell:true to execute.
|
||||
// Using shell:true for all commands causes spaces in paths to be misinterpreted
|
||||
// by cmd.exe when it joins the args array into a raw command string.
|
||||
const needsShell = process.platform === 'win32' && command.endsWith('.cmd');
|
||||
execFileSync(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
|
|
@ -177,7 +181,7 @@ const runCommand = (command, args, cwd, extraEnv = {}) => {
|
|||
},
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: process.platform === 'win32',
|
||||
shell: needsShell,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -40,11 +40,27 @@ export interface MethodDispatchIndex {
|
|||
readonly mroByOwnerDefId: ReadonlyMap<DefId, readonly DefId[]>;
|
||||
/** Interfaces / traits → classes that implement them. */
|
||||
readonly implsByInterfaceDefId: ReadonlyMap<DefId, readonly DefId[]>;
|
||||
/**
|
||||
* Optional parallel MRO view that EXCLUDES mixin-like augmentation
|
||||
* (e.g., PHP traits). Populated only when the input supplies
|
||||
* `computeExtendsOnlyMro`. Used by the super-branch dispatch in
|
||||
* `receiver-bound-calls` so that `parent::method()` walks the
|
||||
* inheritance chain only, not the trait-augmented one. Undefined for
|
||||
* languages without mixin-like semantics — callers should fall back
|
||||
* to `mroFor` when this is missing.
|
||||
*/
|
||||
readonly extendsOnlyMroByOwnerDefId?: ReadonlyMap<DefId, readonly DefId[]>;
|
||||
|
||||
/** `mroByOwnerDefId.get`, with an empty frozen array on miss. */
|
||||
mroFor(ownerDefId: DefId): readonly DefId[];
|
||||
/** `implsByInterfaceDefId.get`, with an empty frozen array on miss. */
|
||||
implementorsOf(interfaceDefId: DefId): readonly DefId[];
|
||||
/**
|
||||
* `extendsOnlyMroByOwnerDefId.get`, with an empty frozen array on miss.
|
||||
* Undefined when `extendsOnlyMroByOwnerDefId` was not populated; callers
|
||||
* should treat this as equivalent to `mroFor` for non-mixin languages.
|
||||
*/
|
||||
readonly extendsOnlyMroFor?: (ownerDefId: DefId) => readonly DefId[];
|
||||
}
|
||||
|
||||
export interface MethodDispatchInput {
|
||||
|
|
@ -81,12 +97,25 @@ export interface MethodDispatchInput {
|
|||
* write-wins policy and fires at most once per unique owner.
|
||||
*/
|
||||
readonly implementsOf: (ownerDefId: DefId) => readonly DefId[];
|
||||
/**
|
||||
* Optional: return the EXTENDS-only ancestor chain for `ownerDefId`,
|
||||
* excluding the owner itself AND any mixin-like augmentation (e.g.,
|
||||
* PHP traits). Languages without mixin semantics leave this undefined
|
||||
* and the index's `extendsOnlyMroByOwnerDefId` stays unpopulated.
|
||||
*
|
||||
* Same contract as `computeMro`: pure, deterministic, `[]` on no parents.
|
||||
* Called at most once per unique owner (first-write-wins).
|
||||
*/
|
||||
readonly computeExtendsOnlyMro?: (ownerDefId: DefId) => readonly DefId[];
|
||||
}
|
||||
|
||||
// ─── Builder ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDispatchIndex {
|
||||
const mroByOwnerDefId = new Map<DefId, readonly DefId[]>();
|
||||
const extendsOnlyByOwnerDefId = input.computeExtendsOnlyMro
|
||||
? new Map<DefId, readonly DefId[]>()
|
||||
: undefined;
|
||||
const implsBuilding = new Map<DefId, DefId[]>();
|
||||
const implsSeen = new Map<DefId, Set<DefId>>();
|
||||
|
||||
|
|
@ -97,6 +126,14 @@ export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDisp
|
|||
const chain = input.computeMro(ownerId);
|
||||
mroByOwnerDefId.set(ownerId, Object.freeze(chain.slice()));
|
||||
}
|
||||
if (
|
||||
input.computeExtendsOnlyMro !== undefined &&
|
||||
extendsOnlyByOwnerDefId !== undefined &&
|
||||
!extendsOnlyByOwnerDefId.has(ownerId)
|
||||
) {
|
||||
const extOnly = input.computeExtendsOnlyMro(ownerId);
|
||||
extendsOnlyByOwnerDefId.set(ownerId, Object.freeze(extOnly.slice()));
|
||||
}
|
||||
|
||||
for (const ifaceId of input.implementsOf(ownerId)) {
|
||||
let seen = implsSeen.get(ifaceId);
|
||||
|
|
@ -121,7 +158,7 @@ export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDisp
|
|||
implsByInterfaceDefId.set(ifaceId, Object.freeze(owners.slice()));
|
||||
}
|
||||
|
||||
return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId);
|
||||
return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId, extendsOnlyByOwnerDefId);
|
||||
}
|
||||
|
||||
// ─── Internal ───────────────────────────────────────────────────────────────
|
||||
|
|
@ -131,8 +168,9 @@ const EMPTY: readonly DefId[] = Object.freeze([]);
|
|||
function wrapIndex(
|
||||
mroByOwnerDefId: Map<DefId, readonly DefId[]>,
|
||||
implsByInterfaceDefId: Map<DefId, readonly DefId[]>,
|
||||
extendsOnlyMroByOwnerDefId: Map<DefId, readonly DefId[]> | undefined,
|
||||
): MethodDispatchIndex {
|
||||
return {
|
||||
const base: MethodDispatchIndex = {
|
||||
mroByOwnerDefId,
|
||||
implsByInterfaceDefId,
|
||||
mroFor(ownerDefId: DefId): readonly DefId[] {
|
||||
|
|
@ -142,4 +180,14 @@ function wrapIndex(
|
|||
return implsByInterfaceDefId.get(interfaceDefId) ?? EMPTY;
|
||||
},
|
||||
};
|
||||
if (extendsOnlyMroByOwnerDefId !== undefined) {
|
||||
return {
|
||||
...base,
|
||||
extendsOnlyMroByOwnerDefId,
|
||||
extendsOnlyMroFor(ownerDefId: DefId): readonly DefId[] {
|
||||
return extendsOnlyMroByOwnerDefId.get(ownerDefId) ?? EMPTY;
|
||||
},
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -423,13 +423,30 @@ function applyArityFilter(
|
|||
}
|
||||
|
||||
let anyCompatible = false;
|
||||
let anyUnknown = false;
|
||||
for (const state of perCandidate.values()) {
|
||||
const verdict = arityFn(callsite, state.def);
|
||||
state.signals.arityVerdict = verdict;
|
||||
if (verdict === 'compatible') anyCompatible = true;
|
||||
else if (verdict === 'unknown') anyUnknown = true;
|
||||
}
|
||||
|
||||
if (!anyCompatible) return;
|
||||
// When ALL candidates are 'incompatible' (none compatible, none unknown),
|
||||
// the call is genuinely arity-broken — drop every candidate so the
|
||||
// registry returns no resolution. This matches the PHP variadic case
|
||||
// f(int $req, ...$rest) called with zero args: every candidate definitively
|
||||
// rejects, and emitting an edge to a definitively-rejected callable is
|
||||
// a false positive. When some candidates are 'unknown' (missing metadata),
|
||||
// keep the set so downstream evidence can break the tie — that's the
|
||||
// original safety-fallback behavior.
|
||||
if (!anyCompatible) {
|
||||
if (!anyUnknown) {
|
||||
for (const defId of perCandidate.keys()) {
|
||||
perCandidate.delete(defId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter: when at least one compatible candidate exists, drop incompatibles.
|
||||
for (const [defId, state] of perCandidate) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
371
gitnexus-web/package-lock.json
generated
371
gitnexus-web/package-lock.json
generated
|
|
@ -10,7 +10,7 @@
|
|||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.3.29",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"@langchain/google-genai": "^2.1.28",
|
||||
"@langchain/google-genai": "^2.1.30",
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
"@langchain/ollama": "^1.2.6",
|
||||
"@langchain/openai": "^1.4.5",
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
"langchain": "^1.3.5",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^8.0.10",
|
||||
"vite": "^8.0.11",
|
||||
"vitest": "^4.1.5",
|
||||
"wait-on": "^9.0.5"
|
||||
},
|
||||
|
|
@ -528,41 +528,10 @@
|
|||
"integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@chevrotain/cst-dts-gen": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz",
|
||||
"integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/gast": "12.0.0",
|
||||
"@chevrotain/types": "12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/gast": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz",
|
||||
"integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/types": "12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/regexp-to-ast": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz",
|
||||
"integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@chevrotain/types": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz",
|
||||
"integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@chevrotain/utils": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz",
|
||||
"integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==",
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
|
||||
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
|
|
@ -1433,32 +1402,18 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/google-genai": {
|
||||
"version": "2.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.1.28.tgz",
|
||||
"integrity": "sha512-iTzNYWST8hTRqOXZdme18tq5GnCUwtrrJECE51ZCjg6Vg0mPsV44amdC+/bc+UK0+uphKWESGWs277aAyM2MlA==",
|
||||
"version": "2.1.30",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.1.30.tgz",
|
||||
"integrity": "sha512-0wKgy1NvV89fw5MwYiOOhh18SnUEH20z6MZrPV6Tj2hMAA3jAHVSLlIcCQ2mDRJo2r1aHLV8MDXhzkvD1tEHoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@google/generative-ai": "^0.24.0",
|
||||
"uuid": "^11.1.0"
|
||||
"@google/generative-ai": "^0.24.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.41"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/google-genai/node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
"@langchain/core": "^1.1.43"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph": {
|
||||
|
|
@ -1679,12 +1634,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@mermaid-js/parser": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz",
|
||||
"integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz",
|
||||
"integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"langium": "^4.0.0"
|
||||
"@chevrotain/types": "~11.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
|
|
@ -1744,9 +1699,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.127.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
|
||||
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
|
||||
"version": "0.128.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz",
|
||||
"integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
|
|
@ -1769,9 +1724,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1785,9 +1740,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1801,9 +1756,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1817,9 +1772,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1833,9 +1788,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -1849,9 +1804,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1865,9 +1820,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1881,9 +1836,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -1897,9 +1852,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -1913,9 +1868,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1929,9 +1884,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1945,9 +1900,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1961,9 +1916,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
|
|
@ -1979,9 +1934,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1995,9 +1950,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -3643,34 +3598,6 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/chevrotain": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
|
||||
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/cst-dts-gen": "12.0.0",
|
||||
"@chevrotain/gast": "12.0.0",
|
||||
"@chevrotain/regexp-to-ast": "12.0.0",
|
||||
"@chevrotain/types": "12.0.0",
|
||||
"@chevrotain/utils": "12.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chevrotain-allstar": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz",
|
||||
"integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"chevrotain": "^12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
|
|
@ -4628,6 +4555,16 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.46.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
|
||||
"integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz",
|
||||
|
|
@ -5661,24 +5598,6 @@
|
|||
"@langchain/core": "^1.1.42"
|
||||
}
|
||||
},
|
||||
"node_modules/langium": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz",
|
||||
"integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@chevrotain/regexp-to-ast": "~12.0.0",
|
||||
"chevrotain": "~12.0.0",
|
||||
"chevrotain-allstar": "~0.4.1",
|
||||
"vscode-languageserver": "~9.0.1",
|
||||
"vscode-languageserver-textdocument": "~1.0.11",
|
||||
"vscode-uri": "~3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.10.0",
|
||||
"npm": ">=10.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/langsmith": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.23.tgz",
|
||||
|
|
@ -6422,14 +6341,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/mermaid": {
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
|
||||
"integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==",
|
||||
"version": "11.15.0",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz",
|
||||
"integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.1.1",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@mermaid-js/parser": "^1.1.0",
|
||||
"@mermaid-js/parser": "^1.1.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@upsetjs/venn.js": "^2.0.0",
|
||||
"cytoscape": "^3.33.1",
|
||||
|
|
@ -6440,27 +6359,14 @@
|
|||
"dagre-d3-es": "7.0.14",
|
||||
"dayjs": "^1.11.19",
|
||||
"dompurify": "^3.3.1",
|
||||
"es-toolkit": "^1.45.1",
|
||||
"katex": "^0.16.25",
|
||||
"khroma": "^2.1.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
"marked": "^16.3.0",
|
||||
"roughjs": "^4.6.6",
|
||||
"stylis": "^4.3.6",
|
||||
"ts-dedent": "^2.2.0",
|
||||
"uuid": "^11.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mermaid/node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
"uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark": {
|
||||
|
|
@ -7216,9 +7122,9 @@
|
|||
}
|
||||
},
|
||||
"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==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -7595,9 +7501,9 @@
|
|||
}
|
||||
},
|
||||
"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==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -7947,13 +7853,13 @@
|
|||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.127.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.17"
|
||||
"@oxc-project/types": "=0.128.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.18"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
|
|
@ -7962,27 +7868,27 @@
|
|||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.18",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.18",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.18",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.18",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.18",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.18",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.18",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz",
|
||||
"integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/roughjs": {
|
||||
|
|
@ -8702,15 +8608,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.10",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
||||
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
|
||||
"version": "8.0.11",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
|
||||
"integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.10",
|
||||
"rolldown": "1.0.0-rc.17",
|
||||
"postcss": "^8.5.14",
|
||||
"rolldown": "1.0.0-rc.18",
|
||||
"tinyglobby": "^0.2.16"
|
||||
},
|
||||
"bin": {
|
||||
|
|
@ -8727,7 +8633,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",
|
||||
|
|
@ -8875,55 +8781,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
|
||||
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vscode-languageserver-protocol": "3.17.5"
|
||||
},
|
||||
"bin": {
|
||||
"installServerIntoExtension": "bin/installServerIntoExtension"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-protocol": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
|
||||
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vscode-jsonrpc": "8.2.0",
|
||||
"vscode-languageserver-types": "3.17.5"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-textdocument": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
|
||||
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-languageserver-types": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
|
||||
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"@langchain/anthropic": "^1.3.29",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"@langchain/google-genai": "^2.1.28",
|
||||
"@langchain/google-genai": "^2.1.30",
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
"@langchain/ollama": "^1.2.6",
|
||||
"@langchain/openai": "^1.4.5",
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
"langchain": "^1.3.5",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
|
|
@ -70,7 +70,7 @@
|
|||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^8.0.10",
|
||||
"vite": "^8.0.11",
|
||||
"vitest": "^4.1.5",
|
||||
"wait-on": "^9.0.5"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
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.
|
||||
|
|
@ -102,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.
|
||||
*/
|
||||
|
|
@ -167,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 {
|
||||
|
|
@ -207,7 +235,8 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
|
|||
function handlePreToolUse(input) {
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return;
|
||||
if (!findGitNexusDir(cwd)) return;
|
||||
const gitNexusDir = findGitNexusDir(cwd);
|
||||
if (!gitNexusDir) return;
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const toolInput = input.tool_input || {};
|
||||
|
|
@ -216,20 +245,29 @@ 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;
|
||||
|
||||
const cliPath = resolveCliPath();
|
||||
let result = '';
|
||||
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 */
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
|
||||
if (result && result.trim()) {
|
||||
sendHookResponse('PreToolUse', result.trim());
|
||||
if (result) {
|
||||
sendHookResponse('PreToolUse', result);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
238
gitnexus/hooks/claude/hook-db-lock-probe.cjs
Normal file
238
gitnexus/hooks/claude/hook-db-lock-probe.cjs
Normal 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,
|
||||
};
|
||||
119
gitnexus/hooks/claude/hook-lock.cjs
Normal file
119
gitnexus/hooks/claude/hook-lock.cjs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const HOOK_LOCK_SUBDIR = '.hook-locks';
|
||||
const HOOK_LOCK_MAX_INFLIGHT = 3;
|
||||
const HOOK_LOCK_STALE_MS = 30000;
|
||||
|
||||
function acquireHookSlot(gitNexusDir) {
|
||||
const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR);
|
||||
try {
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
} catch {
|
||||
// Cannot create lock dir (read-only fs, cross-user perm denial, out of
|
||||
// inodes, etc.) — fail closed by returning null. Caller skips augment.
|
||||
// Fail-open here would let N concurrent hooks all proceed unguarded and
|
||||
// reintroduce the #1486 fan-out the guard exists to prevent.
|
||||
return null;
|
||||
}
|
||||
|
||||
const myPidStr = String(process.pid);
|
||||
|
||||
for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) {
|
||||
const slotPath = path.join(lockDir, `slot-${slot}.lock`);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' });
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
try {
|
||||
// Only unlink if we still own the slot. If we appeared stale and
|
||||
// another hook took over, the file now belongs to it — leave alone.
|
||||
const content = fs.readFileSync(slotPath, 'utf-8').trim();
|
||||
if (content === myPidStr) fs.unlinkSync(slotPath);
|
||||
} catch {
|
||||
/* already removed or unreadable */
|
||||
}
|
||||
};
|
||||
process.on('exit', release);
|
||||
return release;
|
||||
} catch {
|
||||
// Slot exists. Decide whether to take it over.
|
||||
// Open once and inspect mtime + content via the same fd so there's
|
||||
// no TOCTOU between the metadata check and the content read
|
||||
// (codeql js/file-system-race).
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(slotPath, 'r');
|
||||
} catch {
|
||||
continue; // Vanished between EEXIST and open — retry this slot.
|
||||
}
|
||||
let isLive = false;
|
||||
let mtimeMs = Date.now();
|
||||
try {
|
||||
mtimeMs = fs.fstatSync(fd).mtimeMs;
|
||||
const buf = Buffer.alloc(32);
|
||||
const n = fs.readSync(fd, buf, 0, 32, 0);
|
||||
const ownerStr = buf.slice(0, n).toString('utf-8').trim();
|
||||
if (ownerStr === '') {
|
||||
// Owner created the file but hasn't written its PID yet. The
|
||||
// wx open+write window is microseconds; give it the benefit
|
||||
// of the doubt and treat as live.
|
||||
isLive = true;
|
||||
} else {
|
||||
const owner = Number.parseInt(ownerStr, 10);
|
||||
if (Number.isFinite(owner) && owner > 0) {
|
||||
try {
|
||||
process.kill(owner, 0);
|
||||
isLive = true;
|
||||
} catch (e) {
|
||||
// ESRCH = process gone → treat as dead. EPERM = process exists
|
||||
// but owned by another user (cross-user lock dir) → still alive,
|
||||
// keep the slot. Anything else: be conservative, assume alive.
|
||||
if (e && e.code === 'ESRCH') {
|
||||
isLive = false;
|
||||
} else {
|
||||
isLive = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* unreadable — treat as dead */
|
||||
} finally {
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
// For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins —
|
||||
// a slow-but-alive hook is never wrongly evicted. For older slots,
|
||||
// age is the final arbiter as a defense against PID reuse on long-
|
||||
// abandoned slots. 30s >> the 7s augment timeout, so a healthy run
|
||||
// never crosses this threshold.
|
||||
if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) {
|
||||
isLive = false;
|
||||
}
|
||||
if (isLive) break; // Try the next slot.
|
||||
try {
|
||||
fs.unlinkSync(slotPath);
|
||||
} catch {
|
||||
/* another hook beat us to it — retry will hit EEXIST */
|
||||
}
|
||||
// Loop and retry this slot.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HOOK_LOCK_SUBDIR,
|
||||
HOOK_LOCK_MAX_INFLIGHT,
|
||||
HOOK_LOCK_STALE_MS,
|
||||
acquireHookSlot,
|
||||
};
|
||||
76
gitnexus/hooks/claude/win-rm-list-json.ps1
Normal file
76
gitnexus/hooks/claude/win-rm-list-json.ps1
Normal 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
|
||||
298
gitnexus/package-lock.json
generated
298
gitnexus/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.4",
|
||||
"version": "1.6.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.4",
|
||||
"version": "1.6.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
|
|
@ -30,8 +30,6 @@
|
|||
"mnemonist": "^0.40.3",
|
||||
"onnxruntime-node": "^1.24.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"tree-sitter": "^0.21.1",
|
||||
"tree-sitter-c": "0.21.4",
|
||||
"tree-sitter-c-sharp": "0.23.1",
|
||||
|
|
@ -63,7 +61,7 @@
|
|||
"vitest": "^4.0.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"node-addon-api": "^8.0.0",
|
||||
|
|
@ -1581,12 +1579,6 @@
|
|||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
|
|
@ -2065,9 +2057,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
|
||||
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
|
|
@ -2388,15 +2380,6 @@
|
|||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
|
|
@ -2596,12 +2579,6 @@
|
|||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
|
|
@ -2706,15 +2683,6 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/dateformat": {
|
||||
"version": "4.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
|
||||
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -2838,15 +2806,6 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
|
|
@ -3059,12 +3018,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.5.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
|
||||
"integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz",
|
||||
"integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.2.0"
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
|
|
@ -3091,28 +3050,16 @@
|
|||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz",
|
||||
"integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-safe-stringify": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -3469,16 +3416,10 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/help-me": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.18",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
||||
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
||||
"version": "4.12.16",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz",
|
||||
"integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
|
|
@ -3545,9 +3486,9 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
|
|
@ -3634,15 +3575,6 @@
|
|||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/joycon": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
|
|
@ -4251,15 +4183,6 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
|
|
@ -4282,15 +4205,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/onnxruntime-common": {
|
||||
"version": "1.26.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.26.0.tgz",
|
||||
"integrity": "sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw==",
|
||||
"version": "1.25.1",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.25.1.tgz",
|
||||
"integrity": "sha512-kKvYQFdos4LWJqhZ+nmKu3NT8NXzw8I5x9fNUKe1rNKcPfNKnYXUtW7JBpcKFsvLtrJashRgVYSbFap4cHxvNg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/onnxruntime-node": {
|
||||
"version": "1.26.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.26.0.tgz",
|
||||
"integrity": "sha512-OHl6PiOEOqxaLHL0N9eFrbzS7IGmu3BtJNH3RTEnRAheCIkfc3gjcjl4sGcjp9C22ZC9YTquDOxSdT/stBQ6BQ==",
|
||||
"version": "1.25.1",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.25.1.tgz",
|
||||
"integrity": "sha512-N0M58CGTiTsLkPpx9bxmRFi24GT6r67Qei/GrBEIiDyntcYdXU5vQZp112ypydG9vEKRFgbgUYQJnEi+jll8dg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
|
|
@ -4301,7 +4224,7 @@
|
|||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"global-agent": "^4.1.3",
|
||||
"onnxruntime-common": "1.26.0"
|
||||
"onnxruntime-common": "1.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-web": {
|
||||
|
|
@ -4409,79 +4332,6 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"pino-std-serializers": "^7.0.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"quick-format-unescaped": "^4.0.3",
|
||||
"real-require": "^0.2.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"thread-stream": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pino": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-abstract-transport": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-pretty": {
|
||||
"version": "13.1.3",
|
||||
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz",
|
||||
"integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"colorette": "^2.0.7",
|
||||
"dateformat": "^4.6.3",
|
||||
"fast-copy": "^4.0.0",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"help-me": "^5.0.0",
|
||||
"joycon": "^3.1.1",
|
||||
"minimist": "^1.2.6",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"pump": "^3.0.0",
|
||||
"secure-json-parse": "^4.0.0",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"strip-json-comments": "^5.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"pino-pretty": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-pretty/node_modules/strip-json-comments": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
|
||||
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pkce-challenge": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
|
|
@ -4526,22 +4376,6 @@
|
|||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.5",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz",
|
||||
|
|
@ -4579,16 +4413,6 @@
|
|||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
|
|
@ -4604,12 +4428,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
|
|
@ -4665,15 +4483,6 @@
|
|||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/real-require": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
|
|
@ -4782,37 +4591,12 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
|
|
@ -5044,15 +4828,6 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
|
@ -5063,15 +4838,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
|
|
@ -5159,18 +4925,6 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^0.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
|
|
|
|||
|
|
@ -199,7 +199,9 @@ async function fileExists(filePath: string): Promise<boolean> {
|
|||
async function upsertGitNexusSection(
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<'created' | 'updated' | 'appended'> {
|
||||
projectName: string,
|
||||
stats: RepoStats,
|
||||
): Promise<'created' | 'updated' | 'appended' | 'preserved'> {
|
||||
const exists = await fileExists(filePath);
|
||||
|
||||
if (!exists) {
|
||||
|
|
@ -223,7 +225,50 @@ async function upsertGitNexusSection(
|
|||
);
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
||||
// Replace existing section
|
||||
const existingSection = existingContent.substring(
|
||||
startIdx,
|
||||
endIdx + GITNEXUS_END_MARKER.length,
|
||||
);
|
||||
|
||||
// If the existing section contains <!-- gitnexus:keep -->, preserve the user's
|
||||
// custom layout and only update the stats line (node/edge/flow counts).
|
||||
// This lets teams trim the verbose default template to a lean format without
|
||||
// having it overwritten on every `gitnexus analyze`.
|
||||
//
|
||||
// Note: the keep-marker check operates on `existingSection` (the substring
|
||||
// between valid section markers identified by findSectionMarkerIndex), so
|
||||
// a keep marker in user prose OUTSIDE the GitNexus block has no effect.
|
||||
if (existingSection.includes('<!-- gitnexus:keep -->')) {
|
||||
// Build the new stats line from the caller-provided values directly.
|
||||
// We do NOT re-extract from `content` because:
|
||||
// (a) first-bold extraction is fragile if the template evolves
|
||||
// (b) the parenthesized-text fallback can match unrelated tuples
|
||||
// like `({target: "symbolName", direction: "upstream"})`
|
||||
// when noStats is set
|
||||
// Passing projectName + stats explicitly makes the contract obvious.
|
||||
// noStats controls template generation, not keep-section stat updates — the user opted into a stats line by keeping it.
|
||||
const newStatsInner = `${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows`;
|
||||
const statsLine = `Indexed as **${projectName}** (${newStatsInner})`;
|
||||
|
||||
// Match either canonical phrasing at line start (`^` with `m` flag) so we
|
||||
// cannot replace prose embedded mid-paragraph. Deliberately no `$`: text
|
||||
// after the closing `)` on the same line (e.g. ". MCP tools.") stays intact.
|
||||
const statsPattern = /^(?:Indexed as|indexed by GitNexus as) \*\*[^*]+\*\* \([^)]+\)/m;
|
||||
|
||||
if (statsPattern.test(existingSection)) {
|
||||
const updatedSection = existingSection.replace(statsPattern, statsLine);
|
||||
const before = existingContent.substring(0, startIdx);
|
||||
const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length);
|
||||
await fs.writeFile(filePath, (before + updatedSection + after).trim() + '\n', 'utf-8');
|
||||
return 'updated';
|
||||
}
|
||||
// Keep marker present but no stats line matched. Section is preserved
|
||||
// unchanged on disk; return a distinct status so callers/CLI output
|
||||
// don't mis-report this as 'updated' (which would imply a write).
|
||||
return 'preserved';
|
||||
}
|
||||
|
||||
// No keep marker — replace existing section with full verbose content
|
||||
const before = existingContent.substring(0, startIdx);
|
||||
const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length);
|
||||
const newContent = before + content + after;
|
||||
|
|
@ -344,12 +389,12 @@ export async function generateAIContextFiles(
|
|||
if (!options?.skipAgentsMd) {
|
||||
// Create AGENTS.md (standard for Cursor, Windsurf, OpenCode, Cline, etc.)
|
||||
const agentsPath = path.join(repoPath, 'AGENTS.md');
|
||||
const agentsResult = await upsertGitNexusSection(agentsPath, content);
|
||||
const agentsResult = await upsertGitNexusSection(agentsPath, content, projectName, stats);
|
||||
createdFiles.push(`AGENTS.md (${agentsResult})`);
|
||||
|
||||
// Create CLAUDE.md (for Claude Code)
|
||||
const claudePath = path.join(repoPath, 'CLAUDE.md');
|
||||
const claudeResult = await upsertGitNexusSection(claudePath, content);
|
||||
const claudeResult = await upsertGitNexusSection(claudePath, content, projectName, stats);
|
||||
createdFiles.push(`CLAUDE.md (${claudeResult})`);
|
||||
} else {
|
||||
createdFiles.push('AGENTS.md (skipped via --skip-agents-md)');
|
||||
|
|
|
|||
|
|
@ -117,8 +117,18 @@ export interface AnalyzeOptions {
|
|||
verbose?: boolean;
|
||||
/** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */
|
||||
skipAgentsMd?: boolean;
|
||||
/** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */
|
||||
noStats?: boolean;
|
||||
/**
|
||||
* Stats inclusion in AGENTS.md and CLAUDE.md.
|
||||
*
|
||||
* Commander.js represents `--no-stats` as `stats: boolean` (default
|
||||
* `true`; `false` when the user passes `--no-stats`), NOT as
|
||||
* `noStats: boolean`. Reading the negated form would always be
|
||||
* `undefined` and the flag would silently no-op (#1477). Consumers
|
||||
* that want "did the user request --no-stats?" should compare with
|
||||
* `=== false` to distinguish the explicit-off case from the
|
||||
* default-on case.
|
||||
*/
|
||||
stats?: boolean;
|
||||
/** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
|
||||
skipSkills?: boolean;
|
||||
/** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */
|
||||
|
|
@ -449,7 +459,12 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
skipGit: options?.skipGit,
|
||||
skipAgentsMd,
|
||||
skipSkills,
|
||||
noStats: options?.noStats,
|
||||
// commander.js `.option('--no-stats', …)` registers the flag as
|
||||
// `options.stats` (boolean, default true; `false` when the user
|
||||
// passed --no-stats). Reading `options?.noStats` here returns
|
||||
// undefined every time, so the flag was a no-op on the markdown
|
||||
// rewrite path before this fix. See #1477.
|
||||
noStats: options?.stats === false,
|
||||
registryName: options?.name,
|
||||
// Registry-collision bypass — its own CLI flag, intentionally NOT
|
||||
// overloading --force. A user who hits the collision guard should
|
||||
|
|
@ -537,7 +552,13 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
processes: s.processes,
|
||||
},
|
||||
skillResult.skills,
|
||||
{ skipAgentsMd, skipSkills, noStats: options?.noStats },
|
||||
{
|
||||
skipAgentsMd,
|
||||
skipSkills,
|
||||
// Mirror runFullAnalysis `noStats` bridge (#1477) — same expression;
|
||||
// exercised on the `--skills` path by analyze-no-stats-bridge.test.ts.
|
||||
noStats: options?.stats === false,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -161,6 +161,8 @@ program
|
|||
)
|
||||
.option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)')
|
||||
.option('--concurrency <n>', 'Parallel LLM calls (default: 3)', '3')
|
||||
.option('--timeout <seconds>', 'Per-attempt LLM request timeout in seconds (default: 60)')
|
||||
.option('--retries <n>', 'Max LLM retry attempts per request (default: 3)')
|
||||
.option('--gist', 'Publish wiki as a public GitHub Gist after generation')
|
||||
.option('-v, --verbose', 'Enable verbose output (show LLM commands and responses)')
|
||||
.option('--review', 'Stop after grouping to review module structure before generating pages')
|
||||
|
|
|
|||
|
|
@ -364,6 +364,33 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
|
|||
// Script not found in source — skip
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.copyFile(
|
||||
path.join(pluginHooksPath, 'hook-lock.cjs'),
|
||||
path.join(destHooksDir, 'hook-lock.cjs'),
|
||||
);
|
||||
} catch {
|
||||
// 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`
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ export interface WikiCommandOptions {
|
|||
provider?: LLMProvider;
|
||||
verbose?: boolean;
|
||||
review?: boolean;
|
||||
timeout?: string;
|
||||
retries?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -347,6 +349,16 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
|
|||
}
|
||||
}
|
||||
|
||||
// ── Apply per-run overrides not saved to config ────────────────────
|
||||
if (options?.timeout) {
|
||||
const secs = parseInt(options.timeout, 10);
|
||||
if (!isNaN(secs) && secs > 0) llmConfig.requestTimeoutMs = secs * 1000;
|
||||
}
|
||||
if (options?.retries) {
|
||||
const n = parseInt(options.retries, 10);
|
||||
if (!isNaN(n) && n > 0) llmConfig.maxAttempts = n;
|
||||
}
|
||||
|
||||
// ── Setup progress bar with elapsed timer ──────────────────────────
|
||||
const bar = new cliProgress.SingleBar(
|
||||
{
|
||||
|
|
|
|||
76
gitnexus/src/core/incremental/shadow-candidates.ts
Normal file
76
gitnexus/src/core/incremental/shadow-candidates.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Shadow-candidate path derivation for incremental indexing.
|
||||
*
|
||||
* Background — Bugbot review on PR #1479:
|
||||
* queryImporters() on a NEWLY ADDED file returns 0 importers in the
|
||||
* pre-pipeline DB, because the new file's IMPORTS rows haven't been
|
||||
* written yet. But pre-existing files may have IMPORTS edges that
|
||||
* *resolved to a sibling path*, and the newcomer can now steal that
|
||||
* resolution under standard JS/TS module-resolution rules. Without
|
||||
* pulling those pre-existing files into the writable set, their
|
||||
* stale CALLS edges remain pointing at the OLD resolution target.
|
||||
*
|
||||
* Given an added file path, this helper enumerates the pre-existing
|
||||
* file paths whose import-resolution claim the newcomer can steal.
|
||||
* Caller filters the candidates against the prior-run `fileHashes`
|
||||
* map so we only query importers of paths that actually existed.
|
||||
*
|
||||
* Shadow patterns covered (resolution-priority-aware):
|
||||
*
|
||||
* (a) Same basename, different extension —
|
||||
* added `foo/bar.ts` shadows `foo/bar.{tsx,js,jsx,mjs,cjs,d.ts}`.
|
||||
* (b) Bare-file beats directory-style index —
|
||||
* added `foo/bar.ts` shadows `foo/bar/index.{ts,tsx,...}`.
|
||||
* (c) Directory-index beats bare-file —
|
||||
* added `foo/index.ts` shadows `foo.{ts,tsx,...}` (rare but real,
|
||||
* e.g. converting a single-file module into a directory module).
|
||||
*
|
||||
* Resolution-order priority is conservatively wide: we enumerate ALL
|
||||
* common extensions because we don't know which the importer actually
|
||||
* specified, and over-seeding is harmless (extra BFS work, but the
|
||||
* subgraph extract still gates write-back by file membership).
|
||||
*
|
||||
* Cross-platform path separators: candidates are emitted with both `/`
|
||||
* and `\` for shadow pattern (b), since the caller's prior fileHashes
|
||||
* map may use either depending on the OS that wrote it.
|
||||
*/
|
||||
|
||||
const SHADOW_EXTS = ['.d.ts', '.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs'];
|
||||
|
||||
/**
|
||||
* Enumerate pre-existing paths whose import-resolution `added` can steal.
|
||||
*
|
||||
* @param added — repo-relative path of a newly-added file
|
||||
* @returns deduplicated list of candidate paths (NOT filtered against
|
||||
* any known-files set — caller does that)
|
||||
*/
|
||||
export const shadowCandidatesFor = (added: string): string[] => {
|
||||
const ext = SHADOW_EXTS.find((e) => added.endsWith(e));
|
||||
if (!ext) return [];
|
||||
|
||||
const noExt = added.slice(0, -ext.length);
|
||||
const out = new Set<string>();
|
||||
|
||||
// (a) Same basename, different extension.
|
||||
for (const alt of SHADOW_EXTS) {
|
||||
if (alt !== ext) out.add(noExt + alt);
|
||||
}
|
||||
|
||||
// (b) Bare file beats sibling directory-style index.
|
||||
for (const idx of SHADOW_EXTS) {
|
||||
out.add(`${noExt}/index${idx}`);
|
||||
out.add(`${noExt}\\index${idx}`);
|
||||
}
|
||||
|
||||
// (c) New `foo/index.ext` shadows old `foo.ext`.
|
||||
const idxSuffixSlash = '/index';
|
||||
const idxSuffixBack = '\\index';
|
||||
let dir: string | null = null;
|
||||
if (noExt.endsWith(idxSuffixSlash)) dir = noExt.slice(0, -idxSuffixSlash.length);
|
||||
else if (noExt.endsWith(idxSuffixBack)) dir = noExt.slice(0, -idxSuffixBack.length);
|
||||
if (dir !== null) {
|
||||
for (const alt of SHADOW_EXTS) out.add(dir + alt);
|
||||
}
|
||||
|
||||
return [...out];
|
||||
};
|
||||
123
gitnexus/src/core/incremental/subgraph-extract.ts
Normal file
123
gitnexus/src/core/incremental/subgraph-extract.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* Subgraph extraction for incremental DB writeback.
|
||||
*
|
||||
* Given the FULL ctx.graph produced by the pipeline (all files parsed,
|
||||
* all phases run) and the set of file paths whose DB rows must be
|
||||
* replaced, produce a smaller KnowledgeGraph that contains:
|
||||
*
|
||||
* - Every node whose `properties.filePath` is in `toWriteSet`.
|
||||
* - Every graph-wide node (Community, Process) — these are regenerated
|
||||
* each run by the communities/processes phases and must be fully
|
||||
* rewritten.
|
||||
* - Every relationship where AT LEAST ONE endpoint is in the writable
|
||||
* set above. Relationships entirely between unchanged-file nodes
|
||||
* are skipped — their rows are still in the DB and re-inserting
|
||||
* them would PK-conflict at COPY time.
|
||||
*
|
||||
* The resulting subgraph is what gets passed to `loadGraphToLbug` after
|
||||
* the orchestrator has deleted the corresponding DB rows. Hydrated
|
||||
* unchanged-file rows are never touched in the DB.
|
||||
*
|
||||
* # Cross-file edge consistency (Finding 1)
|
||||
*
|
||||
* `extractChangedSubgraph` intentionally does NOT expand the set it is
|
||||
* given — expansion is the orchestrator's job, so the SAME expanded set
|
||||
* can be fed to both `deleteNodesForFile` and this function (asymmetry
|
||||
* between the delete set and the write set silently corrupts the DB).
|
||||
* `computeEffectiveWriteSet` below performs the boundary-crossing 1-hop
|
||||
* walk; the orchestrator composes it with its importer-BFS expansion and
|
||||
* passes the result here.
|
||||
*
|
||||
* Why the 1-hop walk is needed: consider a barrel re-export change —
|
||||
* file C (a barrel) shifts `export { foo } from './b'` to
|
||||
* `export { foo } from './d'`. After scope resolution, file A's CALLS
|
||||
* edge to `foo` resolves to D instead of B, even though A's content is
|
||||
* byte-for-byte identical:
|
||||
*
|
||||
* - Old A→B edge survives in DB (neither A nor B is changed → not deleted)
|
||||
* - New A→D edge is missing (neither A nor D in writable set → skipped)
|
||||
*
|
||||
* Pulling the unchanged-side file of every writable-boundary-crossing
|
||||
* edge into the write set fixes both halves: the orchestrator's
|
||||
* `DETACH DELETE` cleans up the stale unchanged-side rows, and the new
|
||||
* cross-file edges land because at least one endpoint is now writable.
|
||||
*
|
||||
* Limitation (documented): if a file X *stopped* importing from a
|
||||
* changed file C, X has no edge to C in the new graph, so this 1-hop
|
||||
* walk doesn't catch it. The orchestrator's importer-BFS (which reads
|
||||
* IMPORTS from the pre-pipeline DB) covers that case instead.
|
||||
*/
|
||||
|
||||
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
|
||||
import { createKnowledgeGraph } from '../graph/graph.js';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
|
||||
const isGraphWide = (label: string): boolean => label === 'Community' || label === 'Process';
|
||||
|
||||
/**
|
||||
* Build a Map<nodeId, filePath> for every File-bound node in the graph.
|
||||
* Graph-wide nodes (Community/Process) have no filePath and are filtered.
|
||||
*/
|
||||
const indexNodeFilePaths = (fullGraph: KnowledgeGraph): Map<string, string> => {
|
||||
const idx = new Map<string, string>();
|
||||
fullGraph.forEachNode((n: GraphNode) => {
|
||||
const fp = n.properties?.filePath as string | undefined;
|
||||
if (fp) idx.set(n.id, fp);
|
||||
});
|
||||
return idx;
|
||||
};
|
||||
|
||||
export const extractChangedSubgraph = (
|
||||
fullGraph: KnowledgeGraph,
|
||||
toWriteSet: ReadonlySet<string>,
|
||||
): KnowledgeGraph => {
|
||||
const sub = createKnowledgeGraph();
|
||||
const writableNodeIds = new Set<string>();
|
||||
|
||||
fullGraph.forEachNode((n: GraphNode) => {
|
||||
const filePath = n.properties?.filePath as string | undefined;
|
||||
const include = (filePath && toWriteSet.has(filePath)) || isGraphWide(n.label);
|
||||
if (include) {
|
||||
sub.addNode(n);
|
||||
writableNodeIds.add(n.id);
|
||||
}
|
||||
});
|
||||
|
||||
fullGraph.forEachRelationship((r: GraphRelationship) => {
|
||||
if (writableNodeIds.has(r.sourceId) || writableNodeIds.has(r.targetId)) {
|
||||
sub.addRelationship(r);
|
||||
}
|
||||
});
|
||||
|
||||
return sub;
|
||||
};
|
||||
|
||||
/**
|
||||
* Public — derive the EFFECTIVE write-set: `toWriteSet` expanded by one
|
||||
* hop along every edge in the new graph that crosses the writable
|
||||
* boundary (one endpoint in a writable file, the other in an unchanged
|
||||
* file). The unchanged-side file is pulled in so its stale rows are
|
||||
* deleted + rewritten in lockstep with the changed side.
|
||||
*
|
||||
* Single pass over the edge list. Does NOT mutate `toWriteSet`. The
|
||||
* orchestrator MUST feed the returned set to both `deleteNodesForFile`
|
||||
* and `extractChangedSubgraph` — feeding the unexpanded set to either
|
||||
* one leaves stale rows or PK-conflicts at COPY time.
|
||||
*/
|
||||
export const computeEffectiveWriteSet = (
|
||||
fullGraph: KnowledgeGraph,
|
||||
toWriteSet: ReadonlySet<string>,
|
||||
): Set<string> => {
|
||||
const nodeFilePaths = indexNodeFilePaths(fullGraph);
|
||||
const expanded = new Set<string>(toWriteSet);
|
||||
fullGraph.forEachRelationship((r: GraphRelationship) => {
|
||||
const sourcePath = nodeFilePaths.get(r.sourceId);
|
||||
const targetPath = nodeFilePaths.get(r.targetId);
|
||||
if (!sourcePath || !targetPath) return; // skip edges to graph-wide nodes
|
||||
const sourceWritable = toWriteSet.has(sourcePath);
|
||||
const targetWritable = toWriteSet.has(targetPath);
|
||||
if (sourceWritable && !targetWritable) expanded.add(targetPath);
|
||||
else if (targetWritable && !sourceWritable) expanded.add(sourcePath);
|
||||
});
|
||||
return expanded;
|
||||
};
|
||||
|
|
@ -42,12 +42,14 @@ import { isVerboseIngestionEnabled } from './utils/verbose.js';
|
|||
import { yieldToEventLoop } from './utils/event-loop.js';
|
||||
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
|
||||
import {
|
||||
CLASS_CONTAINER_TYPES,
|
||||
FUNCTION_NODE_TYPES,
|
||||
findEnclosingClassId,
|
||||
findEnclosingClassInfo,
|
||||
genericFuncName,
|
||||
inferFunctionLabel,
|
||||
} from './utils/ast-helpers.js';
|
||||
import type { FieldInfo, FieldExtractorContext } from './field-types.js';
|
||||
import type { LanguageProvider } from './language-provider.js';
|
||||
import { typeTagForId, constTagForId, buildCollisionGroups } from './utils/method-props.js';
|
||||
import type { MethodInfo } from './method-types.js';
|
||||
import {
|
||||
|
|
@ -77,6 +79,62 @@ import type { LiteralTypeInferrer } from './type-extractors/types.js';
|
|||
import type { SyntaxNode } from './utils/ast-helpers.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
// ── Property-prepass helpers (parity with parse-worker.ts) ──
|
||||
// These mirror the sequential-path equivalents in parse-worker.ts so the main-
|
||||
// thread `processCalls` pre-pass produces byte-identical Property nodes/symbols
|
||||
// to the worker pool. Drift between the two paths breaks the
|
||||
// `incremental ≡ --force` invariant the moment a repo crosses the worker
|
||||
// threshold between runs.
|
||||
|
||||
/** Walk up to the nearest enclosing class/struct/interface AST node. */
|
||||
const findEnclosingClassNode = (node: SyntaxNode): SyntaxNode | null => {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
if (CLASS_CONTAINER_TYPES.has(current.type)) return current;
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** No-op SymbolTable stub for FieldExtractorContext — matches parse-worker. */
|
||||
const NOOP_SYMBOL_TABLE: SymbolTableReader = {
|
||||
lookupExact: () => undefined,
|
||||
lookupExactFull: () => undefined,
|
||||
lookupExactAll: () => [],
|
||||
lookupCallableByName: () => [],
|
||||
getFiles: () => [][Symbol.iterator](),
|
||||
getStats: () => ({ fileCount: 0 }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract (and cache) field info for a class node. Cache is passed in so it
|
||||
* stays scoped to a single `processCalls` invocation rather than leaking
|
||||
* across analyze runs (worker uses module-level caching because each worker
|
||||
* process is short-lived; the main thread is not).
|
||||
*
|
||||
* Cache key is `${filePath}:${classNode.startIndex}` — startIndex alone is a
|
||||
* per-file byte offset, so almost every Ruby/Python file's leading class lands
|
||||
* at byte 0 and would collide across files in the shared map.
|
||||
*/
|
||||
const getFieldInfo = (
|
||||
classNode: SyntaxNode,
|
||||
provider: LanguageProvider,
|
||||
context: FieldExtractorContext,
|
||||
cache: Map<string, Map<string, FieldInfo>>,
|
||||
): Map<string, FieldInfo> | undefined => {
|
||||
if (!provider.fieldExtractor) return undefined;
|
||||
const cacheKey = `${context.filePath}:${classNode.startIndex}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const result = provider.fieldExtractor.extract(classNode, context);
|
||||
if (!result?.fields?.length) return undefined;
|
||||
const map = new Map<string, FieldInfo>();
|
||||
for (const field of result.fields) map.set(field.name, field);
|
||||
cache.set(cacheKey, map);
|
||||
return map;
|
||||
};
|
||||
|
||||
/** Per-file resolved type bindings for exported symbols.
|
||||
* Populated during call processing, consumed by Phase 14 re-resolution pass. */
|
||||
export type ExportedTypeMap = Map<string, Map<string, string>>;
|
||||
|
|
@ -716,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
|
||||
|
|
@ -860,6 +919,120 @@ export const processCalls = async (
|
|||
prepared.push({ file, language, provider, tree, matches, parentMap, typeEnv });
|
||||
}
|
||||
|
||||
// ── Property-registration pre-pass ──
|
||||
// Register all routed properties (e.g. Ruby attr_accessor) BEFORE the
|
||||
// resolution loop so cross-file field-type lookups (e.g.
|
||||
// `user.address.save → Address#save`) succeed regardless of file
|
||||
// processing order. This MUST stay in lockstep with the equivalent
|
||||
// worker-path block in parse-worker.ts (kind === 'properties') — any
|
||||
// divergence between the two paths breaks the `incremental ≡ --force`
|
||||
// invariant once a repo crosses the worker threshold between runs.
|
||||
const fieldInfoCache = new Map<string, Map<string, FieldInfo>>();
|
||||
for (const { file, language, provider, matches, typeEnv } of prepared) {
|
||||
const callRouter = provider.callRouter;
|
||||
if (!callRouter) continue;
|
||||
matches.forEach((match) => {
|
||||
const captureMap: Record<string, any> = {};
|
||||
match.captures.forEach((c) => (captureMap[c.name] = c.node));
|
||||
if (!captureMap['call']) return;
|
||||
const callNameNode = captureMap['call.name'];
|
||||
if (!callNameNode) return;
|
||||
const routed = callRouter(callNameNode.text, captureMap['call']);
|
||||
if (!routed || routed.kind !== 'properties') return;
|
||||
|
||||
const propEnclosingInfo = findEnclosingClassInfo(
|
||||
captureMap['call'],
|
||||
file.path,
|
||||
provider.resolveEnclosingOwner,
|
||||
);
|
||||
const propEnclosingClassId = propEnclosingInfo?.classId ?? null;
|
||||
|
||||
// Enrich routed properties with FieldExtractor metadata so types
|
||||
// discovered from constructor assignments (e.g. `@address = Address.new`)
|
||||
// are propagated even when the routing payload itself lacks declaredType.
|
||||
let routedFieldMap: Map<string, FieldInfo> | undefined;
|
||||
if (provider.fieldExtractor && typeEnv) {
|
||||
const classNode = findEnclosingClassNode(captureMap['call']);
|
||||
if (classNode) {
|
||||
routedFieldMap = getFieldInfo(
|
||||
classNode,
|
||||
provider,
|
||||
{
|
||||
typeEnv,
|
||||
symbolTable: NOOP_SYMBOL_TABLE,
|
||||
filePath: file.path,
|
||||
language,
|
||||
},
|
||||
fieldInfoCache,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fileId = generateId('File', file.path);
|
||||
for (const item of routed.items) {
|
||||
const routedFieldInfo = routedFieldMap?.get(item.propName);
|
||||
const propQualifiedName = propEnclosingInfo
|
||||
? `${propEnclosingInfo.className}.${item.propName}`
|
||||
: item.propName;
|
||||
const nodeId = generateId('Property', `${file.path}:${propQualifiedName}`);
|
||||
graph.addNode({
|
||||
id: nodeId,
|
||||
label: 'Property',
|
||||
properties: {
|
||||
name: item.propName,
|
||||
filePath: file.path,
|
||||
startLine: item.startLine,
|
||||
endLine: item.endLine,
|
||||
language,
|
||||
isExported: true,
|
||||
description: item.accessorType,
|
||||
...(item.declaredType
|
||||
? { declaredType: item.declaredType }
|
||||
: routedFieldInfo?.type
|
||||
? { declaredType: routedFieldInfo.type }
|
||||
: {}),
|
||||
...(routedFieldInfo?.visibility !== undefined
|
||||
? { visibility: routedFieldInfo.visibility }
|
||||
: {}),
|
||||
...(routedFieldInfo?.isStatic !== undefined
|
||||
? { isStatic: routedFieldInfo.isStatic }
|
||||
: {}),
|
||||
...(routedFieldInfo?.isReadonly !== undefined
|
||||
? { isReadonly: routedFieldInfo.isReadonly }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
ctx.model.symbols.add(file.path, item.propName, nodeId, 'Property', {
|
||||
...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
|
||||
...(item.declaredType
|
||||
? { declaredType: item.declaredType }
|
||||
: routedFieldInfo?.type
|
||||
? { declaredType: routedFieldInfo.type }
|
||||
: {}),
|
||||
});
|
||||
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
|
||||
graph.addRelationship({
|
||||
id: relId,
|
||||
sourceId: fileId,
|
||||
targetId: nodeId,
|
||||
type: 'DEFINES',
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
if (propEnclosingClassId) {
|
||||
graph.addRelationship({
|
||||
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
|
||||
sourceId: propEnclosingClassId,
|
||||
targetId: nodeId,
|
||||
type: 'HAS_PROPERTY',
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Resolution loop: verify constructor bindings and resolve calls ──
|
||||
// The accumulator (if present) is now fully populated from the preparation
|
||||
// loop above, so verifyConstructorBindings sees all provider bindings
|
||||
|
|
@ -933,7 +1106,13 @@ export const processCalls = async (
|
|||
// 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 });
|
||||
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.
|
||||
|
|
@ -1053,47 +1232,8 @@ export const processCalls = async (
|
|||
return;
|
||||
|
||||
case 'properties': {
|
||||
const fileId = generateId('File', file.path);
|
||||
const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path);
|
||||
for (const item of routed.items) {
|
||||
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
|
||||
graph.addNode({
|
||||
id: nodeId,
|
||||
label: 'Property',
|
||||
properties: {
|
||||
name: item.propName,
|
||||
filePath: file.path,
|
||||
startLine: item.startLine,
|
||||
endLine: item.endLine,
|
||||
language,
|
||||
isExported: true,
|
||||
description: item.accessorType,
|
||||
},
|
||||
});
|
||||
ctx.model.symbols.add(file.path, item.propName, nodeId, 'Property', {
|
||||
...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
|
||||
...(item.declaredType ? { declaredType: item.declaredType } : {}),
|
||||
});
|
||||
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
|
||||
graph.addRelationship({
|
||||
id: relId,
|
||||
sourceId: fileId,
|
||||
targetId: nodeId,
|
||||
type: 'DEFINES',
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
if (propEnclosingClassId) {
|
||||
graph.addRelationship({
|
||||
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
|
||||
sourceId: propEnclosingClassId,
|
||||
targetId: nodeId,
|
||||
type: 'HAS_PROPERTY',
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
// Properties already registered in the pre-pass above.
|
||||
// Skip to avoid duplicate nodes/edges.
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1382,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',
|
||||
|
|
@ -2979,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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,24 @@ interface LeidenDetailedResult {
|
|||
modularity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic PRNG (mulberry32) seed for the vendored Leiden algorithm.
|
||||
* Vendored Leiden defaults `rng: Math.random`, which makes community
|
||||
* assignment non-deterministic across runs. Passing a seeded RNG gives us
|
||||
* reproducible community/modularity output, which is required for the
|
||||
* incremental-indexing equivalence test (incremental ≡ full rebuild).
|
||||
*/
|
||||
const LEIDEN_SEED = 0xc0de;
|
||||
function createSeededRng(seed: number): () => number {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
|
@ -150,6 +168,7 @@ export const processCommunities = async (
|
|||
leiden.detailed(graph, {
|
||||
resolution: isLarge ? 2.0 : 1.0,
|
||||
maxIterations: isLarge ? 3 : 0,
|
||||
rng: createSeededRng(LEIDEN_SEED),
|
||||
}),
|
||||
),
|
||||
new Promise<never>((_, reject) =>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -128,7 +135,18 @@ export const resolveImportPath = (
|
|||
|
||||
if (importPath.startsWith('.')) {
|
||||
const resolved = tryResolveWithExtensions(basePath, allFiles);
|
||||
return cache(resolved);
|
||||
if (resolved) return cache(resolved);
|
||||
|
||||
// TypeScript ESM: imports use .js/.jsx/.mjs/.cjs but source files are
|
||||
// .ts/.tsx/.mts/.cts. Strip the JS-family extension and re-resolve.
|
||||
if (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) {
|
||||
const stripped = stripJsExtension(basePath);
|
||||
if (stripped !== null) {
|
||||
return cache(tryResolveWithExtensions(stripped, allFiles));
|
||||
}
|
||||
}
|
||||
|
||||
return cache(null);
|
||||
}
|
||||
|
||||
// ---- Generic package/absolute import resolution (suffix matching) ----
|
||||
|
|
@ -182,3 +200,19 @@ export function resolveStandard(
|
|||
export function createStandardStrategy(language: SupportedLanguages): ImportResolverStrategy {
|
||||
return (raw, fp, ctx) => resolveStandard(raw, fp, ctx, language);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ESM extension helpers
|
||||
// ============================================================================
|
||||
|
||||
/** JS-family extensions that TypeScript ESM maps to TS equivalents. */
|
||||
const JS_EXTENSION_PATTERN = /\.(js|jsx|mjs|cjs)$/;
|
||||
|
||||
/**
|
||||
* Strip a JS-family extension from a path, returning the stem.
|
||||
* Returns `null` if the path does not end with a JS-family extension.
|
||||
*/
|
||||
export function stripJsExtension(path: string): string | null {
|
||||
const match = JS_EXTENSION_PATTERN.exec(path);
|
||||
return match ? path.slice(0, -match[0].length) : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,12 @@ export const EXTENSIONS = [
|
|||
// TypeScript/JavaScript
|
||||
'.tsx',
|
||||
'.ts',
|
||||
'.mts',
|
||||
'.cts',
|
||||
'.jsx',
|
||||
'.js',
|
||||
'.mjs',
|
||||
'.cjs',
|
||||
'.vue',
|
||||
'/index.tsx',
|
||||
'/index.ts',
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
});
|
||||
|
|
|
|||
330
gitnexus/src/core/ingestion/languages/cpp/adl.ts
Normal file
330
gitnexus/src/core/ingestion/languages/cpp/adl.ts
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
/**
|
||||
* 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 ONE associated-entity rule: an argument that's a directly-named
|
||||
* class type (`audit::Event e`) contributes its **direct enclosing
|
||||
* namespace** to the candidate set. V2 extends that one step to
|
||||
* pointer-typed and reference-typed class args (`audit::Event* p`,
|
||||
* `audit::Event& r`, `audit::Event&& rr`): they contribute the pointee /
|
||||
* referred class's enclosing namespace too. Function-pointer arguments,
|
||||
* template specializations, base-class associated namespaces, and the
|
||||
* rest of the full closure are still deliberately excluded.
|
||||
*
|
||||
* 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, template specs, etc. */
|
||||
readonly simpleClassName: 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) {
|
||||
if (arg.simpleClassName === '') continue;
|
||||
const classDef = findCppClassDefBySimpleName(arg.simpleClassName, scopes);
|
||||
if (classDef === undefined) continue;
|
||||
const nsQName = classToNamespaceQualifiedName.get(classDef.nodeId);
|
||||
if (nsQName !== undefined) associatedNamespaces.add(nsQName);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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
|
||||
* arbitrary-pick on collisions (multiple classes share the simple name);
|
||||
* C++ ADL strictness would require full type-driven lookup, but V1
|
||||
* trades that for simplicity. */
|
||||
function findCppClassDefBySimpleName(
|
||||
simpleName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): 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) return def;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
185
gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts
Normal file
185
gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts
Normal 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;
|
||||
}
|
||||
35
gitnexus/src/core/ingestion/languages/cpp/arity.ts
Normal file
35
gitnexus/src/core/ingestion/languages/cpp/arity.ts
Normal 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';
|
||||
}
|
||||
899
gitnexus/src/core/ingestion/languages/cpp/captures.ts
Normal file
899
gitnexus/src/core/ingestion/languages/cpp/captures.ts
Normal file
|
|
@ -0,0 +1,899 @@
|
|||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import {
|
||||
findNodeAtRange,
|
||||
nodeToCapture,
|
||||
syntheticCapture,
|
||||
type SyntaxNode,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { getCppParser, getCppScopeQuery } from './query.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
import { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js';
|
||||
import { computeCppDeclarationArity, computeCppCallArity } from './arity-metadata.js';
|
||||
import { markFileLocal } from './file-local-linkage.js';
|
||||
import { markCppDependentBase } from './two-phase-lookup.js';
|
||||
import { markCppAdlSiteArgs, markCppAdlSiteNoAdl, type CppAdlArgInfo } from './adl.js';
|
||||
import { markCppInlineNamespaceRange } from './inline-namespaces.js';
|
||||
|
||||
export function emitCppScopeCaptures(
|
||||
sourceText: string,
|
||||
filePath: string,
|
||||
cachedTree?: unknown,
|
||||
): readonly CaptureMatch[] {
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getCppParser>['parse']> | undefined;
|
||||
if (tree === undefined) {
|
||||
tree = parseSourceSafe(getCppParser(), sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
}
|
||||
|
||||
const rawMatches = getCppScopeQuery().matches(tree.rootNode);
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
// Track ranges where typedef-struct was captured as @declaration.struct
|
||||
// so we can suppress the duplicate @declaration.typedef match.
|
||||
const structTypedefRanges = new Set<string>();
|
||||
|
||||
for (const m of rawMatches) {
|
||||
const grouped: Record<string, Capture> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
if (tag.startsWith('@_')) continue;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
// ── Handle #include statements ──────────────────────────────────
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
const anchor = grouped['@import.statement']!;
|
||||
const includeNode = findNodeAtRange(tree.rootNode, anchor.range, 'preproc_include');
|
||||
if (includeNode !== null) {
|
||||
const split = splitCppInclude(includeNode);
|
||||
if (split !== null) {
|
||||
out.push(split);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handle using declarations (using namespace / using name) ────
|
||||
if (grouped['@import.using-decl'] !== undefined) {
|
||||
const anchor = grouped['@import.using-decl']!;
|
||||
const usingNode = findNodeAtRange(tree.rootNode, anchor.range, 'using_declaration');
|
||||
if (usingNode !== null) {
|
||||
const split = splitCppUsingDecl(usingNode);
|
||||
if (split !== null) {
|
||||
out.push(split);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Track typedef-struct ranges ─────────────────────────────────
|
||||
const structAnchor = grouped['@declaration.struct'] ?? grouped['@declaration.class'];
|
||||
if (structAnchor !== undefined) {
|
||||
const r = structAnchor.range;
|
||||
structTypedefRanges.add(`${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`);
|
||||
}
|
||||
|
||||
// Suppress @declaration.typedef if the same range was already captured
|
||||
const typedefAnchor = grouped['@declaration.typedef'];
|
||||
if (typedefAnchor !== undefined) {
|
||||
const r = typedefAnchor.range;
|
||||
const key = `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`;
|
||||
if (structTypedefRanges.has(key)) continue;
|
||||
}
|
||||
|
||||
// ── Enrich function/method declarations with arity metadata ─────
|
||||
const declAnchor = grouped['@declaration.function'] ?? grouped['@declaration.method'];
|
||||
if (declAnchor !== undefined) {
|
||||
const fnNode =
|
||||
findNodeAtRange(tree.rootNode, declAnchor.range, 'function_definition') ??
|
||||
findNodeAtRange(tree.rootNode, declAnchor.range, 'declaration') ??
|
||||
findNodeAtRange(tree.rootNode, declAnchor.range, 'field_declaration');
|
||||
if (fnNode !== null) {
|
||||
const arity = computeCppDeclarationArity(fnNode);
|
||||
if (arity.parameterCount !== undefined) {
|
||||
grouped['@declaration.parameter-count'] = syntheticCapture(
|
||||
'@declaration.parameter-count',
|
||||
fnNode,
|
||||
String(arity.parameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.requiredParameterCount !== undefined) {
|
||||
grouped['@declaration.required-parameter-count'] = syntheticCapture(
|
||||
'@declaration.required-parameter-count',
|
||||
fnNode,
|
||||
String(arity.requiredParameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.parameterTypes !== undefined) {
|
||||
grouped['@declaration.parameter-types'] = syntheticCapture(
|
||||
'@declaration.parameter-types',
|
||||
fnNode,
|
||||
JSON.stringify(arity.parameterTypes),
|
||||
);
|
||||
}
|
||||
|
||||
// Detect static storage class (file-local linkage)
|
||||
if (hasStaticStorageClass(fnNode)) {
|
||||
const nameText = grouped['@declaration.name']?.text;
|
||||
if (nameText !== undefined) {
|
||||
markFileLocal(filePath, nameText);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect anonymous namespace (file-local linkage)
|
||||
if (isInsideAnonymousNamespace(fnNode)) {
|
||||
const nameText = grouped['@declaration.name']?.text;
|
||||
if (nameText !== undefined) {
|
||||
markFileLocal(filePath, nameText);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Detect static variables (file-local linkage) ────────────────
|
||||
const varDeclAnchor = grouped['@declaration.variable'];
|
||||
if (varDeclAnchor !== undefined) {
|
||||
const varNode = findNodeAtRange(tree.rootNode, varDeclAnchor.range, 'declaration');
|
||||
if (varNode !== null) {
|
||||
if (hasStaticStorageClass(varNode) || isInsideAnonymousNamespace(varNode)) {
|
||||
const nameText = grouped['@declaration.name']?.text;
|
||||
if (nameText !== undefined) {
|
||||
markFileLocal(filePath, nameText);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Enrich call references with arity ───────────────────────────
|
||||
const callAnchor =
|
||||
grouped['@reference.call.free'] ??
|
||||
grouped['@reference.call.member'] ??
|
||||
grouped['@reference.call.qualified'];
|
||||
if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
const callNode = findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression');
|
||||
if (callNode !== null) {
|
||||
grouped['@reference.arity'] = syntheticCapture(
|
||||
'@reference.arity',
|
||||
callNode,
|
||||
String(computeCppCallArity(callNode)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Enrich constructor calls (new Foo()) with arity ─────────────
|
||||
const ctorCallAnchor = grouped['@reference.call.constructor'];
|
||||
if (ctorCallAnchor !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
const newNode = findNodeAtRange(tree.rootNode, ctorCallAnchor.range, 'new_expression');
|
||||
if (newNode !== null) {
|
||||
grouped['@reference.arity'] = syntheticCapture(
|
||||
'@reference.arity',
|
||||
newNode,
|
||||
String(computeCppCallArity(newNode)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Synthesize argument types for overload narrowing ────────────
|
||||
const anyCallAnchor = callAnchor ?? ctorCallAnchor;
|
||||
if (anyCallAnchor !== undefined && grouped['@reference.parameter-types'] === undefined) {
|
||||
const cNode =
|
||||
findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'new_expression');
|
||||
if (cNode !== null) {
|
||||
const argTypes = inferCppCallArgTypes(cNode);
|
||||
if (argTypes !== undefined && argTypes.length > 0) {
|
||||
grouped['@reference.parameter-types'] = syntheticCapture(
|
||||
'@reference.parameter-types',
|
||||
cNode,
|
||||
JSON.stringify(argTypes),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inline namespace detection ──────────────────────────────────
|
||||
// `inline namespace v1 { ... }` — tree-sitter-cpp exposes the
|
||||
// `inline` keyword as a child of `namespace_definition`. Record the
|
||||
// namespace's source range so `populateCppInlineNamespaceScopes`
|
||||
// (during populateOwners) can match it back to the corresponding
|
||||
// Namespace scope.
|
||||
if (grouped['@declaration.namespace'] !== undefined) {
|
||||
const anchor = grouped['@declaration.namespace']!;
|
||||
const nsNode = findNodeAtRange(tree.rootNode, anchor.range, 'namespace_definition');
|
||||
if (nsNode !== null && isInlineNamespace(nsNode)) {
|
||||
// Range coords stored in the shared Range shape use 1-based
|
||||
// line numbers (see `ast-helpers.ts` rangeForNode where
|
||||
// `startPosition.row + 1` is applied). Match that convention so
|
||||
// `populateCppInlineNamespaceScopes` can join against `Scope.range`.
|
||||
markCppInlineNamespaceRange(filePath, {
|
||||
startLine: nsNode.startPosition.row + 1,
|
||||
startCol: nsNode.startPosition.column,
|
||||
endLine: nsNode.endPosition.row + 1,
|
||||
endCol: nsNode.endPosition.column,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── ADL (Koenig lookup) per-site recording ──────────────────────
|
||||
// Only free-call sites (no explicit receiver) participate in ADL —
|
||||
// qualified `Ns::f(s)` and member `obj.f(s)` calls bypass the
|
||||
// free-call fallback entirely (handled by receiver-bound-calls).
|
||||
if (grouped['@reference.call.free'] !== undefined) {
|
||||
const freeCallNode = findNodeAtRange(
|
||||
tree.rootNode,
|
||||
grouped['@reference.call.free']!.range,
|
||||
'call_expression',
|
||||
);
|
||||
if (freeCallNode !== null) {
|
||||
const adlAnchorRange = grouped['@reference.call.free']!.range;
|
||||
if (isParenthesizedFunctionCall(freeCallNode)) {
|
||||
markCppAdlSiteNoAdl(filePath, adlAnchorRange.startLine, adlAnchorRange.startCol);
|
||||
}
|
||||
const adlArgs = inferCppCallAdlArgs(freeCallNode);
|
||||
if (adlArgs.length > 0) {
|
||||
markCppAdlSiteArgs(filePath, adlAnchorRange.startLine, adlAnchorRange.startCol, adlArgs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Post-process @type-binding.assignment for auto declarations ──
|
||||
// The wildcard `type: (_)` in the @type-binding.assignment query
|
||||
// pattern matches before the more specific @type-binding.alias and
|
||||
// @type-binding.member-access patterns. When the type is `auto`
|
||||
// (placeholder_type_specifier), we re-inspect the AST to synthesize
|
||||
// the correct capture tags so interpret.ts can produce the right
|
||||
// rawTypeName for compound-receiver chain resolution.
|
||||
if (
|
||||
grouped['@type-binding.assignment'] !== undefined &&
|
||||
grouped['@type-binding.type']?.text === 'auto'
|
||||
) {
|
||||
const anchor = grouped['@type-binding.assignment']!;
|
||||
const declNode = findNodeAtRange(tree.rootNode, anchor.range, 'declaration');
|
||||
if (declNode !== null) {
|
||||
const declarator = declNode.childForFieldName('declarator');
|
||||
if (declarator?.type === 'init_declarator') {
|
||||
const valueNode = declarator.childForFieldName('value');
|
||||
if (valueNode !== null) {
|
||||
if (valueNode.type === 'identifier') {
|
||||
// auto alias = existingVar → promote to @type-binding.alias
|
||||
grouped['@type-binding.alias'] = anchor;
|
||||
grouped['@type-binding.type'] = nodeToCapture('@type-binding.type', valueNode);
|
||||
delete grouped['@type-binding.assignment'];
|
||||
} else if (valueNode.type === 'field_expression') {
|
||||
// auto addr = user.address → promote to @type-binding.member-access
|
||||
const argNode = valueNode.childForFieldName('argument');
|
||||
const fieldNode = valueNode.childForFieldName('field');
|
||||
if (argNode !== null && fieldNode !== null) {
|
||||
grouped['@type-binding.member-access'] = anchor;
|
||||
grouped['@type-binding.member-access-receiver'] = nodeToCapture(
|
||||
'@type-binding.member-access-receiver',
|
||||
argNode,
|
||||
);
|
||||
grouped['@type-binding.type'] = nodeToCapture('@type-binding.type', fieldNode);
|
||||
delete grouped['@type-binding.assignment'];
|
||||
}
|
||||
} else if (valueNode.type === 'call_expression') {
|
||||
const fnNode = valueNode.childForFieldName('function');
|
||||
if (fnNode?.type === 'field_expression') {
|
||||
// auto city = addr.getCity() → promote to @type-binding.alias
|
||||
// with dotted rawName "addr.getCity" for compound-receiver
|
||||
const argNode = fnNode.childForFieldName('argument');
|
||||
const fieldNode = fnNode.childForFieldName('field');
|
||||
if (argNode !== null && fieldNode !== null) {
|
||||
grouped['@type-binding.member-access'] = anchor;
|
||||
grouped['@type-binding.member-access-receiver'] = nodeToCapture(
|
||||
'@type-binding.member-access-receiver',
|
||||
argNode,
|
||||
);
|
||||
grouped['@type-binding.type'] = nodeToCapture('@type-binding.type', fieldNode);
|
||||
delete grouped['@type-binding.assignment'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.push(grouped);
|
||||
}
|
||||
|
||||
// ── Emit inheritance references for scope-resolution MRO / EXTENDS ──
|
||||
// Walk every class/struct base list and synthesize `@reference.inherits`
|
||||
// captures consumed by the registry-primary graph bridge. The lookup name
|
||||
// is normalized to the bare class name so `Base<T>` / `outer::v1::Base<T>`
|
||||
// resolve through V1's simple-name `findClassBindingInScope('Base')`.
|
||||
emitCppInheritanceCaptures(tree.rootNode, out);
|
||||
|
||||
// ── Detect dependent-base relationships for two-phase template lookup ──
|
||||
// Walk the tree once, finding every `template_declaration` whose
|
||||
// child is a class/struct definition with a `base_class_clause` whose
|
||||
// base names reference an in-scope template parameter. Record the
|
||||
// (className, dependentBaseName) pair so `populateCppDependentBases`
|
||||
// (called from the `populateOwners` hook) can resolve names to nodeIds
|
||||
// and the resolver can suppress unqualified-call binding to those
|
||||
// bases per ISO C++ two-phase lookup.
|
||||
detectCppDependentBases(tree.rootNode, filePath);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every C++ class/struct base clause and emit `@reference.inherits`
|
||||
* captures for each base so scope resolution can resolve them into EXTENDS
|
||||
* edges. Lookup names are normalized to bare class names (`Base<T>` → `Base`,
|
||||
* `outer::v1::Base<T>` → `Base`) to match the V1 simple-name
|
||||
* `findClassBindingInScope` contract. This intentionally preserves the
|
||||
* existing scope-chain tradeoff: qualified namespace context is discarded
|
||||
* here instead of introducing a C++-only name-resolution lane in shared
|
||||
* ingestion infrastructure.
|
||||
*/
|
||||
function emitCppInheritanceCaptures(root: SyntaxNode, out: CaptureMatch[]): void {
|
||||
const stack: SyntaxNode[] = [root];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
if (node.type === 'class_specifier' || node.type === 'struct_specifier') {
|
||||
const baseClause = findChildOfType(node, ['base_class_clause']);
|
||||
if (baseClause !== null) {
|
||||
for (const base of iterBaseClasses(baseClause)) {
|
||||
const baseName = extractBaseLookupName(base);
|
||||
if (baseName.length === 0) continue;
|
||||
out.push({
|
||||
'@reference.inherits': nodeToCapture('@reference.inherits', base),
|
||||
'@reference.name': syntheticCapture('@reference.name', base, baseName),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child !== null) stack.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the AST finding every template_declaration containing a class or
|
||||
* struct definition with a dependent base. Records (className, baseName)
|
||||
* pairs into the module-level state via `markCppDependentBase`.
|
||||
*
|
||||
* A base is "dependent" when its name (typically a template_type like
|
||||
* `Base<T>`) uses a template parameter of the enclosing template_declaration.
|
||||
* Conservative bias: `typename T::U`, `decltype(...)` and template-template
|
||||
* parameter shapes are also treated as dependent.
|
||||
*/
|
||||
function detectCppDependentBases(root: SyntaxNode, filePath: string): void {
|
||||
const stack: SyntaxNode[] = [root];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
if (node.type === 'template_declaration') {
|
||||
// Collect template-parameter names declared by this declaration.
|
||||
// Inner template_declarations shadow outer ones — handled by the
|
||||
// recursive descent below (each template_declaration creates its
|
||||
// own parameter scope).
|
||||
const params = collectTemplateParameterNames(node);
|
||||
|
||||
// Find the class/struct definition inside this template_declaration.
|
||||
const classNode = findChildOfType(node, ['class_specifier', 'struct_specifier']);
|
||||
if (classNode !== null) {
|
||||
const className = getTypeIdentifierName(classNode);
|
||||
if (className !== '') {
|
||||
const baseClause = findChildOfType(classNode, ['base_class_clause']);
|
||||
if (baseClause !== null) {
|
||||
for (const base of iterBaseClasses(baseClause)) {
|
||||
if (isBaseDependent(base, params)) {
|
||||
const baseName = extractBaseLookupName(base);
|
||||
if (baseName !== '') {
|
||||
markCppDependentBase(filePath, className, baseName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child !== null) stack.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect simple template parameter names from a template_declaration. */
|
||||
function collectTemplateParameterNames(templateDecl: SyntaxNode): Set<string> {
|
||||
const names = new Set<string>();
|
||||
const paramList = findChildOfType(templateDecl, ['template_parameter_list']);
|
||||
if (paramList === null) return names;
|
||||
for (let i = 0; i < paramList.childCount; i++) {
|
||||
const param = paramList.child(i);
|
||||
if (param === null) continue;
|
||||
if (
|
||||
param.type === 'type_parameter_declaration' ||
|
||||
param.type === 'optional_type_parameter_declaration' ||
|
||||
param.type === 'variadic_type_parameter_declaration'
|
||||
) {
|
||||
const idNode = findFirstDescendantOfType(param, 'type_identifier');
|
||||
if (idNode !== null) names.add(idNode.text);
|
||||
} else if (
|
||||
param.type === 'parameter_declaration' ||
|
||||
param.type === 'optional_parameter_declaration' ||
|
||||
param.type === 'variadic_parameter_declaration'
|
||||
) {
|
||||
// Non-type template parameter (e.g. `template<int N>`).
|
||||
const idNode = findFirstDescendantOfType(param, 'identifier');
|
||||
if (idNode !== null) names.add(idNode.text);
|
||||
} else if (param.type === 'template_template_parameter_declaration') {
|
||||
// template-template parameter (e.g. `template<template<class> class TT>`)
|
||||
const idNode = findFirstDescendantOfType(param, 'type_identifier');
|
||||
if (idNode !== null) names.add(idNode.text);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Yield each base-class entry from a `base_class_clause`. */
|
||||
function* iterBaseClasses(baseClause: SyntaxNode): IterableIterator<SyntaxNode> {
|
||||
for (let i = 0; i < baseClause.childCount; i++) {
|
||||
const child = baseClause.child(i);
|
||||
if (child === null) continue;
|
||||
// Skip ':', ',', and access_specifier nodes — the base names are
|
||||
// type_identifier, template_type, or qualified_identifier.
|
||||
if (
|
||||
child.type === 'type_identifier' ||
|
||||
child.type === 'template_type' ||
|
||||
child.type === 'qualified_identifier'
|
||||
) {
|
||||
yield child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A base is dependent when:
|
||||
* - it's a `template_type` and its argument list contains a
|
||||
* `type_identifier` matching one of the enclosing template's params
|
||||
* (e.g., `Base<T>` where `T` is a template parameter), OR
|
||||
* - it contains a `typename`, `decltype`, or `template_template_parameter`
|
||||
* shape (conservatively treated as dependent).
|
||||
*
|
||||
* Non-dependent: `Base<int>`, `ConcreteBase`, `Base<MyConcrete>` where
|
||||
* `MyConcrete` is not a template parameter.
|
||||
*/
|
||||
function isBaseDependent(baseNode: SyntaxNode, templateParams: Set<string>): boolean {
|
||||
if (baseNode.type !== 'template_type') {
|
||||
// Bare `type_identifier` or `qualified_identifier` bases — not
|
||||
// dependent (the base name itself doesn't reference a template
|
||||
// parameter at this level).
|
||||
return false;
|
||||
}
|
||||
// Walk all descendants of the template_argument_list looking for any
|
||||
// type_identifier matching a template parameter, or any conservative-
|
||||
// dependent shape.
|
||||
const stack: SyntaxNode[] = [baseNode];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
if (node.type === 'type_identifier' && templateParams.has(node.text)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
node.type === 'decltype' ||
|
||||
node.type === 'dependent_type' ||
|
||||
node.type === 'template_template_parameter_declaration'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (node.type === 'qualified_identifier') {
|
||||
// `typename T::U` or `T::nested` — if any inner identifier matches
|
||||
// a template parameter, dependent.
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c !== null) stack.push(c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c !== null) stack.push(c);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively extract the bare lookup name of a base class node.
|
||||
* Examples: `Base` → `Base`, `Base<T>` → `Base`,
|
||||
* `outer::v1::Base<T>` → `Base`. Namespace qualifiers are intentionally
|
||||
* dropped to align with V1 scope-chain lookup everywhere else in the
|
||||
* registry-primary pipeline.
|
||||
*/
|
||||
function extractBaseLookupName(baseNode: SyntaxNode): string {
|
||||
if (baseNode.type === 'type_identifier' || baseNode.type === 'identifier') return baseNode.text;
|
||||
if (baseNode.type === 'template_type') {
|
||||
const nameNode = baseNode.childForFieldName('name');
|
||||
if (nameNode !== null) return extractBaseLookupName(nameNode);
|
||||
const id =
|
||||
findFirstDescendantOfType(baseNode, 'type_identifier') ??
|
||||
findFirstDescendantOfType(baseNode, 'identifier');
|
||||
if (id !== null) return id.text;
|
||||
}
|
||||
if (baseNode.type === 'qualified_identifier') {
|
||||
const nameNode = baseNode.childForFieldName('name');
|
||||
if (nameNode !== null) {
|
||||
const nested = extractBaseLookupName(nameNode);
|
||||
if (nested.length > 0) return nested;
|
||||
}
|
||||
for (let i = baseNode.childCount - 1; i >= 0; i--) {
|
||||
const child = baseNode.child(i);
|
||||
if (child === null) continue;
|
||||
const nested = extractBaseLookupName(child);
|
||||
if (nested.length > 0) return nested;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Find the first direct child matching one of the given types. */
|
||||
function findChildOfType(node: SyntaxNode, types: readonly string[]): SyntaxNode | null {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c !== null && types.includes(c.type)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recursive search for the first descendant of a given type. */
|
||||
function findFirstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | null {
|
||||
if (node.type === type) return node;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c === null) continue;
|
||||
const hit = findFirstDescendantOfType(c, type);
|
||||
if (hit !== null) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Get the name of a class/struct/template_type node via its `name` field. */
|
||||
function getTypeIdentifierName(node: SyntaxNode): string {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (nameNode !== null) return nameNode.text;
|
||||
const id = findFirstDescendantOfType(node, 'type_identifier');
|
||||
return id !== null ? id.text : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer argument types from a call_expression or new_expression node.
|
||||
* Used for overload disambiguation by parameter types.
|
||||
*
|
||||
* Only literal types are inferred — identifiers and complex expressions
|
||||
* return empty string (unknown) so narrowOverloadCandidates treats them
|
||||
* as any-match.
|
||||
*/
|
||||
function inferCppCallArgTypes(node: SyntaxNode): string[] | undefined {
|
||||
const argList = node.childForFieldName('arguments');
|
||||
if (argList === null) return undefined;
|
||||
|
||||
const types: string[] = [];
|
||||
for (let i = 0; i < argList.childCount; i++) {
|
||||
const child = argList.child(i);
|
||||
if (child === null) continue;
|
||||
if (child.type === ',' || child.type === '(' || child.type === ')') continue;
|
||||
const litType = inferCppLiteralType(child);
|
||||
if (litType !== '') {
|
||||
types.push(litType);
|
||||
} else if (child.type === 'identifier') {
|
||||
// Variable reference — look up declared type in enclosing scope
|
||||
types.push(lookupDeclaredTypeForIdentifier(child));
|
||||
} else {
|
||||
types.push('');
|
||||
}
|
||||
}
|
||||
return types.length > 0 ? types : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the canonical type name of a C++ literal AST node.
|
||||
* Returns empty string for non-literal / unknown nodes.
|
||||
*/
|
||||
function inferCppLiteralType(node: SyntaxNode): string {
|
||||
switch (node.type) {
|
||||
case 'number_literal': {
|
||||
const text = node.text;
|
||||
// Floating-point literals contain '.', 'e', 'E', or end with 'f'/'F'
|
||||
if (
|
||||
text.includes('.') ||
|
||||
text.includes('e') ||
|
||||
text.includes('E') ||
|
||||
text.endsWith('f') ||
|
||||
text.endsWith('F')
|
||||
) {
|
||||
return 'double';
|
||||
}
|
||||
return 'int';
|
||||
}
|
||||
case 'string_literal':
|
||||
case 'raw_string_literal':
|
||||
case 'concatenated_string':
|
||||
return 'string';
|
||||
case 'char_literal':
|
||||
return 'char';
|
||||
case 'true':
|
||||
case 'false':
|
||||
return 'bool';
|
||||
case 'null':
|
||||
case 'nullptr':
|
||||
return 'null';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the declared type of a variable by scanning sibling declarations
|
||||
* in the enclosing compound_statement (function body). Handles:
|
||||
* - `std::string result = ...` → 'string'
|
||||
* - `int n = ...` → 'int'
|
||||
* - `const int n = ...` → 'int'
|
||||
* Returns empty string if no declaration found or type is auto/placeholder.
|
||||
*/
|
||||
function lookupDeclaredTypeForIdentifier(identNode: SyntaxNode): string {
|
||||
const varName = identNode.text;
|
||||
// Walk up to the enclosing compound_statement (function body)
|
||||
let scope: SyntaxNode | null = identNode.parent;
|
||||
while (
|
||||
scope !== null &&
|
||||
scope.type !== 'compound_statement' &&
|
||||
scope.type !== 'translation_unit'
|
||||
) {
|
||||
scope = scope.parent;
|
||||
}
|
||||
if (scope === null) return '';
|
||||
|
||||
// Scan declarations in the scope for a matching variable name
|
||||
for (let i = 0; i < scope.childCount; i++) {
|
||||
const stmt = scope.child(i);
|
||||
if (stmt === null || stmt.type !== 'declaration') continue;
|
||||
|
||||
const typeNode = stmt.childForFieldName('type');
|
||||
if (typeNode === null) continue;
|
||||
// Skip auto/placeholder types — those need chain-follow, not literal
|
||||
if (typeNode.type === 'placeholder_type_specifier') continue;
|
||||
|
||||
// Check init_declarator children for the variable name
|
||||
const declarator = stmt.childForFieldName('declarator');
|
||||
if (declarator === null) continue;
|
||||
if (declarator.type === 'init_declarator') {
|
||||
const nameChild = declarator.childForFieldName('declarator');
|
||||
if (nameChild !== null && nameChild.text === varName) {
|
||||
return normalizeCppTypeText(typeNode.text);
|
||||
}
|
||||
} else if (declarator.text === varName) {
|
||||
return normalizeCppTypeText(typeNode.text);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Normalize a type-specifier text for argument type matching.
|
||||
* Strips qualifiers (const, volatile), namespace prefixes (std::),
|
||||
* and pointer/reference markers. */
|
||||
function normalizeCppTypeText(text: string): string {
|
||||
let t = text.trim();
|
||||
t = t.replace(/\b(const|volatile|static|extern|mutable)\b/g, '').trim();
|
||||
t = t.replace(/^.*::/, ''); // strip namespace prefix
|
||||
t = t.replace(/[*&]/g, '').trim();
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether a `namespace_definition` AST node is inline.
|
||||
* Tree-sitter-cpp exposes the `inline` keyword as an anonymous child
|
||||
* node — we scan direct children for that keyword.
|
||||
*/
|
||||
function isInlineNamespace(nsNode: SyntaxNode): boolean {
|
||||
for (let i = 0; i < nsNode.childCount; i++) {
|
||||
const c = nsNode.child(i);
|
||||
if (c === null) continue;
|
||||
if (c.type === 'inline') return true;
|
||||
// Some grammar variants surface keywords by their text rather than
|
||||
// by a dedicated node type; check both for resilience.
|
||||
if (c.text === 'inline' && (c.type === 'storage_class_specifier' || c.type === 'inline')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect `(f)(args)` shape — the call-expression's `function` field is a
|
||||
* `parenthesized_expression`. ISO C++ specifies that this form suppresses
|
||||
* ADL (`[basic.lookup.argdep]/3.1`): the parenthesized name is treated as
|
||||
* an ordinary unqualified-lookup-only callee.
|
||||
*/
|
||||
function isParenthesizedFunctionCall(callNode: SyntaxNode): boolean {
|
||||
const fn = callNode.childForFieldName('function');
|
||||
return fn !== null && fn.type === 'parenthesized_expression';
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-argument ADL classification: walk each argument of a free call and
|
||||
* decide whether it resolves to a directly-named class or class-pointer
|
||||
* type (ADL fires) or to an excluded shape such as a reference, function
|
||||
* pointer, primitive, literal, or template specialization.
|
||||
*
|
||||
* Class-typed values and class pointers (`N::S`, `N::S*`, `N::S**`) all
|
||||
* preserve the pointee class name for associated-namespace lookup.
|
||||
* Function pointers remain excluded even when their return type names a
|
||||
* class, because the associated entity is the pointed-to function type,
|
||||
* not the return type.
|
||||
*/
|
||||
function inferCppCallAdlArgs(callNode: SyntaxNode): CppAdlArgInfo[] {
|
||||
const argList = callNode.childForFieldName('arguments');
|
||||
if (argList === null) return [];
|
||||
const out: CppAdlArgInfo[] = [];
|
||||
for (let i = 0; i < argList.childCount; i++) {
|
||||
const child = argList.child(i);
|
||||
if (child === null) continue;
|
||||
if (child.type === ',' || child.type === '(' || child.type === ')') continue;
|
||||
out.push(classifyAdlArg(child));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const EMPTY_ADL_ARG: CppAdlArgInfo = { simpleClassName: '' };
|
||||
|
||||
function classifyAdlArg(argNode: SyntaxNode): CppAdlArgInfo {
|
||||
// Literals and primitive-shaped expressions never have associated namespaces.
|
||||
if (
|
||||
argNode.type === 'number_literal' ||
|
||||
argNode.type === 'string_literal' ||
|
||||
argNode.type === 'raw_string_literal' ||
|
||||
argNode.type === 'char_literal' ||
|
||||
argNode.type === 'true' ||
|
||||
argNode.type === 'false' ||
|
||||
argNode.type === 'null' ||
|
||||
argNode.type === 'nullptr'
|
||||
) {
|
||||
return EMPTY_ADL_ARG;
|
||||
}
|
||||
// Variable reference — look up its declared type (preserving pointer /
|
||||
// reference / qualified-name shape; the existing arity-narrowing helper
|
||||
// strips this info).
|
||||
if (argNode.type === 'identifier') {
|
||||
return lookupAdlIdentifierType(argNode);
|
||||
}
|
||||
// Other shapes (calls, member access, operators) — V1 unsupported.
|
||||
return EMPTY_ADL_ARG;
|
||||
}
|
||||
|
||||
function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo {
|
||||
const varName = identNode.text;
|
||||
let scope: SyntaxNode | null = identNode.parent;
|
||||
while (
|
||||
scope !== null &&
|
||||
scope.type !== 'compound_statement' &&
|
||||
scope.type !== 'translation_unit'
|
||||
) {
|
||||
scope = scope.parent;
|
||||
}
|
||||
if (scope === null) return EMPTY_ADL_ARG;
|
||||
|
||||
for (let i = 0; i < scope.childCount; i++) {
|
||||
const stmt = scope.child(i);
|
||||
if (stmt === null || stmt.type !== 'declaration') continue;
|
||||
const typeNode = stmt.childForFieldName('type');
|
||||
if (typeNode === null) continue;
|
||||
if (typeNode.type === 'placeholder_type_specifier') continue;
|
||||
|
||||
const declarator = stmt.childForFieldName('declarator');
|
||||
if (declarator === null) continue;
|
||||
|
||||
// Unwrap declarator chain to find pointer/reference markers and the
|
||||
// variable name. `init_declarator > pointer_declarator > identifier`
|
||||
// means pointer-typed; repeated pointer wrappers still count as pointer
|
||||
// typed; `init_declarator > reference_declarator > ...` (or
|
||||
// `rvalue_reference_declarator`) means reference-typed; bare
|
||||
// `init_declarator > identifier` is value.
|
||||
// Function-pointer wrappers (`pointer_declarator > function_declarator`)
|
||||
// must not contribute ADL associated namespaces.
|
||||
let isFunctionPointer = false;
|
||||
let inner: SyntaxNode = declarator;
|
||||
let nameText: string | null = null;
|
||||
let safety = 16; // bound walk depth defensively
|
||||
while (safety-- > 0) {
|
||||
if (inner.type === 'pointer_declarator') {
|
||||
if (findFirstDescendantOfType(inner, 'function_declarator') !== null) {
|
||||
isFunctionPointer = true;
|
||||
break;
|
||||
}
|
||||
const next = inner.childForFieldName('declarator');
|
||||
if (next === null) break;
|
||||
inner = next;
|
||||
continue;
|
||||
}
|
||||
if (inner.type === 'reference_declarator' || inner.type === 'rvalue_reference_declarator') {
|
||||
// reference_declarator has a single child (the inner declarator).
|
||||
let next: SyntaxNode | null = null;
|
||||
for (let j = 0; j < inner.namedChildCount; j++) {
|
||||
const c = inner.namedChild(j);
|
||||
if (c !== null) {
|
||||
next = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (next === null) break;
|
||||
inner = next;
|
||||
continue;
|
||||
}
|
||||
if (inner.type === 'init_declarator') {
|
||||
const next = inner.childForFieldName('declarator');
|
||||
if (next === null) break;
|
||||
inner = next;
|
||||
continue;
|
||||
}
|
||||
if (inner.type === 'function_declarator') {
|
||||
isFunctionPointer = true;
|
||||
break;
|
||||
}
|
||||
// Reached the leaf — usually `identifier`. Take its text.
|
||||
nameText = inner.text;
|
||||
break;
|
||||
}
|
||||
if (isFunctionPointer || nameText !== varName) continue;
|
||||
|
||||
const simpleClassName = extractAdlSimpleTypeName(typeNode);
|
||||
return { simpleClassName };
|
||||
}
|
||||
return EMPTY_ADL_ARG;
|
||||
}
|
||||
|
||||
/** Extract the simple class-like type name from a `type:` field node.
|
||||
* Returns '' for primitives, template specializations, and any other
|
||||
* unsupported type-only shape. Function pointers are filtered at the
|
||||
* declarator level in `lookupAdlIdentifierType`. */
|
||||
function extractAdlSimpleTypeName(typeNode: SyntaxNode): string {
|
||||
if (typeNode.type === 'primitive_type') return '';
|
||||
if (typeNode.type === 'sized_type_specifier') return '';
|
||||
if (typeNode.type === 'type_identifier') return typeNode.text;
|
||||
if (typeNode.type === 'qualified_identifier') {
|
||||
const nameNode = typeNode.childForFieldName('name');
|
||||
if (nameNode !== null) return extractAdlSimpleTypeName(nameNode);
|
||||
const id = findFirstDescendantOfType(typeNode, 'type_identifier');
|
||||
return id !== null ? id.text : '';
|
||||
}
|
||||
// template_type (e.g. `vector<int>`), function pointers, decltype — V1 excludes.
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a C++ function_definition or declaration has `static` storage class.
|
||||
*/
|
||||
function hasStaticStorageClass(node: SyntaxNode): boolean {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child !== null && child.type === 'storage_class_specifier' && child.text === 'static') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is inside an anonymous namespace (file-local linkage in C++).
|
||||
* Anonymous namespaces have no `name` field in tree-sitter-cpp.
|
||||
*/
|
||||
function isInsideAnonymousNamespace(node: SyntaxNode): boolean {
|
||||
let ancestor: SyntaxNode | null = node.parent ?? null;
|
||||
while (ancestor !== null) {
|
||||
if (ancestor.type === 'namespace_definition') {
|
||||
// Anonymous namespace: has declaration_list but no name child
|
||||
const nameChild = ancestor.childForFieldName?.('name') ?? null;
|
||||
if (nameChild === null) return true;
|
||||
}
|
||||
ancestor = ancestor.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
214
gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts
Normal file
214
gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
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>>();
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Clear tracked file-local names (call at start of each resolution pass). */
|
||||
export function clearFileLocalNames(): void {
|
||||
fileLocalNames.clear();
|
||||
nonGloballyVisibleNodeIds.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
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`).
|
||||
const ownerScope = ownerScopeByNodeId.get(def.nodeId);
|
||||
if (
|
||||
ownerScope !== undefined &&
|
||||
(ownerScope.kind === 'Namespace' || ownerScope.kind === 'Class')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = simpleName(def);
|
||||
if (name === '') continue;
|
||||
if (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 ?? '';
|
||||
}
|
||||
53
gitnexus/src/core/ingestion/languages/cpp/header-scan.ts
Normal file
53
gitnexus/src/core/ingestion/languages/cpp/header-scan.ts
Normal 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, '/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
120
gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts
Normal file
120
gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
18
gitnexus/src/core/ingestion/languages/cpp/import-target.ts
Normal file
18
gitnexus/src/core/ingestion/languages/cpp/import-target.ts
Normal 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);
|
||||
}
|
||||
16
gitnexus/src/core/ingestion/languages/cpp/index.ts
Normal file
16
gitnexus/src/core/ingestion/languages/cpp/index.ts
Normal 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';
|
||||
170
gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts
Normal file
170
gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts
Normal 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;
|
||||
}
|
||||
110
gitnexus/src/core/ingestion/languages/cpp/interpret.ts
Normal file
110
gitnexus/src/core/ingestion/languages/cpp/interpret.ts
Normal 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;
|
||||
}
|
||||
38
gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts
Normal file
38
gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
512
gitnexus/src/core/ingestion/languages/cpp/query.ts
Normal file
512
gitnexus/src/core/ingestion/languages/cpp/query.ts
Normal 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;
|
||||
}
|
||||
255
gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts
Normal file
255
gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts
Normal 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;
|
||||
}
|
||||
268
gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts
Normal file
268
gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
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,
|
||||
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-namespace ranges (recorded at capture time) to
|
||||
// ScopeIds BEFORE `populateCppNonGloballyVisible` runs, so the
|
||||
// inline-namespace exemption sees the populated Set.
|
||||
populateCppInlineNamespaceScopes(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.
|
||||
// V1 limitation: only direct enclosing-namespace closure for value
|
||||
// class-typed args; pointer/reference/template-spec args 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),
|
||||
};
|
||||
79
gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts
Normal file
79
gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts
Normal 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;
|
||||
}
|
||||
205
gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts
Normal file
205
gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -27,6 +27,17 @@ import { javaMethodConfig } from '../method-extractors/configs/jvm.js';
|
|||
import { createVariableExtractor } from '../variable-extractors/generic.js';
|
||||
import { javaVariableConfig } from '../variable-extractors/configs/jvm.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import {
|
||||
emitJavaScopeCaptures,
|
||||
interpretJavaImport,
|
||||
interpretJavaTypeBinding,
|
||||
javaBindingScopeFor,
|
||||
javaImportOwningScope,
|
||||
javaMergeBindings,
|
||||
javaReceiverBinding,
|
||||
javaArityCompatibility,
|
||||
resolveJavaImportTarget,
|
||||
} from './java/index.js';
|
||||
|
||||
export const javaProvider = defineLanguage({
|
||||
id: SupportedLanguages.Java,
|
||||
|
|
@ -65,4 +76,15 @@ export const javaProvider = defineLanguage({
|
|||
variableExtractor: createVariableExtractor(javaVariableConfig),
|
||||
classExtractor: createClassExtractor(javaClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.Java),
|
||||
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks ──
|
||||
emitScopeCaptures: emitJavaScopeCaptures,
|
||||
interpretImport: interpretJavaImport,
|
||||
interpretTypeBinding: interpretJavaTypeBinding,
|
||||
bindingScopeFor: javaBindingScopeFor,
|
||||
importOwningScope: javaImportOwningScope,
|
||||
mergeBindings: (_scope, bindings) => javaMergeBindings(bindings),
|
||||
receiverBinding: javaReceiverBinding,
|
||||
arityCompatibility: javaArityCompatibility,
|
||||
resolveImportTarget: resolveJavaImportTarget,
|
||||
});
|
||||
|
|
|
|||
49
gitnexus/src/core/ingestion/languages/java/arity-metadata.ts
Normal file
49
gitnexus/src/core/ingestion/languages/java/arity-metadata.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Extract Java arity metadata from a method-like tree-sitter node —
|
||||
* `method_declaration` or `constructor_declaration`.
|
||||
*
|
||||
* Reuses `javaMethodConfig.extractParameters` so scope-extracted defs
|
||||
* carry the same arity semantics as the legacy parse-worker path:
|
||||
* - varargs (`...`) collapses `parameterCount` to `undefined`
|
||||
* - `parameterTypes` collects declared type names; a literal
|
||||
* `'varargs'` marker is appended for variadic methods so
|
||||
* `javaArityCompatibility` can detect them.
|
||||
*/
|
||||
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { javaMethodConfig } from '../../method-extractors/configs/jvm.js';
|
||||
|
||||
export interface JavaArityMetadata {
|
||||
readonly parameterCount: number | undefined;
|
||||
readonly requiredParameterCount: number | undefined;
|
||||
readonly parameterTypes: readonly string[] | undefined;
|
||||
}
|
||||
|
||||
export function computeJavaArityMetadata(fnNode: SyntaxNode): JavaArityMetadata {
|
||||
const params = javaMethodConfig.extractParameters?.(fnNode) ?? [];
|
||||
|
||||
let hasVariadic = false;
|
||||
const types: string[] = [];
|
||||
for (const p of params) {
|
||||
if (p.isVariadic) hasVariadic = true;
|
||||
if (p.type !== null) types.push(p.type);
|
||||
}
|
||||
if (hasVariadic) types.push('varargs');
|
||||
|
||||
const total = params.length;
|
||||
// For varargs methods, `parameterCount` (max) is unknown — any number of
|
||||
// trailing arguments is valid. But the fixed-prefix parameters (everything
|
||||
// before the variadic `...` param) are still required, so we preserve that
|
||||
// count in `requiredParameterCount` so `javaArityCompatibility` can reject
|
||||
// calls that undersupply the fixed prefix (e.g. `f(int x, String... args)`
|
||||
// called with 0 args).
|
||||
const fixedCount = params.filter((p) => !p.isVariadic).length;
|
||||
const parameterCount = hasVariadic ? undefined : total;
|
||||
const requiredParameterCount = hasVariadic ? fixedCount : total;
|
||||
|
||||
return {
|
||||
parameterCount,
|
||||
requiredParameterCount,
|
||||
parameterTypes: types.length > 0 ? types : undefined,
|
||||
};
|
||||
}
|
||||
31
gitnexus/src/core/ingestion/languages/java/arity.ts
Normal file
31
gitnexus/src/core/ingestion/languages/java/arity.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Java arity check, accommodating varargs (`...`).
|
||||
*
|
||||
* Verdicts:
|
||||
* - `'compatible'` — argCount matches parameterCount, OR varargs present.
|
||||
* - `'incompatible'` — argCount mismatches with no varargs.
|
||||
* - `'unknown'` — metadata absent / incomplete.
|
||||
*/
|
||||
|
||||
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
export function javaArityCompatibility(
|
||||
def: SymbolDefinition,
|
||||
callsite: Callsite,
|
||||
): 'compatible' | 'unknown' | 'incompatible' {
|
||||
const max = def.parameterCount;
|
||||
const min = def.requiredParameterCount;
|
||||
if (max === undefined && min === undefined) return 'unknown';
|
||||
|
||||
const argCount = callsite.arity;
|
||||
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
|
||||
|
||||
const hasVarArgs =
|
||||
def.parameterTypes !== undefined &&
|
||||
def.parameterTypes.some((t) => t === 'varargs' || t.includes('...'));
|
||||
|
||||
if (min !== undefined && argCount < min) return 'incompatible';
|
||||
if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible';
|
||||
|
||||
return 'compatible';
|
||||
}
|
||||
30
gitnexus/src/core/ingestion/languages/java/cache-stats.ts
Normal file
30
gitnexus/src/core/ingestion/languages/java/cache-stats.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Dev-mode counters for the cross-phase scope-captures parse cache
|
||||
* (Java mirror of `languages/csharp/cache-stats.ts`).
|
||||
*
|
||||
* Gated by `PROF_SCOPE_RESOLUTION=1`. Production builds fold every
|
||||
* increment into dead code via the module-level `PROF` constant, so
|
||||
* the hot path in `captures.ts` stays branch-free.
|
||||
*/
|
||||
|
||||
const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
|
||||
|
||||
let CACHE_HITS = 0;
|
||||
let CACHE_MISSES = 0;
|
||||
|
||||
export function recordCacheHit(): void {
|
||||
if (PROF) CACHE_HITS++;
|
||||
}
|
||||
|
||||
export function recordCacheMiss(): void {
|
||||
if (PROF) CACHE_MISSES++;
|
||||
}
|
||||
|
||||
export function getJavaCaptureCacheStats(): { hits: number; misses: number } {
|
||||
return { hits: CACHE_HITS, misses: CACHE_MISSES };
|
||||
}
|
||||
|
||||
export function resetJavaCaptureCacheStats(): void {
|
||||
CACHE_HITS = 0;
|
||||
CACHE_MISSES = 0;
|
||||
}
|
||||
235
gitnexus/src/core/ingestion/languages/java/captures.ts
Normal file
235
gitnexus/src/core/ingestion/languages/java/captures.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
/**
|
||||
* `emitScopeCaptures` for Java.
|
||||
*
|
||||
* Drives the Java scope query against tree-sitter-java and groups raw
|
||||
* matches into `CaptureMatch[]` for the central extractor. Layers:
|
||||
*
|
||||
* 1. **Decomposed import declarations** — each `import_declaration`
|
||||
* is re-emitted with `@import.kind/source/name` markers.
|
||||
* 2. **Receiver binding synthesis** — `this`/`super` type-bindings
|
||||
* on instance methods.
|
||||
* 3. **Arity metadata** on method/constructor declarations.
|
||||
* 4. **Reference arity** on call sites.
|
||||
*
|
||||
* Pure given the input source text. No I/O, no globals consulted.
|
||||
*/
|
||||
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
|
||||
import { splitImportDeclaration } from './import-decomposer.js';
|
||||
import { computeJavaArityMetadata } from './arity-metadata.js';
|
||||
import { synthesizeJavaReceiverBinding } from './receiver-binding.js';
|
||||
import { getJavaParser, getJavaScopeQuery } from './query.js';
|
||||
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
|
||||
/** Declaration anchors that carry function-like arity metadata. */
|
||||
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const;
|
||||
|
||||
/** tree-sitter-java node types that the method extractor accepts. */
|
||||
const FUNCTION_NODE_TYPES = ['method_declaration', 'constructor_declaration'] as const;
|
||||
|
||||
/** Suppress read.member emissions when the field_access is already
|
||||
* covered by a method_invocation (object of a call) or an
|
||||
* assignment_expression (write target). */
|
||||
function shouldEmitReadMember(memberNode: SyntaxNode): boolean {
|
||||
const parent = memberNode.parent;
|
||||
if (parent === null) return true;
|
||||
|
||||
switch (parent.type) {
|
||||
case 'method_invocation':
|
||||
// Don't emit read.member when the field_access is the object of a method_invocation
|
||||
// (the method call already handles this relationship)
|
||||
return parent.childForFieldName('object')?.id !== memberNode.id;
|
||||
case 'assignment_expression':
|
||||
return parent.childForFieldName('left')?.id !== memberNode.id;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function emitJavaScopeCaptures(
|
||||
sourceText: string,
|
||||
_filePath: string,
|
||||
cachedTree?: unknown,
|
||||
): readonly CaptureMatch[] {
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getJavaParser>['parse']> | undefined;
|
||||
if (tree === undefined) {
|
||||
tree = parseSourceSafe(getJavaParser(), sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
recordCacheMiss();
|
||||
} else {
|
||||
recordCacheHit();
|
||||
}
|
||||
|
||||
const rawMatches = getJavaScopeQuery().matches(tree.rootNode);
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
for (const m of rawMatches) {
|
||||
const grouped: Record<string, Capture> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
// Decompose each `import_declaration`.
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
const stmtCapture = grouped['@import.statement'];
|
||||
const stmtNode = findNodeAtRange(tree.rootNode, stmtCapture.range, 'import_declaration');
|
||||
if (stmtNode !== null) {
|
||||
const decomposed = splitImportDeclaration(stmtNode);
|
||||
if (decomposed !== null) {
|
||||
out.push(decomposed);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(grouped);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip free-call matches that are actually member calls. The query
|
||||
// matches ALL method_invocations as @reference.call.free (without
|
||||
// negation) because tree-sitter-java's query engine drops !object
|
||||
// patterns when a positive object: pattern exists for the same node
|
||||
// type. Filter here: if the match has @reference.call.free but also
|
||||
// has @reference.receiver, it's a member call — skip the free match
|
||||
// (the separate @reference.call.member match covers it).
|
||||
if (
|
||||
grouped['@reference.call.free'] !== undefined &&
|
||||
grouped['@reference.receiver'] !== undefined
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter read.member when it's a child of method_invocation or assignment.
|
||||
if (grouped['@reference.read.member'] !== undefined) {
|
||||
const anchor = grouped['@reference.read.member'];
|
||||
const memberNode = findNodeAtRange(tree.rootNode, anchor.range, 'field_access');
|
||||
if (memberNode === null || !shouldEmitReadMember(memberNode)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Synthesize `this` / `super` receiver type-bindings on every
|
||||
// instance method-like.
|
||||
if (grouped['@scope.function'] !== undefined) {
|
||||
out.push(grouped);
|
||||
const anchor = grouped['@scope.function']!;
|
||||
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
|
||||
if (fnNode !== null) {
|
||||
for (const synth of synthesizeJavaReceiverBinding(fnNode)) {
|
||||
out.push(synth);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Synthesize arity metadata on function-like declarations.
|
||||
const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined);
|
||||
if (declTag !== undefined) {
|
||||
const anchor = grouped[declTag]!;
|
||||
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
|
||||
if (fnNode !== null) {
|
||||
const arity = computeJavaArityMetadata(fnNode);
|
||||
if (arity.parameterCount !== undefined) {
|
||||
grouped['@declaration.parameter-count'] = syntheticCapture(
|
||||
'@declaration.parameter-count',
|
||||
fnNode,
|
||||
String(arity.parameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.requiredParameterCount !== undefined) {
|
||||
grouped['@declaration.required-parameter-count'] = syntheticCapture(
|
||||
'@declaration.required-parameter-count',
|
||||
fnNode,
|
||||
String(arity.requiredParameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.parameterTypes !== undefined) {
|
||||
grouped['@declaration.parameter-types'] = syntheticCapture(
|
||||
'@declaration.parameter-types',
|
||||
fnNode,
|
||||
JSON.stringify(arity.parameterTypes),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Synthesize `@reference.arity` on every callsite.
|
||||
const callTag = (
|
||||
['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const
|
||||
).find((t) => grouped[t] !== undefined);
|
||||
if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
const anchor = grouped[callTag]!;
|
||||
const callNode =
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'method_invocation') ??
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression');
|
||||
if (callNode !== null) {
|
||||
const argList = callNode.childForFieldName('arguments');
|
||||
const args =
|
||||
argList === null
|
||||
? []
|
||||
: argList.namedChildren.filter((c) => c !== null && c.type !== 'comment');
|
||||
grouped['@reference.arity'] = syntheticCapture(
|
||||
'@reference.arity',
|
||||
callNode,
|
||||
String(args.length),
|
||||
);
|
||||
|
||||
const argTypes = args.map((arg) => inferArgType(arg!));
|
||||
grouped['@reference.parameter-types'] = syntheticCapture(
|
||||
'@reference.parameter-types',
|
||||
callNode,
|
||||
JSON.stringify(argTypes),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
out.push(grouped);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
type SyntaxNode = ReturnType<ReturnType<typeof getJavaParser>['parse']>['rootNode'];
|
||||
|
||||
/** Infer a Java argument's static type from literal patterns. */
|
||||
function inferArgType(argNode: SyntaxNode): string {
|
||||
switch (argNode.type) {
|
||||
case 'decimal_integer_literal':
|
||||
case 'hex_integer_literal':
|
||||
case 'octal_integer_literal':
|
||||
case 'binary_integer_literal':
|
||||
return 'int';
|
||||
case 'decimal_floating_point_literal':
|
||||
case 'hex_floating_point_literal':
|
||||
return 'double';
|
||||
case 'string_literal':
|
||||
return 'String';
|
||||
case 'character_literal':
|
||||
return 'char';
|
||||
case 'true':
|
||||
case 'false':
|
||||
return 'boolean';
|
||||
case 'null_literal':
|
||||
return 'null';
|
||||
case 'object_creation_expression': {
|
||||
const typeNode = argNode.childForFieldName('type');
|
||||
return typeNode?.text ?? '';
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the first Java function-like node at the given range. */
|
||||
function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null {
|
||||
for (const nodeType of FUNCTION_NODE_TYPES) {
|
||||
const n = findNodeAtRange(rootNode, range, nodeType);
|
||||
if (n !== null) return n as SyntaxNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
104
gitnexus/src/core/ingestion/languages/java/import-decomposer.ts
Normal file
104
gitnexus/src/core/ingestion/languages/java/import-decomposer.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Decompose a Java `import_declaration` into a `CaptureMatch` carrying
|
||||
* the synthesized markers `@import.kind` / `@import.source` /
|
||||
* `@import.name` that `interpretJavaImport` consumes.
|
||||
*
|
||||
* Unlike C#'s using-directive decomposer, Java has four import forms:
|
||||
*
|
||||
* import com.example.User; → named
|
||||
* import com.example.*; → wildcard
|
||||
* import static com.example.Utils.format; → static
|
||||
* import static com.example.Utils.*; → static-wildcard
|
||||
*
|
||||
* Each produces exactly one import. The decomposer inspects the raw
|
||||
* source text and tree-sitter children to determine the flavor.
|
||||
*/
|
||||
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
type ImportKind = 'named' | 'wildcard' | 'static' | 'static-wildcard';
|
||||
|
||||
interface ImportSpec {
|
||||
readonly kind: ImportKind;
|
||||
/** Full dotted path: `com.example.User`. */
|
||||
readonly source: string;
|
||||
/** Local binding name — last path segment for named/static,
|
||||
* `'*'` for wildcard/static-wildcard. */
|
||||
readonly name: string;
|
||||
/** Node to anchor the synthesized captures (range-wise). */
|
||||
readonly atNode: SyntaxNode;
|
||||
}
|
||||
|
||||
export function splitImportDeclaration(stmtNode: SyntaxNode): CaptureMatch | null {
|
||||
if (stmtNode.type !== 'import_declaration') return null;
|
||||
const spec = parseImportDeclaration(stmtNode);
|
||||
if (spec === null) return null;
|
||||
return buildImportMatch(stmtNode, spec);
|
||||
}
|
||||
|
||||
function parseImportDeclaration(node: SyntaxNode): ImportSpec | null {
|
||||
// Detect `static` by checking for an anonymous `static` token child.
|
||||
let isStatic = false;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child !== null && child.type === 'static') {
|
||||
isStatic = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect wildcard by checking for `asterisk` named child.
|
||||
let isWildcard = false;
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child !== null && child.type === 'asterisk') {
|
||||
isWildcard = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the scoped_identifier (or identifier for single-segment imports).
|
||||
let pathNode: SyntaxNode | null = null;
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child !== null && (child.type === 'scoped_identifier' || child.type === 'identifier')) {
|
||||
pathNode = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (pathNode === null) return null;
|
||||
|
||||
const fullPath = pathNode.text;
|
||||
if (fullPath === '') return null;
|
||||
|
||||
if (isStatic && isWildcard) {
|
||||
// `import static com.example.Utils.*;`
|
||||
return { kind: 'static-wildcard', source: fullPath, name: '*', atNode: node };
|
||||
}
|
||||
if (isStatic) {
|
||||
// `import static com.example.Utils.format;`
|
||||
const lastDot = fullPath.lastIndexOf('.');
|
||||
const name = lastDot >= 0 ? fullPath.slice(lastDot + 1) : fullPath;
|
||||
return { kind: 'static', source: fullPath, name, atNode: node };
|
||||
}
|
||||
if (isWildcard) {
|
||||
// `import com.example.*;`
|
||||
return { kind: 'wildcard', source: fullPath, name: '*', atNode: node };
|
||||
}
|
||||
|
||||
// `import com.example.User;`
|
||||
const lastDot = fullPath.lastIndexOf('.');
|
||||
const name = lastDot >= 0 ? fullPath.slice(lastDot + 1) : fullPath;
|
||||
return { kind: 'named', source: fullPath, name, atNode: node };
|
||||
}
|
||||
|
||||
function buildImportMatch(stmtNode: SyntaxNode, spec: ImportSpec): CaptureMatch {
|
||||
const m: Record<string, Capture> = {
|
||||
'@import.statement': nodeToCapture('@import.statement', stmtNode),
|
||||
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
|
||||
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
|
||||
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
|
||||
};
|
||||
return m;
|
||||
}
|
||||
108
gitnexus/src/core/ingestion/languages/java/import-target.ts
Normal file
108
gitnexus/src/core/ingestion/languages/java/import-target.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* Adapter from `(ParsedImport, WorkspaceIndex)` → concrete file path.
|
||||
*
|
||||
* Converts Java package paths (dots → slashes) and tries:
|
||||
* 1. Exact file match: `com/example/User.java`
|
||||
* 2. Suffix match for nested layouts
|
||||
* 3. Directory match (wildcard imports)
|
||||
* 4. Progressive prefix stripping for non-standard layouts
|
||||
*
|
||||
* Returns `null` for unresolvable / JDK imports.
|
||||
*/
|
||||
|
||||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
|
||||
export interface JavaResolveContext {
|
||||
readonly fromFile: string;
|
||||
readonly allFilePaths: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export function resolveJavaImportTarget(
|
||||
parsedImport: ParsedImport,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | null {
|
||||
const ctx = workspaceIndex as JavaResolveContext | undefined;
|
||||
if (
|
||||
ctx === undefined ||
|
||||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
|
||||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (parsedImport.kind === 'dynamic-unresolved') return null;
|
||||
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
|
||||
|
||||
// Strip trailing `.*` for wildcard imports: `com.example.*` → `com.example`
|
||||
let target = parsedImport.targetRaw;
|
||||
if (target.endsWith('.*')) {
|
||||
target = target.slice(0, -2);
|
||||
}
|
||||
|
||||
// Package path: `com.example.User` → `com/example/User`
|
||||
const pathLike = target.replace(/\./g, '/');
|
||||
const suffix = `/${pathLike}`;
|
||||
|
||||
let exactFile: string | null = null;
|
||||
let suffixFile: string | null = null;
|
||||
let directoryChild: string | null = null;
|
||||
const dirPrefix = `${pathLike}/`;
|
||||
const suffixDirPrefix = `/${dirPrefix}`;
|
||||
|
||||
for (const raw of ctx.allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (!f.endsWith('.java')) continue;
|
||||
if (f === `${pathLike}.java`) {
|
||||
exactFile = raw;
|
||||
break;
|
||||
}
|
||||
if (suffixFile === null && f.endsWith(`${suffix}.java`)) {
|
||||
suffixFile = raw;
|
||||
}
|
||||
if (directoryChild === null) {
|
||||
const atRoot = f.startsWith(dirPrefix);
|
||||
const atNested = f.includes(suffixDirPrefix);
|
||||
if (atRoot || atNested) {
|
||||
const idx = atRoot ? 0 : f.indexOf(suffixDirPrefix) + 1;
|
||||
const after = f.slice(idx + dirPrefix.length);
|
||||
if (after.length > 0 && !after.includes('/')) {
|
||||
directoryChild = raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (exactFile !== null) return exactFile;
|
||||
if (suffixFile !== null) return suffixFile;
|
||||
if (directoryChild !== null) return directoryChild;
|
||||
|
||||
// Progressive prefix stripping — handles `import com.example.User;`
|
||||
// in a repo laid out `User.java` (no `com/example/` prefix).
|
||||
const segments = pathLike.split('/').filter(Boolean);
|
||||
for (let skip = 1; skip < segments.length; skip++) {
|
||||
const tail = segments.slice(skip).join('/');
|
||||
if (tail === '') continue;
|
||||
const tailFile = `${tail}.java`;
|
||||
const tailSuffix = `/${tailFile}`;
|
||||
const tailDir = `${tail}/`;
|
||||
const tailSuffixDir = `/${tailDir}`;
|
||||
let tailDirectChild: string | null = null;
|
||||
for (const raw of ctx.allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (!f.endsWith('.java')) continue;
|
||||
if (f === tailFile) return raw;
|
||||
if (f.endsWith(tailSuffix)) return raw;
|
||||
if (tailDirectChild === null) {
|
||||
const atRoot = f.startsWith(tailDir);
|
||||
const atNested = f.includes(tailSuffixDir);
|
||||
if (atRoot || atNested) {
|
||||
const idx = atRoot ? 0 : f.indexOf(tailSuffixDir) + 1;
|
||||
const after = f.slice(idx + tailDir.length);
|
||||
if (after.length > 0 && !after.includes('/')) tailDirectChild = raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tailDirectChild !== null) return tailDirectChild;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
30
gitnexus/src/core/ingestion/languages/java/index.ts
Normal file
30
gitnexus/src/core/ingestion/languages/java/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Java scope-resolution hooks (RFC #909 Ring 3).
|
||||
*
|
||||
* Public API barrel. Consumers should import from this file rather than
|
||||
* the individual modules.
|
||||
*
|
||||
* Module layout:
|
||||
*
|
||||
* - `query.ts` — tree-sitter query + lazy parser/query singletons
|
||||
* - `captures.ts` — `emitJavaScopeCaptures` orchestrator
|
||||
* - `import-decomposer.ts` — each `import` → ParsedImport-shaped captures
|
||||
* - `interpret.ts` — capture-match → `ParsedImport` / `ParsedTypeBinding`
|
||||
* - `simple-hooks.ts` — small hooks made explicit
|
||||
* - `receiver-binding.ts` — synthesize `this`/`super` type-bindings on
|
||||
* instance-method entry
|
||||
* - `merge-bindings.ts` — Java import precedence
|
||||
* - `arity.ts` — Java arity compatibility (varargs)
|
||||
* - `arity-metadata.ts` — synthesize arity metadata from declarations
|
||||
* - `import-target.ts` — `(ParsedImport, WorkspaceIndex) → file path` adapter
|
||||
* - `scope-resolver.ts` — `ScopeResolver` registered in `SCOPE_RESOLVERS`
|
||||
* - `cache-stats.ts` — PROF_SCOPE_RESOLUTION cache hit/miss counters
|
||||
*/
|
||||
|
||||
export { emitJavaScopeCaptures } from './captures.js';
|
||||
export { getJavaCaptureCacheStats, resetJavaCaptureCacheStats } from './cache-stats.js';
|
||||
export { interpretJavaImport, interpretJavaTypeBinding } from './interpret.js';
|
||||
export { javaMergeBindings } from './merge-bindings.js';
|
||||
export { javaArityCompatibility } from './arity.js';
|
||||
export { resolveJavaImportTarget, type JavaResolveContext } from './import-target.js';
|
||||
export { javaBindingScopeFor, javaImportOwningScope, javaReceiverBinding } from './simple-hooks.js';
|
||||
141
gitnexus/src/core/ingestion/languages/java/interpret.ts
Normal file
141
gitnexus/src/core/ingestion/languages/java/interpret.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* Capture-match → semantic-shape interpreters for Java.
|
||||
*
|
||||
* - `interpretJavaImport` → `ParsedImport`
|
||||
* - `interpretJavaTypeBinding` → `ParsedTypeBinding`
|
||||
*
|
||||
* Import matches arrive pre-decomposed by `emitJavaScopeCaptures`
|
||||
* (one import per match, with synthesized `@import.kind/source/name`
|
||||
* markers). Type-binding matches arrive from the raw query captures.
|
||||
*/
|
||||
|
||||
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
|
||||
|
||||
// ─── interpretImport ──────────────────────────────────────────────────────
|
||||
|
||||
export function interpretJavaImport(captures: CaptureMatch): ParsedImport | null {
|
||||
const kindCap = captures['@import.kind'];
|
||||
const sourceCap = captures['@import.source'];
|
||||
const nameCap = captures['@import.name'];
|
||||
|
||||
const kind = kindCap?.text;
|
||||
if (kind === undefined || sourceCap === undefined) return null;
|
||||
|
||||
switch (kind) {
|
||||
case 'named': {
|
||||
// `import com.example.User;`
|
||||
return {
|
||||
kind: 'named',
|
||||
localName: nameCap?.text ?? sourceCap.text.split('.').pop() ?? sourceCap.text,
|
||||
importedName: sourceCap.text,
|
||||
targetRaw: sourceCap.text,
|
||||
};
|
||||
}
|
||||
case 'wildcard': {
|
||||
// `import com.example.*;`
|
||||
return {
|
||||
kind: 'wildcard',
|
||||
targetRaw: sourceCap.text + '.*',
|
||||
};
|
||||
}
|
||||
case 'static': {
|
||||
// `import static com.example.Utils.format;`
|
||||
// The source contains the full path including the member name
|
||||
// (e.g. `com.example.Utils.format`). For file resolution we need
|
||||
// the class path (`com.example.Utils`), so strip the final member
|
||||
// segment. The local binding name is the member itself.
|
||||
const fullSource = sourceCap.text;
|
||||
const lastDot = fullSource.lastIndexOf('.');
|
||||
const classPath = lastDot >= 0 ? fullSource.slice(0, lastDot) : fullSource;
|
||||
return {
|
||||
kind: 'named',
|
||||
localName: nameCap?.text ?? (lastDot >= 0 ? fullSource.slice(lastDot + 1) : fullSource),
|
||||
importedName: fullSource,
|
||||
targetRaw: classPath,
|
||||
};
|
||||
}
|
||||
case 'static-wildcard': {
|
||||
// `import static com.example.Utils.*;`
|
||||
// The source is the class path (e.g. `com.example.Utils`).
|
||||
// Resolution should target the class file, not a wildcard directory
|
||||
// scan — `Utils.java` is the file that contains the static members.
|
||||
return {
|
||||
kind: 'wildcard',
|
||||
targetRaw: sourceCap.text + '.*',
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── interpretTypeBinding ─────────────────────────────────────────────────
|
||||
|
||||
export function interpretJavaTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
|
||||
const nameCap = captures['@type-binding.name'];
|
||||
const typeCap = captures['@type-binding.type'];
|
||||
if (nameCap === undefined || typeCap === undefined) return null;
|
||||
|
||||
// Strip qualifier first so that `com.example.BaseModel<T>` becomes
|
||||
// `BaseModel<T>` before stripGeneric — the JVM-erasure fallback pattern
|
||||
// requires an unqualified identifier at the start of the string.
|
||||
const rawType = stripGeneric(stripQualifier(typeCap.text.trim()));
|
||||
|
||||
// Skip `var` — tree-sitter-java parses `var` as type_identifier with
|
||||
// text "var". When used without a constructor initializer, there's no
|
||||
// concrete type to bind.
|
||||
if (rawType === 'var') return null;
|
||||
|
||||
let source: TypeRef['source'] = 'parameter-annotation';
|
||||
if (captures['@type-binding.self'] !== undefined) source = 'self';
|
||||
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
|
||||
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
|
||||
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
|
||||
|
||||
return { boundName: nameCap.text, rawTypeName: rawType, source };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap generic type parameters from Java types.
|
||||
*
|
||||
* Three tiers, checked in order:
|
||||
* 1. Known single-arg collection wrappers → extract the element type
|
||||
* (`List<User>` → `User`, `Optional<User>` → `User`).
|
||||
* 2. Known two-arg map/container types → extract the value type
|
||||
* (`Map<String, User>` → `User`).
|
||||
* 3. **Fallback (JVM type erasure):** any other generic type →
|
||||
* strip the generic parameters and keep the raw class name
|
||||
* (`BaseModel<T>` → `BaseModel`, `CustomList<Foo>` → `CustomList`).
|
||||
* This ensures receiver bindings (`this`/`super`) on classes with
|
||||
* generic superclasses resolve to the correct class file.
|
||||
*/
|
||||
function stripGeneric(text: string): string {
|
||||
// Single-type-argument containers — extract the element type.
|
||||
const single = text.match(
|
||||
/^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:List|ArrayList|LinkedList|Set|HashSet|TreeSet|SortedSet|LinkedHashSet|Collection|Iterable|Iterator|Optional|Stream|CompletableFuture|Future|Queue|Deque|ArrayDeque|PriorityQueue|Vector|Stack|Supplier|Consumer|Predicate|Function)<([^,<>]+)>$/,
|
||||
);
|
||||
if (single !== null) return single[1].trim();
|
||||
|
||||
// Two-type-argument map/container types — extract the value type (second arg).
|
||||
const twoArg = text.match(
|
||||
/^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:Map|HashMap|TreeMap|LinkedHashMap|ConcurrentHashMap|ConcurrentMap|SortedMap|NavigableMap|Hashtable|EnumMap|WeakHashMap|IdentityHashMap|BiFunction|BiConsumer|BiPredicate|Pair|Entry)<[^,<>]+,\s*([^,<>]+)>$/,
|
||||
);
|
||||
if (twoArg !== null) return twoArg[1].trim();
|
||||
|
||||
// Fallback: strip generic parameters from any unrecognized generic type.
|
||||
// `BaseModel<T>` → `BaseModel`, `Builder<Self>` → `Builder`.
|
||||
// This mirrors JVM type erasure — the raw class name is the resolvable symbol.
|
||||
// The pattern matches up to the first `<` to handle nested generics safely
|
||||
// (e.g. `BaseModel<List<String>>` → `BaseModel`).
|
||||
const fallback = text.match(/^([A-Za-z_$][A-Za-z0-9_$]*)<.+>$/s);
|
||||
if (fallback !== null) return fallback[1].trim();
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/** `com.example.User` → `User`. */
|
||||
function stripQualifier(text: string): string {
|
||||
const lastDot = text.lastIndexOf('.');
|
||||
if (lastDot === -1) return text;
|
||||
return text.slice(lastDot + 1);
|
||||
}
|
||||
44
gitnexus/src/core/ingestion/languages/java/merge-bindings.ts
Normal file
44
gitnexus/src/core/ingestion/languages/java/merge-bindings.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Java shadowing precedence for the `mergeBindings` hook.
|
||||
*
|
||||
* Tier ranking (lower wins):
|
||||
* - 0: `local` — class member, method, local variable, parameter
|
||||
* - 1: `import` / `namespace` / `reexport` — explicit imports
|
||||
* - 2: `wildcard` — wildcard imports (`import x.y.*`)
|
||||
*
|
||||
* Within a surviving tier: de-dup by DefId, last-write-wins.
|
||||
*/
|
||||
|
||||
import type { BindingRef } from 'gitnexus-shared';
|
||||
|
||||
const TIER_LOCAL = 0;
|
||||
const TIER_IMPORT = 1;
|
||||
const TIER_WILDCARD = 2;
|
||||
const TIER_UNKNOWN = 3;
|
||||
|
||||
function tierOf(b: BindingRef): number {
|
||||
switch (b.origin) {
|
||||
case 'local':
|
||||
return TIER_LOCAL;
|
||||
case 'reexport':
|
||||
case 'import':
|
||||
case 'namespace':
|
||||
return TIER_IMPORT;
|
||||
case 'wildcard':
|
||||
return TIER_WILDCARD;
|
||||
default:
|
||||
return TIER_UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
export function javaMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
|
||||
if (bindings.length === 0) return bindings;
|
||||
|
||||
let bestTier = Number.POSITIVE_INFINITY;
|
||||
for (const b of bindings) bestTier = Math.min(bestTier, tierOf(b));
|
||||
const survivors = bindings.filter((b) => tierOf(b) === bestTier);
|
||||
|
||||
const seen = new Map<string, BindingRef>();
|
||||
for (const b of survivors) seen.set(b.def.nodeId, b);
|
||||
return [...seen.values()];
|
||||
}
|
||||
197
gitnexus/src/core/ingestion/languages/java/query.ts
Normal file
197
gitnexus/src/core/ingestion/languages/java/query.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
/**
|
||||
* Tree-sitter query for Java scope captures (RFC §5.1).
|
||||
*
|
||||
* Captures the structural skeleton the generic scope-resolution
|
||||
* pipeline consumes: scopes (module/class/function), declarations
|
||||
* (class-likes, method-likes, fields, variables), imports (import
|
||||
* declarations), type bindings (parameter annotations, variable
|
||||
* annotations, constructor inference), and references (call sites,
|
||||
* member writes/reads).
|
||||
*
|
||||
* Java specifics that shape this query:
|
||||
*
|
||||
* - Java uses `program` as the root node (not `compilation_unit`).
|
||||
* - `import_declaration` nodes carry `scoped_identifier` children
|
||||
* and optional `asterisk` for wildcard imports.
|
||||
* - `static` imports are detected by an anonymous `static` token
|
||||
* child within `import_declaration`.
|
||||
* - `var` (Java 10+ local variable type inference) parses as a
|
||||
* `type_identifier` with text `"var"`, not a dedicated node type.
|
||||
* - Modifiers (`public`, `static`, etc.) are grouped under a
|
||||
* `modifiers` named child with anonymous keyword tokens.
|
||||
* - Superclass inheritance uses a `superclass:` field containing
|
||||
* a `superclass` node wrapping a `type_identifier`.
|
||||
*
|
||||
* Exposes lazy `Parser` and `Query` singletons so callers don't pay
|
||||
* tree-sitter init cost per file.
|
||||
*/
|
||||
|
||||
import Parser from 'tree-sitter';
|
||||
import Java from 'tree-sitter-java';
|
||||
|
||||
const JAVA_SCOPE_QUERY = `
|
||||
;; Scopes
|
||||
(program) @scope.module
|
||||
|
||||
(class_declaration) @scope.class
|
||||
(interface_declaration) @scope.class
|
||||
(enum_declaration) @scope.class
|
||||
(record_declaration) @scope.class
|
||||
(annotation_type_declaration) @scope.class
|
||||
|
||||
(method_declaration) @scope.function
|
||||
(constructor_declaration) @scope.function
|
||||
|
||||
;; Declarations — types
|
||||
(class_declaration
|
||||
name: (identifier) @declaration.name) @declaration.class
|
||||
|
||||
(interface_declaration
|
||||
name: (identifier) @declaration.name) @declaration.interface
|
||||
|
||||
(enum_declaration
|
||||
name: (identifier) @declaration.name) @declaration.enum
|
||||
|
||||
(record_declaration
|
||||
name: (identifier) @declaration.name) @declaration.record
|
||||
|
||||
(annotation_type_declaration
|
||||
name: (identifier) @declaration.name) @declaration.class
|
||||
|
||||
;; Declarations — methods / constructors
|
||||
(method_declaration
|
||||
name: (identifier) @declaration.name) @declaration.method
|
||||
|
||||
(constructor_declaration
|
||||
name: (identifier) @declaration.name) @declaration.constructor
|
||||
|
||||
;; Declarations — fields
|
||||
(field_declaration
|
||||
declarator: (variable_declarator
|
||||
name: (identifier) @declaration.name)) @declaration.variable
|
||||
|
||||
;; Declarations — local variables
|
||||
(local_variable_declaration
|
||||
declarator: (variable_declarator
|
||||
name: (identifier) @declaration.name)) @declaration.variable
|
||||
|
||||
;; Imports — single anchor per import_declaration
|
||||
(import_declaration) @import.statement
|
||||
|
||||
;; Type bindings — parameter annotations: void f(User u)
|
||||
(formal_parameter
|
||||
type: (type_identifier) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.parameter
|
||||
|
||||
(formal_parameter
|
||||
type: (generic_type) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.parameter
|
||||
|
||||
(formal_parameter
|
||||
type: (scoped_type_identifier) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.parameter
|
||||
|
||||
;; Type bindings — local variable annotations: User u = new User();
|
||||
(local_variable_declaration
|
||||
type: (type_identifier) @type-binding.type
|
||||
declarator: (variable_declarator
|
||||
name: (identifier) @type-binding.name)) @type-binding.annotation
|
||||
|
||||
(local_variable_declaration
|
||||
type: (generic_type) @type-binding.type
|
||||
declarator: (variable_declarator
|
||||
name: (identifier) @type-binding.name)) @type-binding.annotation
|
||||
|
||||
;; Type bindings — var u = new User(); (Java 10+ local variable type inference)
|
||||
;; tree-sitter-java parses \`var\` as a \`type_identifier\` with text "var".
|
||||
;; The type-binding.constructor anchor fires when the rhs is an
|
||||
;; object_creation_expression so interpretJavaTypeBinding can infer
|
||||
;; the concrete type from the constructor call.
|
||||
(local_variable_declaration
|
||||
type: (type_identifier) @_var_type
|
||||
declarator: (variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (object_creation_expression
|
||||
type: (type_identifier) @type-binding.type))) @type-binding.constructor
|
||||
|
||||
;; Type bindings — field declarations: private User user;
|
||||
(field_declaration
|
||||
type: (type_identifier) @type-binding.type
|
||||
declarator: (variable_declarator
|
||||
name: (identifier) @type-binding.name)) @type-binding.annotation
|
||||
|
||||
(field_declaration
|
||||
type: (generic_type) @type-binding.type
|
||||
declarator: (variable_declarator
|
||||
name: (identifier) @type-binding.name)) @type-binding.annotation
|
||||
|
||||
;; Type bindings — method return type: public User getUser() { }
|
||||
(method_declaration
|
||||
type: (type_identifier) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.return
|
||||
|
||||
(method_declaration
|
||||
type: (generic_type) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.return
|
||||
|
||||
;; Type bindings — enhanced for: for (User u : list)
|
||||
(enhanced_for_statement
|
||||
type: (type_identifier) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.annotation
|
||||
|
||||
(enhanced_for_statement
|
||||
type: (generic_type) @type-binding.type
|
||||
name: (identifier) @type-binding.name) @type-binding.annotation
|
||||
|
||||
;; References — all method calls: foo() and obj.method()
|
||||
;; tree-sitter-java's query engine drops negation-based \`!object\`
|
||||
;; patterns when a positive \`object:\` pattern exists for the same
|
||||
;; node type, so we match all calls here and classify free vs
|
||||
;; member in captures.ts based on the presence of @reference.receiver.
|
||||
(method_invocation
|
||||
object: (_) @reference.receiver
|
||||
name: (identifier) @reference.name) @reference.call.member
|
||||
|
||||
(method_invocation
|
||||
name: (identifier) @reference.name) @reference.call.free
|
||||
|
||||
;; References — constructor calls: new User(...)
|
||||
(object_creation_expression
|
||||
type: (type_identifier) @reference.name) @reference.call.constructor
|
||||
|
||||
(object_creation_expression
|
||||
type: (generic_type
|
||||
(type_identifier) @reference.name)) @reference.call.constructor
|
||||
|
||||
(object_creation_expression
|
||||
type: (scoped_type_identifier) @reference.call.constructor.qualified) @reference.call.constructor
|
||||
|
||||
;; References — field/property writes: obj.name = "x"
|
||||
(assignment_expression
|
||||
left: (field_access
|
||||
object: (_) @reference.receiver
|
||||
field: (identifier) @reference.name)) @reference.write.member
|
||||
|
||||
;; References — field/property reads: obj.name
|
||||
(field_access
|
||||
object: (_) @reference.receiver
|
||||
field: (identifier) @reference.name) @reference.read.member
|
||||
`;
|
||||
|
||||
let _parser: Parser | null = null;
|
||||
let _query: Parser.Query | null = null;
|
||||
|
||||
export function getJavaParser(): Parser {
|
||||
if (_parser === null) {
|
||||
_parser = new Parser();
|
||||
_parser.setLanguage(Java as Parameters<Parser['setLanguage']>[0]);
|
||||
}
|
||||
return _parser;
|
||||
}
|
||||
|
||||
export function getJavaScopeQuery(): Parser.Query {
|
||||
if (_query === null) {
|
||||
_query = new Parser.Query(Java as Parameters<Parser['setLanguage']>[0], JAVA_SCOPE_QUERY);
|
||||
}
|
||||
return _query;
|
||||
}
|
||||
103
gitnexus/src/core/ingestion/languages/java/receiver-binding.ts
Normal file
103
gitnexus/src/core/ingestion/languages/java/receiver-binding.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* Synthesize `@type-binding.self` captures for Java instance methods —
|
||||
* one for `this` (always on non-static methods inside a type
|
||||
* declaration) and optionally one for `super` (only on class methods
|
||||
* when the enclosing class has a `superclass`).
|
||||
*
|
||||
* Mirrors `languages/csharp/receiver-binding.ts` in structure.
|
||||
*/
|
||||
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
const TYPE_DECL_NODE_TYPES = new Set([
|
||||
'class_declaration',
|
||||
'interface_declaration',
|
||||
'enum_declaration',
|
||||
'record_declaration',
|
||||
]);
|
||||
|
||||
const FUNCTION_NODE_TYPES = new Set(['method_declaration', 'constructor_declaration']);
|
||||
|
||||
/** Walk up to the enclosing type declaration. */
|
||||
function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null {
|
||||
let cur: SyntaxNode | null = node.parent;
|
||||
while (cur !== null) {
|
||||
if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur;
|
||||
cur = cur.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function typeName(typeNode: SyntaxNode): string | null {
|
||||
return typeNode.childForFieldName('name')?.text ?? null;
|
||||
}
|
||||
|
||||
/** First superclass text. tree-sitter-java uses a `superclass` field
|
||||
* containing a `superclass` node wrapping a `type_identifier`. */
|
||||
function firstSuperclassText(typeNode: SyntaxNode): string | null {
|
||||
const superclass = typeNode.childForFieldName('superclass');
|
||||
if (superclass === null) return null;
|
||||
// The superclass node wraps the type_identifier
|
||||
for (let i = 0; i < superclass.namedChildCount; i++) {
|
||||
const child = superclass.namedChild(i);
|
||||
if (child !== null && (child.type === 'type_identifier' || child.type === 'generic_type')) {
|
||||
return child.text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Check if a method has the `static` modifier. In tree-sitter-java,
|
||||
* modifiers are grouped under a `modifiers` named child with anonymous
|
||||
* keyword tokens. */
|
||||
function isStaticMethod(fnNode: SyntaxNode): boolean {
|
||||
for (let i = 0; i < fnNode.namedChildCount; i++) {
|
||||
const child = fnNode.namedChild(i);
|
||||
if (child !== null && child.type === 'modifiers') {
|
||||
for (let j = 0; j < child.childCount; j++) {
|
||||
const mod = child.child(j);
|
||||
if (mod !== null && mod.text.trim() === 'static') return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function synthesizeJavaReceiverBinding(fnNode: SyntaxNode): CaptureMatch[] {
|
||||
if (!FUNCTION_NODE_TYPES.has(fnNode.type)) return [];
|
||||
if (isStaticMethod(fnNode)) return [];
|
||||
|
||||
const enclosingType = findEnclosingTypeDeclaration(fnNode);
|
||||
if (enclosingType === null) return [];
|
||||
|
||||
const enclosingName = typeName(enclosingType);
|
||||
if (enclosingName === null) return [];
|
||||
|
||||
// Anchor to the method body so the synthesized captures are inside
|
||||
// the function scope.
|
||||
const anchorNode = fnNode.childForFieldName('body');
|
||||
if (anchorNode === null) return [];
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
out.push(buildReceiverMatch(anchorNode, 'this', enclosingName));
|
||||
|
||||
// `super` applies only to class/record methods with an explicit superclass.
|
||||
if (enclosingType.type === 'class_declaration' || enclosingType.type === 'record_declaration') {
|
||||
const superText = firstSuperclassText(enclosingType);
|
||||
if (superText !== null) {
|
||||
out.push(buildReceiverMatch(anchorNode, 'super', superText));
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildReceiverMatch(anchorNode: SyntaxNode, name: string, typeText: string): CaptureMatch {
|
||||
const m: Record<string, Capture> = {
|
||||
'@type-binding.self': nodeToCapture('@type-binding.self', anchorNode),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText),
|
||||
};
|
||||
return m;
|
||||
}
|
||||
97
gitnexus/src/core/ingestion/languages/java/scope-resolver.ts
Normal file
97
gitnexus/src/core/ingestion/languages/java/scope-resolver.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Java `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
|
||||
* the generic `runScopeResolution` orchestrator (RFC #909 Ring 3).
|
||||
*
|
||||
* ## Registry-primary parity status
|
||||
*
|
||||
* Java is **not** in `MIGRATED_LANGUAGES` — the scope-resolution
|
||||
* registry runs in shadow mode only. Parity in forced registry mode
|
||||
* (`REGISTRY_PRIMARY_JAVA=1`) is 143/172 (83%). The 29 gaps fall into:
|
||||
*
|
||||
* - switch pattern binding / sealed-class exhaustiveness
|
||||
* - Map.values() / entrySet() iteration type propagation
|
||||
* - assignment / method chain return-type propagation across files
|
||||
* - virtual dispatch / interface default methods
|
||||
*
|
||||
* These are the same category of advanced-resolution gaps seen in prior
|
||||
* migrations (Python, C#, Go). Parity is below the ≥99% flip threshold
|
||||
* per RFC §6.4.
|
||||
*
|
||||
* **CI visibility:** Because Java is absent from `MIGRATED_LANGUAGES`,
|
||||
* the parity CI workflow (`ci-scope-parity.yml`) does not run Java in
|
||||
* either `REGISTRY_PRIMARY_JAVA=0` or `=1` mode. Regressions in forced
|
||||
* mode are only visible via manual `REGISTRY_PRIMARY_JAVA=1 npx vitest
|
||||
* run java.test.ts`. Before flipping Java to registry-primary, a
|
||||
* non-required CI step should be added to run Java tests in forced mode
|
||||
* and report parity as a dashboard input.
|
||||
*
|
||||
* **Parity baseline (29 failures):** The 29 gaps in forced registry mode
|
||||
* are tracked in this PR (#1482) and this JSDoc. If the gap count
|
||||
* changes (up or down), update this baseline accordingly.
|
||||
*
|
||||
* ### Known flip-blockers (must fix before adding to MIGRATED_LANGUAGES)
|
||||
*
|
||||
* - Varargs arity: fixed-prefix count is now preserved, but no
|
||||
* integration fixture exercises the 0-arg rejection path yet.
|
||||
* - Static import resolution: `import static X.Y.m` now correctly
|
||||
* resolves to `X/Y.java` (the class), not `X/Y/m.java` (the member).
|
||||
* Edge cases with nested classes may remain.
|
||||
* - Generic superclass receiver binding: `BaseModel<T>` now strips
|
||||
* to `BaseModel` via JVM type-erasure fallback in `stripGeneric`.
|
||||
* - Wildcard import (`import com.example.*`) file selection is
|
||||
* nondeterministic when multiple classes share a package directory.
|
||||
* May produce wrong-file edges in forced mode.
|
||||
* - Qualified generic type parameters in field/parameter annotations
|
||||
* (`com.example.BaseModel<T>`) — rare in practice but may miss
|
||||
* resolution when the full qualifier is present with generics.
|
||||
*/
|
||||
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
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 { javaProvider } from '../java.js';
|
||||
import {
|
||||
javaArityCompatibility,
|
||||
javaMergeBindings,
|
||||
resolveJavaImportTarget,
|
||||
type JavaResolveContext,
|
||||
} from './index.js';
|
||||
|
||||
const javaScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.Java,
|
||||
languageProvider: javaProvider,
|
||||
importEdgeReason: 'java-scope: import',
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
|
||||
const ws: JavaResolveContext = { fromFile, allFilePaths };
|
||||
return resolveJavaImportTarget(
|
||||
{ kind: 'named', localName: '_', importedName: '_', targetRaw },
|
||||
ws,
|
||||
);
|
||||
},
|
||||
|
||||
mergeBindings: (existing, incoming) => [...javaMergeBindings([...existing, ...incoming])],
|
||||
|
||||
arityCompatibility: (callsite, def) => javaArityCompatibility(def, callsite),
|
||||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) =>
|
||||
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
|
||||
|
||||
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
|
||||
|
||||
isSuperReceiver: (text) => text.trim() === 'super',
|
||||
|
||||
// Java is statically typed — field-fallback heuristic stays off
|
||||
fieldFallbackOnMethodLookup: false,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
|
||||
// Java doesn't collapse member calls
|
||||
collapseMemberCallsByCallerTarget: false,
|
||||
|
||||
// Hoist return-type bindings to Module scope for cross-file propagation
|
||||
hoistTypeBindingsToModule: true,
|
||||
};
|
||||
|
||||
export { javaScopeResolver };
|
||||
54
gitnexus/src/core/ingestion/languages/java/simple-hooks.ts
Normal file
54
gitnexus/src/core/ingestion/languages/java/simple-hooks.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Small hooks for the Java provider. Each is a few lines; they make
|
||||
* the provider's choice explicit rather than relying on defaults.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CaptureMatch,
|
||||
ParsedImport,
|
||||
Scope,
|
||||
ScopeId,
|
||||
ScopeTree,
|
||||
TypeRef,
|
||||
} from 'gitnexus-shared';
|
||||
|
||||
// ─── bindingScopeFor ──────────────────────────────────────────────────────
|
||||
|
||||
/** Method return-type bindings hoist to Module scope so cross-file
|
||||
* `propagateImportedReturnTypes` and chain-follow can find them. */
|
||||
export function javaBindingScopeFor(
|
||||
decl: CaptureMatch,
|
||||
innermost: Scope,
|
||||
tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── importOwningScope ────────────────────────────────────────────────────
|
||||
|
||||
/** Java imports are always at compilation-unit (Module) level (JLS §7.5).
|
||||
* Return `null` unconditionally so the default Module scope is used. */
|
||||
export function javaImportOwningScope(
|
||||
_imp: ParsedImport,
|
||||
_innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── receiverBinding ──────────────────────────────────────────────────────
|
||||
|
||||
/** Look up `this` or `super` in the function scope's type bindings. */
|
||||
export function javaReceiverBinding(functionScope: Scope): TypeRef | null {
|
||||
if (functionScope.kind !== 'Function') return null;
|
||||
return functionScope.typeBindings.get('this') ?? functionScope.typeBindings.get('super') ?? null;
|
||||
}
|
||||
|
|
@ -5,12 +5,22 @@
|
|||
* and standard export/import resolution. PHP files can use a variety of
|
||||
* extensions from legacy versions through modern PHP 8.
|
||||
*/
|
||||
import {
|
||||
emitPhpScopeCaptures,
|
||||
interpretPhpImport,
|
||||
interpretPhpTypeBinding,
|
||||
phpArityCompatibility,
|
||||
phpMergeBindings,
|
||||
resolvePhpImportTarget,
|
||||
phpBindingScopeFor,
|
||||
phpImportOwningScope,
|
||||
phpReceiverBinding,
|
||||
} from './php/index.js';
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { phpClassConfig } from '../class-extractors/configs/php.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { defineLanguage, type AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { typeConfig as phpConfig } from '../type-extractors/php.js';
|
||||
import { phpExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
|
|
@ -289,4 +299,18 @@ export const phpProvider = defineLanguage({
|
|||
descriptionExtractor: phpDescriptionExtractor,
|
||||
isRouteFile: isPhpRouteFile,
|
||||
builtInNames: BUILT_INS,
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────────────────
|
||||
emitScopeCaptures: emitPhpScopeCaptures,
|
||||
interpretImport: interpretPhpImport,
|
||||
interpretTypeBinding: interpretPhpTypeBinding,
|
||||
// LanguageProvider uses (def, callsite); phpArityCompatibility uses (def, callsite) — same.
|
||||
arityCompatibility: phpArityCompatibility,
|
||||
// LanguageProvider adapter: (parsedImport, workspaceIndex) → string | null
|
||||
resolveImportTarget: resolvePhpImportTarget,
|
||||
// mergeBindings on LanguageProvider: (scope, bindings) — ignore scope id,
|
||||
// delegate to phpMergeBindings which uses binding origin tiers.
|
||||
mergeBindings: (_scope, bindings) => [...phpMergeBindings(bindings)],
|
||||
bindingScopeFor: phpBindingScopeFor,
|
||||
importOwningScope: phpImportOwningScope,
|
||||
receiverBinding: phpReceiverBinding,
|
||||
});
|
||||
|
|
|
|||
73
gitnexus/src/core/ingestion/languages/php/arity-metadata.ts
Normal file
73
gitnexus/src/core/ingestion/languages/php/arity-metadata.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Extract PHP arity metadata from a method-like tree-sitter node —
|
||||
* `method_declaration` or `function_definition`.
|
||||
*
|
||||
* Reuses `phpMethodConfig.extractParameters` so scope-extracted defs
|
||||
* carry the same arity semantics as the legacy parse-worker path:
|
||||
* - `variadic_parameter` (`...$args`) collapses `parameterCount` to
|
||||
* `undefined`, which `phpArityCompatibility` then treats as
|
||||
* "max unknown" — the candidate stays eligible at `argCount >= required`.
|
||||
* - Defaulted parameters (`= expr`) contribute to `optionalCount`;
|
||||
* `requiredParameterCount = total − optionalCount − (variadic ? 1 : 0)`.
|
||||
* The variadic slot itself accepts zero args so it is subtracted from
|
||||
* the required count — `f(int $a, ...$rest)` requires exactly 1 arg,
|
||||
* not 2, and `f(...$rest)` requires 0.
|
||||
* - `property_promotion_parameter` (constructor-promoted) is counted
|
||||
* the same as `simple_parameter` since both consume an argument slot.
|
||||
* - `parameterTypes` collects declared type names; a literal `'...'`
|
||||
* marker is appended for variadic methods so `phpArityCompatibility`
|
||||
* can detect them without re-reading the AST.
|
||||
*/
|
||||
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { phpMethodConfig } from '../../method-extractors/configs/php.js';
|
||||
|
||||
interface PhpArityMetadata {
|
||||
readonly parameterCount: number | undefined;
|
||||
readonly requiredParameterCount: number | undefined;
|
||||
readonly parameterTypes: readonly string[] | undefined;
|
||||
}
|
||||
|
||||
export function computePhpArityMetadata(fnNode: SyntaxNode): PhpArityMetadata {
|
||||
const params = phpMethodConfig.extractParameters?.(fnNode) ?? [];
|
||||
|
||||
let hasVariadic = false;
|
||||
let optionalCount = 0;
|
||||
const types: string[] = [];
|
||||
|
||||
for (const p of params) {
|
||||
if (p.isVariadic) {
|
||||
hasVariadic = true;
|
||||
} else if (p.isOptional) {
|
||||
optionalCount++;
|
||||
}
|
||||
if (p.type !== null) types.push(p.type);
|
||||
}
|
||||
// PHP variadic marker convention: append the literal '...' string to
|
||||
// `parameterTypes`. This is intentionally DIFFERENT from C#, which uses
|
||||
// the literal 'params' (its source-language keyword). The shared
|
||||
// `narrowOverloadCandidates` pass in `scope-resolution/passes/overload-
|
||||
// narrowing.ts` checks for the C# 'params' marker — that branch is
|
||||
// dead code for PHP because PHP variadic methods set `parameterCount
|
||||
// = undefined` (see line below), which skips the `max !== undefined`
|
||||
// gate that hosts the 'params' check. PHP's actual variadic-aware
|
||||
// arity logic lives in `phpArityCompatibility` (arity.ts) and now
|
||||
// also in `phpEmitUnresolvedReceiverEdges` (scope-resolver.ts), both
|
||||
// of which check `'...'`. Finding 9 of PR #1497 adversarial review.
|
||||
if (hasVariadic) types.push('...');
|
||||
|
||||
const total = params.length;
|
||||
// Variadic methods accept any arg count ≥ required — leave `parameterCount`
|
||||
// undefined so the registry treats max as unknown.
|
||||
const parameterCount = hasVariadic ? undefined : total;
|
||||
// The variadic slot itself accepts zero args; subtract it from the required
|
||||
// count so PHP's ArgumentCountError-equivalent calls (too few args before
|
||||
// the variadic) are correctly rejected by arity compatibility.
|
||||
const requiredParameterCount = total - optionalCount - (hasVariadic ? 1 : 0);
|
||||
|
||||
return {
|
||||
parameterCount,
|
||||
requiredParameterCount,
|
||||
parameterTypes: types.length > 0 ? types : undefined,
|
||||
};
|
||||
}
|
||||
47
gitnexus/src/core/ingestion/languages/php/arity.ts
Normal file
47
gitnexus/src/core/ingestion/languages/php/arity.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* PHP arity check, accommodating variadic (`...$args`) and default parameters.
|
||||
*
|
||||
* The `def` metadata synthesized by `arity-metadata.ts`:
|
||||
* - `parameterCount` — total formal parameters; `undefined` when
|
||||
* the method has a variadic `...$param`.
|
||||
* - `requiredParameterCount` — min required (excludes defaulted params
|
||||
* and the variadic itself).
|
||||
* - `parameterTypes` — declared type strings; contains the
|
||||
* literal `'...'` when the method is variadic.
|
||||
*
|
||||
* Verdicts:
|
||||
* - `'compatible'` — `required <= argCount <= max`, OR the def has
|
||||
* variadic (any `argCount >= required`).
|
||||
* - `'incompatible'` — argCount below required, or above max with no variadic.
|
||||
* - `'unknown'` — metadata absent / incomplete; named-args can satisfy
|
||||
* any arity so we return unknown when we detect them.
|
||||
*
|
||||
* PHP supports named arguments (PHP 8.0+): `save(force: true)`. Named-arg
|
||||
* call sites cannot be arity-checked statically without parsing arg names,
|
||||
* so we return `'unknown'` when the callsite carries named args (signalled
|
||||
* by a negative `arity` value per the shared Callsite contract).
|
||||
*/
|
||||
|
||||
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
export function phpArityCompatibility(
|
||||
def: SymbolDefinition,
|
||||
callsite: Callsite,
|
||||
): 'compatible' | 'unknown' | 'incompatible' {
|
||||
const max = def.parameterCount;
|
||||
const min = def.requiredParameterCount;
|
||||
if (max === undefined && min === undefined) return 'unknown';
|
||||
|
||||
const argCount = callsite.arity;
|
||||
// Negative arity signals named-argument call sites — can't narrow statically.
|
||||
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
|
||||
|
||||
const hasVarArgs =
|
||||
def.parameterTypes !== undefined &&
|
||||
def.parameterTypes.some((t) => t === '...' || t.startsWith('...'));
|
||||
|
||||
if (min !== undefined && argCount < min) return 'incompatible';
|
||||
if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible';
|
||||
|
||||
return 'compatible';
|
||||
}
|
||||
30
gitnexus/src/core/ingestion/languages/php/cache-stats.ts
Normal file
30
gitnexus/src/core/ingestion/languages/php/cache-stats.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Dev-mode counters for the cross-phase scope-captures parse cache
|
||||
* (PHP mirror of `languages/csharp/cache-stats.ts`).
|
||||
*
|
||||
* Gated by `PROF_SCOPE_RESOLUTION=1`. Production builds fold every
|
||||
* increment into dead code via the module-level `PROF` constant, so
|
||||
* the hot path in `captures.ts` stays branch-free.
|
||||
*/
|
||||
|
||||
const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
|
||||
|
||||
let CACHE_HITS = 0;
|
||||
let CACHE_MISSES = 0;
|
||||
|
||||
export function recordCacheHit(): void {
|
||||
if (PROF) CACHE_HITS++;
|
||||
}
|
||||
|
||||
export function recordCacheMiss(): void {
|
||||
if (PROF) CACHE_MISSES++;
|
||||
}
|
||||
|
||||
export function getPhpCaptureCacheStats(): { hits: number; misses: number } {
|
||||
return { hits: CACHE_HITS, misses: CACHE_MISSES };
|
||||
}
|
||||
|
||||
export function resetPhpCaptureCacheStats(): void {
|
||||
CACHE_HITS = 0;
|
||||
CACHE_MISSES = 0;
|
||||
}
|
||||
806
gitnexus/src/core/ingestion/languages/php/captures.ts
Normal file
806
gitnexus/src/core/ingestion/languages/php/captures.ts
Normal file
|
|
@ -0,0 +1,806 @@
|
|||
/**
|
||||
* `emitScopeCaptures` for PHP (RFC #909 Ring 3 LANG-php).
|
||||
*
|
||||
* Drives the PHP scope query against tree-sitter-php and groups raw
|
||||
* matches into `CaptureMatch[]` for the central extractor. Layers two
|
||||
* synthesized streams on top:
|
||||
*
|
||||
* 1. **Decomposed use declarations** — each `namespace_use_declaration`
|
||||
* is re-emitted with `@import.kind/source/name/alias` markers so
|
||||
* `interpretPhpImport` can recover the ParsedImport shape without
|
||||
* re-parsing raw text. Grouped uses fan out to one match per clause.
|
||||
*
|
||||
* 2. **Receiver-binding synthesis** — `$this` and `parent` type-bindings
|
||||
* are synthesized on every non-static method entry. PHP's grammar
|
||||
* does not express "implicit receiver of a non-static class method"
|
||||
* via a clean `.scm` pattern, so we walk up the AST in code.
|
||||
*
|
||||
* 3. **Arity metadata synthesis** — `@declaration.parameter-count` /
|
||||
* `@declaration.required-parameter-count` / `@declaration.parameter-types`
|
||||
* are synthesized on function-like declarations so the registry can
|
||||
* narrow overloads.
|
||||
*
|
||||
* 4. **PHPDoc synthesis** — @param and @return annotations in comment
|
||||
* nodes preceding method/function declarations are extracted and emitted
|
||||
* as `@type-binding.parameter` and `@type-binding.return` matches.
|
||||
*
|
||||
* 5. **Foreach loop synthesis** — `foreach ($users as $user)` emits
|
||||
* a `@type-binding.alias` match binding the loop variable to the
|
||||
* element type of the iterable (resolved from PHPDoc or scopeEnv).
|
||||
*
|
||||
* Pure given the input source text. No I/O, no globals consulted.
|
||||
*/
|
||||
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
|
||||
import { splitNamespaceUseDeclaration } from './import-decomposer.js';
|
||||
import { computePhpArityMetadata } from './arity-metadata.js';
|
||||
import { synthesizePhpReceiverBinding } from './receiver-binding.js';
|
||||
import { getPhpParser, getPhpScopeQuery } from './query.js';
|
||||
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
|
||||
type SyntaxNode = ReturnType<ReturnType<typeof getPhpParser>['parse']>['rootNode'];
|
||||
|
||||
/** Declaration anchors that carry function-like arity metadata. */
|
||||
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.function'] as const;
|
||||
|
||||
/** tree-sitter-php node types that the method extractor accepts. */
|
||||
const FUNCTION_NODE_TYPES = [
|
||||
'method_declaration',
|
||||
'function_definition',
|
||||
'anonymous_function',
|
||||
'arrow_function',
|
||||
] as const;
|
||||
|
||||
export function emitPhpScopeCaptures(
|
||||
sourceText: string,
|
||||
_filePath: string,
|
||||
cachedTree?: unknown,
|
||||
): readonly CaptureMatch[] {
|
||||
// Skip the parse when the caller already produced a Tree for this source.
|
||||
// The cachedTree parameter is typed as `unknown` at the LanguageProvider
|
||||
// contract layer; cast here at the use site.
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getPhpParser>['parse']> | undefined;
|
||||
if (tree === undefined) {
|
||||
tree = parseSourceSafe(getPhpParser(), sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
recordCacheMiss();
|
||||
} else {
|
||||
recordCacheHit();
|
||||
}
|
||||
|
||||
const rawMatches = getPhpScopeQuery().matches(tree.rootNode);
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
// Pre-scan: collect anchor node IDs of property_declaration nodes already
|
||||
// matched by the typed @declaration.property pattern (query.ts ~lines 95–98).
|
||||
// The untyped @declaration.variable catch-all (query.ts ~lines 101–103) is
|
||||
// intentionally loose — it has no `type:` constraint, so tree-sitter also
|
||||
// matches it against typed property declarations and emits a second capture
|
||||
// for the same property_declaration anchor. Graph-level def-id collision
|
||||
// currently masks the duplicate at the node-emit layer, but the catch-all
|
||||
// capture still flows through scope-binding / name-keyed registries with a
|
||||
// `$`-prefixed name that the typed branch's `$`-strip never normalizes —
|
||||
// a known vector for receiver-binding lookup pollution. The two patterns
|
||||
// produce separate rawMatches entries with separate `grouped` maps, so the
|
||||
// dedup has to be cross-match: build the set here, then skip
|
||||
// @declaration.variable matches whose anchor is in it (loop below).
|
||||
const typedPropertyAnchorIds = new Set<number>();
|
||||
for (const m of rawMatches) {
|
||||
for (const c of m.captures) {
|
||||
if (c.name === 'declaration.property') {
|
||||
typedPropertyAnchorIds.add(c.node.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const m of rawMatches) {
|
||||
// Group captures by their tag name. Tree-sitter strips the leading
|
||||
// `@`; we put it back so the central extractor's prefix lookups work.
|
||||
const grouped: Record<string, Capture> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
// Cross-match dedup for the typed-property double-match described above:
|
||||
// skip @declaration.variable matches whose anchor was already captured as
|
||||
// @declaration.property in an earlier match.
|
||||
if (grouped['@declaration.variable'] !== undefined) {
|
||||
const varCap = m.captures.find((c) => c.name === 'declaration.variable');
|
||||
if (varCap !== undefined && typedPropertyAnchorIds.has(varCap.node.id)) continue;
|
||||
}
|
||||
|
||||
// Normalize PHP property declarations: strip leading `$` from
|
||||
// `@declaration.name` for @declaration.property matches. PHP stores
|
||||
// field names WITHOUT the `$` sigil in the graph so that member access
|
||||
// lookups like `$user->address` can find the property named `address`
|
||||
// (not `$address`). `@type-binding.annotation` already strips `$` in
|
||||
// `interpretPhpTypeBinding`; this mirrors that for the declaration side.
|
||||
//
|
||||
// Only applies to `@declaration.property` — typed class properties and
|
||||
// constructor-promoted parameters. Untyped `@declaration.variable` keeps
|
||||
// its `$` prefix (those defs are Variable type and not in the field
|
||||
// registry, so their name doesn't affect member lookup).
|
||||
if (
|
||||
grouped['@declaration.property'] !== undefined &&
|
||||
grouped['@declaration.name'] !== undefined
|
||||
) {
|
||||
const nameCap = grouped['@declaration.name'];
|
||||
if (nameCap.text.startsWith('$')) {
|
||||
grouped['@declaration.name'] = { ...nameCap, text: nameCap.text.slice(1) };
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize PHP receiver expressions so the compound-receiver resolver
|
||||
// can walk chains expressed with `->` (PHP) as if they used `.` (the
|
||||
// resolver's canonical separator). Without this, `$user->address->save()`
|
||||
// has receiver text `$user->address` — the resolver sees no `.` separator,
|
||||
// treats it as a bare identifier, and cannot walk field types.
|
||||
//
|
||||
// Transformation applied to `@reference.receiver` captures:
|
||||
// 1. Replace `->` with `.` ($user->address → $user.address)
|
||||
// 2. Strip leading `$` from each segment ($user.address → user.address)
|
||||
// 3. Strip trailing `?` on null-safe receivers ($user? → user)
|
||||
//
|
||||
// This is a PHP-local normalization — no shared pipeline code is changed.
|
||||
if (grouped['@reference.receiver'] !== undefined) {
|
||||
const recvCap = grouped['@reference.receiver']!;
|
||||
const normalized = normalizePhpReceiver(recvCap.text);
|
||||
if (normalized !== recvCap.text) {
|
||||
grouped['@reference.receiver'] = { ...recvCap, text: normalized };
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize static property write: strip leading `$` from `@reference.name`
|
||||
// so `User::$count` resolves to property `count` (stored without `$` in graph).
|
||||
if (grouped['@reference.write.static'] !== undefined) {
|
||||
const nameCap = grouped['@reference.name'];
|
||||
if (nameCap !== undefined && nameCap.text.startsWith('$')) {
|
||||
grouped['@reference.name'] = {
|
||||
...nameCap,
|
||||
text: nameCap.text.slice(1),
|
||||
};
|
||||
}
|
||||
// Re-tag as @reference.write.member so downstream passes see a uniform write kind.
|
||||
grouped['@reference.write.member'] = grouped['@reference.write.static']!;
|
||||
delete grouped['@reference.write.static'];
|
||||
}
|
||||
|
||||
// Decompose each `namespace_use_declaration` so `interpretPhpImport`
|
||||
// sees the kind/source/name/alias markers it consumes.
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
const stmtCapture = grouped['@import.statement'];
|
||||
const stmtNode = findNodeAtRange(
|
||||
tree.rootNode,
|
||||
stmtCapture.range,
|
||||
'namespace_use_declaration',
|
||||
);
|
||||
if (stmtNode !== null) {
|
||||
const decomposed = splitNamespaceUseDeclaration(stmtNode);
|
||||
if (decomposed.length > 0) {
|
||||
for (const d of decomposed) out.push(d);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Defensive fallback: emit the raw match.
|
||||
out.push(grouped);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Synthesize `$this` / `parent` receiver type-bindings on every
|
||||
// non-static method-like. Mirrors C#'s `this` / `base` synthesis.
|
||||
if (grouped['@scope.function'] !== undefined) {
|
||||
out.push(grouped);
|
||||
const anchor = grouped['@scope.function']!;
|
||||
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
|
||||
if (fnNode !== null) {
|
||||
for (const synth of synthesizePhpReceiverBinding(fnNode)) {
|
||||
out.push(synth);
|
||||
}
|
||||
// Synthesize PHPDoc @param and @return type bindings for this fn.
|
||||
for (const synth of synthesizePhpDocBindings(fnNode)) {
|
||||
out.push(synth);
|
||||
}
|
||||
// Synthesize foreach loop variable bindings inside this fn body.
|
||||
for (const synth of synthesizeForeachBindings(fnNode)) {
|
||||
out.push(synth);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Synthesize arity metadata on function-like declarations so the
|
||||
// registry can narrow overloads.
|
||||
const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined);
|
||||
if (declTag !== undefined) {
|
||||
const anchor = grouped[declTag]!;
|
||||
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
|
||||
if (fnNode !== null) {
|
||||
const arity = computePhpArityMetadata(fnNode);
|
||||
if (arity.parameterCount !== undefined) {
|
||||
grouped['@declaration.parameter-count'] = syntheticCapture(
|
||||
'@declaration.parameter-count',
|
||||
fnNode,
|
||||
String(arity.parameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.requiredParameterCount !== undefined) {
|
||||
grouped['@declaration.required-parameter-count'] = syntheticCapture(
|
||||
'@declaration.required-parameter-count',
|
||||
fnNode,
|
||||
String(arity.requiredParameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.parameterTypes !== undefined) {
|
||||
grouped['@declaration.parameter-types'] = syntheticCapture(
|
||||
'@declaration.parameter-types',
|
||||
fnNode,
|
||||
JSON.stringify(arity.parameterTypes),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Synthesize `@reference.arity` on every call site so the registry's
|
||||
// arity filter can narrow overloads. Count the `argument` children of
|
||||
// the backing `arguments` node. Mirrors C#'s pattern (csharp/captures.ts
|
||||
// lines 149-186). PHP needs this for arity-based dispatch (Cluster H).
|
||||
const callTag = (
|
||||
['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const
|
||||
).find((t) => grouped[t] !== undefined);
|
||||
if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
const anchor = grouped[callTag]!;
|
||||
const callNode =
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'function_call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'member_call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'nullsafe_member_call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'scoped_call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression');
|
||||
if (callNode !== null) {
|
||||
const argList = callNode.childForFieldName('arguments');
|
||||
const args: SyntaxNode[] = [];
|
||||
if (argList !== null) {
|
||||
for (let i = 0; i < argList.namedChildCount; i++) {
|
||||
const child = argList.namedChild(i);
|
||||
if (child !== null && child.type === 'argument') args.push(child);
|
||||
}
|
||||
}
|
||||
grouped['@reference.arity'] = syntheticCapture(
|
||||
'@reference.arity',
|
||||
callNode,
|
||||
String(args.length),
|
||||
);
|
||||
// Infer argument types from literal nodes for type-based narrowing.
|
||||
// Non-literal arguments emit empty string ("unknown" = any-match).
|
||||
const argTypes = args.map((arg) => inferPhpArgType(arg));
|
||||
grouped['@reference.parameter-types'] = syntheticCapture(
|
||||
'@reference.parameter-types',
|
||||
callNode,
|
||||
JSON.stringify(argTypes),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
out.push(grouped);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Find the first PHP function-like node at the given range. */
|
||||
function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null {
|
||||
for (const nodeType of FUNCTION_NODE_TYPES) {
|
||||
const n = findNodeAtRange(rootNode, range, nodeType);
|
||||
if (n !== null) return n as SyntaxNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── PHP receiver normalization ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize a PHP receiver expression so the language-agnostic
|
||||
* compound-receiver resolver (which splits on `.`) can walk field-type chains.
|
||||
*
|
||||
* The compound-receiver resolver:
|
||||
* - splits on `.` to get chain segments
|
||||
* - looks up the first segment in `typeBindings` (keyed with `$` for variables)
|
||||
* - walks subsequent segments as field names (stored without `$` in the graph)
|
||||
*
|
||||
* Transformation:
|
||||
* 1. Replace `->` and `?->` with `.` so the resolver's splitter works
|
||||
* 2. Strip any bare `?` fragment left by null-safe chain ends
|
||||
* 3. Strip `$` from all segments EXCEPT the first (which is a variable
|
||||
* and must keep `$` for typeBindings lookup — e.g. `$user → User`)
|
||||
*
|
||||
* Examples:
|
||||
* `$user` → `$user` (bare variable — unchanged)
|
||||
* `$user->address` → `$user.address`
|
||||
* `$user->address->city` → `$user.address.city`
|
||||
* `$user?` → `$user` (null-safe trailing `?` stripped)
|
||||
* `$this` → `$this` (receiverBinding uses `$this`)
|
||||
* `parent` → `parent` (super-receiver check)
|
||||
*/
|
||||
function normalizePhpReceiver(raw: string): string {
|
||||
// Keep `$this`, `parent`, and `self` as-is.
|
||||
if (raw === '$this' || raw === 'parent' || raw === 'self') return raw;
|
||||
|
||||
// Replace `?->` (null-safe) and plain `->` with `.`.
|
||||
let text = raw.replace(/\?->/g, '.').replace(/->/g, '.');
|
||||
// Strip a trailing `?` (null-safe fragment on the last object node).
|
||||
text = text.replace(/\?$/, '');
|
||||
// Collapse any doubled dots from `?->` where `?` was on its own.
|
||||
text = text.replace(/\.{2,}/g, '.');
|
||||
// Strip trailing dot.
|
||||
text = text.replace(/\.$/, '');
|
||||
|
||||
// Split on `.` and strip `$` from all segments EXCEPT the first.
|
||||
// The first segment is a PHP variable (typeBinding key includes `$`).
|
||||
// Subsequent segments are property/method names (stored without `$`).
|
||||
const segments = text.split('.');
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
const s = segments[i];
|
||||
if (s !== undefined && s.startsWith('$')) segments[i] = s.slice(1);
|
||||
}
|
||||
return segments.join('.');
|
||||
}
|
||||
|
||||
// ─── PHP argument type inference ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Infer the PHP type of a call argument from its literal shape.
|
||||
* Returns an empty string for non-literals (treated as "unknown" = any-match).
|
||||
* Mirrors C#'s `inferArgType` helper.
|
||||
*/
|
||||
function inferPhpArgType(argNode: SyntaxNode): string {
|
||||
// argument node wraps the actual expression
|
||||
const expr = argNode.firstNamedChild ?? argNode;
|
||||
switch (expr.type) {
|
||||
case 'integer':
|
||||
return 'int';
|
||||
case 'float':
|
||||
return 'float';
|
||||
case 'string':
|
||||
case 'encapsed_string':
|
||||
case 'heredoc':
|
||||
case 'nowdoc':
|
||||
return 'string';
|
||||
case 'boolean':
|
||||
case 'true':
|
||||
case 'false':
|
||||
return 'bool';
|
||||
case 'null':
|
||||
return 'null';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PHPDoc synthesis ─────────────────────────────────────────────────────────
|
||||
|
||||
/** PHP 8+ attribute_list nodes that appear between PHPDoc and method. */
|
||||
const SKIP_SIBLING_TYPES = new Set(['attribute_list', 'attribute', 'comment']);
|
||||
|
||||
/** Regex for PHPDoc @param: standard `@param Type $name` */
|
||||
const PHPDOC_PARAM_RE = /@param\s+(\S+)\s+\$(\w+)/g;
|
||||
/** Regex for PHPDoc @param: alternate `@param $name Type` */
|
||||
const PHPDOC_PARAM_ALT_RE = /@param\s+\$(\w+)\s+(\S+)/g;
|
||||
/** Regex for PHPDoc @return: `@return Type` */
|
||||
const PHPDOC_RETURN_RE = /@return\s+(\S+)/;
|
||||
|
||||
/**
|
||||
* Normalize a PHP type string to a simple class name for binding purposes.
|
||||
* Returns null for primitives or uninformative types.
|
||||
* Mirrors `normalizePhpType` in `interpret.ts` but operates on raw PHPDoc strings.
|
||||
*/
|
||||
function normalizePhpDocType(raw: string): string | null {
|
||||
let type = raw.trim();
|
||||
// Strip nullable prefix
|
||||
if (type.startsWith('?')) type = type.slice(1).trim();
|
||||
// Strip array suffix: User[] → User
|
||||
if (type.endsWith('[]')) type = type.slice(0, -2).trim();
|
||||
// Strip union with null/false/void
|
||||
if (type.includes('|')) {
|
||||
const parts = type
|
||||
.split('|')
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed' && p !== '');
|
||||
if (parts.length !== 1) return null;
|
||||
type = parts[0];
|
||||
}
|
||||
// Strip intersection: take first part
|
||||
if (type.includes('&')) {
|
||||
const first = type.split('&')[0].trim();
|
||||
if (first === '') return null;
|
||||
type = first;
|
||||
}
|
||||
// Strip generic wrapper: Collection<User> → User
|
||||
const genericMatch = type.match(/^\w[\w\\]*\s*<([^,<>]+)>$/);
|
||||
if (genericMatch) {
|
||||
type = genericMatch[1].trim();
|
||||
// Strip array suffix again inside generic
|
||||
if (type.endsWith('[]')) type = type.slice(0, -2).trim();
|
||||
}
|
||||
// Strip namespace qualifier: \App\Models\User → User
|
||||
if (type.includes('\\')) {
|
||||
const segs = type.split('\\').filter(Boolean);
|
||||
type = segs[segs.length - 1] ?? type;
|
||||
}
|
||||
// Reject primitives
|
||||
if (PHP_PRIMITIVES.has(type.toLowerCase())) return null;
|
||||
// Must be a simple identifier
|
||||
if (!/^\w+$/.test(type)) return null;
|
||||
return type;
|
||||
}
|
||||
|
||||
const PHP_PRIMITIVES = new Set([
|
||||
'int',
|
||||
'integer',
|
||||
'float',
|
||||
'double',
|
||||
'string',
|
||||
'bool',
|
||||
'boolean',
|
||||
'array',
|
||||
'object',
|
||||
'callable',
|
||||
'iterable',
|
||||
'null',
|
||||
'void',
|
||||
'never',
|
||||
'mixed',
|
||||
'false',
|
||||
'true',
|
||||
'self',
|
||||
'static',
|
||||
'parent',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Collect comment text from siblings immediately before `fnNode`.
|
||||
* Skips PHP 8+ attribute_list nodes.
|
||||
*/
|
||||
function collectPrecedingComments(fnNode: SyntaxNode): string {
|
||||
const texts: string[] = [];
|
||||
let sibling = fnNode.previousSibling;
|
||||
while (sibling !== null) {
|
||||
if (sibling.type === 'comment') {
|
||||
texts.unshift(sibling.text);
|
||||
} else if (sibling.isNamed && !SKIP_SIBLING_TYPES.has(sibling.type)) {
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
return texts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize PHPDoc @param and @return type-binding captures for a
|
||||
* method_declaration or function_definition node.
|
||||
*
|
||||
* PHPDoc @param Type $name → `@type-binding.parameter` match (anchored at fn body/return_type).
|
||||
* PHPDoc @return Type → `@type-binding.return` match (anchored at fn name).
|
||||
*/
|
||||
function synthesizePhpDocBindings(fnNode: SyntaxNode): CaptureMatch[] {
|
||||
if (fnNode.type !== 'method_declaration' && fnNode.type !== 'function_definition') return [];
|
||||
|
||||
const commentBlock = collectPrecedingComments(fnNode);
|
||||
if (commentBlock === '') return [];
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
// Anchor for parameter type-bindings: the function body (or return_type as fallback).
|
||||
// The binding must be inside the function scope so it's visible to body statements.
|
||||
const bodyNode = fnNode.childForFieldName('body');
|
||||
const anchorNode = bodyNode ?? fnNode;
|
||||
|
||||
// ── @param annotations ────────────────────────────────────────────────────
|
||||
PHPDOC_PARAM_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
const seenParams = new Set<string>();
|
||||
|
||||
while ((m = PHPDOC_PARAM_RE.exec(commentBlock)) !== null) {
|
||||
const rawType = m[1];
|
||||
const paramName = '$' + m[2];
|
||||
const typeName = normalizePhpDocType(rawType);
|
||||
if (typeName === null) continue;
|
||||
seenParams.add(paramName);
|
||||
out.push({
|
||||
'@type-binding.parameter': nodeToCapture('@type-binding.parameter', anchorNode),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, paramName),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeName),
|
||||
});
|
||||
}
|
||||
|
||||
// Also check alternate PHPDoc order: @param $name Type
|
||||
PHPDOC_PARAM_ALT_RE.lastIndex = 0;
|
||||
while ((m = PHPDOC_PARAM_ALT_RE.exec(commentBlock)) !== null) {
|
||||
const paramName = '$' + m[1];
|
||||
if (seenParams.has(paramName)) continue; // standard format takes priority
|
||||
const rawType = m[2];
|
||||
const typeName = normalizePhpDocType(rawType);
|
||||
if (typeName === null) continue;
|
||||
out.push({
|
||||
'@type-binding.parameter': nodeToCapture('@type-binding.parameter', anchorNode),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, paramName),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeName),
|
||||
});
|
||||
}
|
||||
|
||||
// ── @return annotation ────────────────────────────────────────────────────
|
||||
const returnMatch = PHPDOC_RETURN_RE.exec(commentBlock);
|
||||
if (returnMatch !== null) {
|
||||
const rawType = returnMatch[1];
|
||||
const typeName = normalizePhpDocType(rawType);
|
||||
if (typeName !== null) {
|
||||
// @return bindings must be anchored at the method name and hoisted to Module scope
|
||||
// by phpBindingScopeFor (which checks for @type-binding.return presence).
|
||||
// Use the function_definition/method_declaration node itself as the anchor — it
|
||||
// coincides with the innermost scope's range, so auto-hoist kicks in.
|
||||
const nameNode = fnNode.childForFieldName('name') ?? fnNode;
|
||||
out.push({
|
||||
'@type-binding.return': nodeToCapture('@type-binding.return', fnNode),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', nameNode, nameNode.text),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', nameNode, typeName),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Foreach synthesis ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walk all `foreach_statement` nodes inside `fnNode` and synthesize
|
||||
* `@type-binding.alias` captures binding the loop variable to the
|
||||
* element type of the iterable.
|
||||
*
|
||||
* Supports:
|
||||
* - `foreach ($users as $user)` — simple iterable variable
|
||||
* - `foreach ($users as $k => $user)` — key→value pair
|
||||
* - `foreach ($this->users as $user)` — member access iterable
|
||||
* - `foreach (getUsers() as $user)` — NOT yet supported (needs return type)
|
||||
*
|
||||
* The element type is resolved by:
|
||||
* 1. Looking up the iterable name in PHPDoc @param bindings already
|
||||
* collected for this function (passed via typeBindingsByName).
|
||||
* 2. Direct resolution when iterable's env type IS the element type
|
||||
* (because PHPDoc normalizes `User[]` → `User` already).
|
||||
*/
|
||||
function synthesizeForeachBindings(fnNode: SyntaxNode): CaptureMatch[] {
|
||||
if (
|
||||
fnNode.type !== 'method_declaration' &&
|
||||
fnNode.type !== 'function_definition' &&
|
||||
fnNode.type !== 'anonymous_function' &&
|
||||
fnNode.type !== 'arrow_function'
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
// Build a mini type map from the function's PHPDoc @param annotations.
|
||||
// This is re-parsed here (not cached from synthesizePhpDocBindings) for simplicity;
|
||||
// the cost is negligible given the small comment sizes.
|
||||
const commentBlock = collectPrecedingComments(fnNode);
|
||||
const paramTypeMap = buildParamTypeMap(commentBlock);
|
||||
|
||||
// Walk the function body for foreach_statement nodes.
|
||||
const bodyNode = fnNode.childForFieldName('body');
|
||||
if (bodyNode === null) return [];
|
||||
collectForeachBindings(bodyNode, fnNode, paramTypeMap, out);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build a map of `$paramName → elementTypeName` from PHPDoc @param in a comment block. */
|
||||
function buildParamTypeMap(commentBlock: string): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
if (commentBlock === '') return map;
|
||||
|
||||
PHPDOC_PARAM_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = PHPDOC_PARAM_RE.exec(commentBlock)) !== null) {
|
||||
const rawType = m[1];
|
||||
const paramName = '$' + m[2];
|
||||
const typeName = normalizePhpDocType(rawType);
|
||||
if (typeName !== null) map.set(paramName, typeName);
|
||||
}
|
||||
PHPDOC_PARAM_ALT_RE.lastIndex = 0;
|
||||
while ((m = PHPDOC_PARAM_ALT_RE.exec(commentBlock)) !== null) {
|
||||
const paramName = '$' + m[1];
|
||||
if (map.has(paramName)) continue;
|
||||
const rawType = m[2];
|
||||
const typeName = normalizePhpDocType(rawType);
|
||||
if (typeName !== null) map.set(paramName, typeName);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a subtree and collect foreach_statement bindings.
|
||||
* Recursively descends into all child nodes.
|
||||
*/
|
||||
function collectForeachBindings(
|
||||
node: SyntaxNode,
|
||||
fnNode: SyntaxNode,
|
||||
paramTypeMap: Map<string, string>,
|
||||
out: CaptureMatch[],
|
||||
): void {
|
||||
if (node.type === 'foreach_statement') {
|
||||
const synth = synthesizeSingleForeach(node, fnNode, paramTypeMap);
|
||||
if (synth !== null) out.push(synth);
|
||||
}
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child !== null) {
|
||||
collectForeachBindings(child, fnNode, paramTypeMap, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize a single `@type-binding.alias` match for a `foreach_statement`.
|
||||
*
|
||||
* AST structure for foreach_statement (tree-sitter-php):
|
||||
* foreach ( <iterable> as <value_or_pair> ) <body>
|
||||
* Named children (excluding body): first = iterable, second = value or pair.
|
||||
*/
|
||||
function synthesizeSingleForeach(
|
||||
foreachNode: SyntaxNode,
|
||||
fnNode: SyntaxNode,
|
||||
paramTypeMap: Map<string, string>,
|
||||
): CaptureMatch | null {
|
||||
// Collect non-body named children: [iterable, value_or_pair]
|
||||
const bodyNode = foreachNode.childForFieldName('body');
|
||||
const children: SyntaxNode[] = [];
|
||||
for (let i = 0; i < foreachNode.namedChildCount; i++) {
|
||||
const child = foreachNode.namedChild(i);
|
||||
if (child !== null && child !== bodyNode) children.push(child);
|
||||
}
|
||||
if (children.length < 2) return null;
|
||||
|
||||
const iterableNode = children[0];
|
||||
const valueOrPair = children[1];
|
||||
|
||||
// Determine the loop variable node
|
||||
let loopVarNode: SyntaxNode;
|
||||
if (valueOrPair.type === 'pair') {
|
||||
// $key => $value — use the last named child of the pair
|
||||
const lastChild = valueOrPair.namedChild(valueOrPair.namedChildCount - 1);
|
||||
if (lastChild === null) return null;
|
||||
loopVarNode =
|
||||
lastChild.type === 'by_ref' ? (lastChild.firstNamedChild ?? lastChild) : lastChild;
|
||||
} else {
|
||||
loopVarNode =
|
||||
valueOrPair.type === 'by_ref' ? (valueOrPair.firstNamedChild ?? valueOrPair) : valueOrPair;
|
||||
}
|
||||
|
||||
// Loop variable must be a variable_name
|
||||
if (loopVarNode.type !== 'variable_name') return null;
|
||||
const loopVarName = loopVarNode.text; // e.g. '$user'
|
||||
|
||||
// Resolve the element type from the iterable
|
||||
let elementType: string | null = null;
|
||||
|
||||
if (iterableNode.type === 'variable_name') {
|
||||
// foreach ($users as $user) — look up $users in param map
|
||||
const iterableName = iterableNode.text; // e.g. '$users'
|
||||
elementType = paramTypeMap.get(iterableName) ?? null;
|
||||
} else if (iterableNode.type === 'member_access_expression') {
|
||||
// foreach ($this->users as $user) — property name is the field
|
||||
const propNameNode = iterableNode.childForFieldName('name');
|
||||
if (propNameNode !== null) {
|
||||
// Property stored with $ prefix in paramTypeMap (rare for $this->prop patterns)
|
||||
// Try both with and without $ prefix
|
||||
const propKey = '$' + propNameNode.text;
|
||||
elementType = paramTypeMap.get(propKey) ?? null;
|
||||
if (elementType === null) {
|
||||
// Try to find the property type from the enclosing class
|
||||
elementType = findClassPropertyElementType(iterableNode, fnNode);
|
||||
}
|
||||
}
|
||||
} else if (iterableNode.type === 'function_call_expression') {
|
||||
// foreach (getUsers() as $user) — use the function name as a type alias.
|
||||
// The function's @return annotation produces a @type-binding.return binding
|
||||
// in the Module scope (e.g. getUsers → User). The scope-extractor's
|
||||
// followChainedRef will resolve $user → getUsers → User.
|
||||
const funcNode = iterableNode.childForFieldName('function');
|
||||
if (funcNode !== null && funcNode.type === 'name') {
|
||||
elementType = funcNode.text; // e.g. 'getUsers' — chain will be resolved later
|
||||
}
|
||||
} else if (iterableNode.type === 'member_call_expression') {
|
||||
// foreach ($this->getUsers() as $user) — use the method name as a type alias.
|
||||
const methodNameNode = iterableNode.childForFieldName('name');
|
||||
if (methodNameNode !== null) {
|
||||
elementType = methodNameNode.text; // e.g. 'getUsers'
|
||||
}
|
||||
}
|
||||
|
||||
if (elementType === null) return null;
|
||||
|
||||
// Anchor the binding inside the foreach body so it's scoped to the loop.
|
||||
const anchorNode = bodyNode ?? foreachNode;
|
||||
|
||||
return {
|
||||
'@type-binding.alias': nodeToCapture('@type-binding.alias', anchorNode),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, loopVarName),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, elementType),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find the element type for `$this->property` member access by walking
|
||||
* up from the foreach to the enclosing class and scanning the property declaration.
|
||||
*/
|
||||
function findClassPropertyElementType(
|
||||
memberAccessNode: SyntaxNode,
|
||||
fnNode: SyntaxNode,
|
||||
): string | null {
|
||||
const propNameNode = memberAccessNode.childForFieldName('name');
|
||||
if (propNameNode === null) return null;
|
||||
const propName = propNameNode.text;
|
||||
|
||||
// Walk up from fnNode to find the enclosing class declaration
|
||||
let cur: SyntaxNode | null = fnNode.parent;
|
||||
while (cur !== null) {
|
||||
if (cur.type === 'class_declaration' || cur.type === 'trait_declaration') {
|
||||
break;
|
||||
}
|
||||
cur = cur.parent;
|
||||
}
|
||||
if (cur === null) return null;
|
||||
|
||||
// Find the property_declaration with matching variable_name '$propName'
|
||||
const declList = cur.childForFieldName('body');
|
||||
if (declList === null) return null;
|
||||
|
||||
for (let i = 0; i < declList.namedChildCount; i++) {
|
||||
const child = declList.namedChild(i);
|
||||
if (child === null || child.type !== 'property_declaration') continue;
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const elem = child.namedChild(j);
|
||||
if (elem === null || elem.type !== 'property_element') continue;
|
||||
const varNameNode = elem.firstNamedChild;
|
||||
if (varNameNode === null || varNameNode.text !== '$' + propName) continue;
|
||||
// Found the property — get its element type from @var PHPDoc or native type
|
||||
return extractPropertyElementType(child);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Regex for PHPDoc @var: `@var Type` */
|
||||
const PHPDOC_VAR_RE = /@var\s+(\S+)/;
|
||||
|
||||
/**
|
||||
* Extract element type from a property_declaration node:
|
||||
* 1. PHPDoc @var annotation on a preceding comment sibling
|
||||
* 2. PHP 7.4+ native type field (non-array)
|
||||
*/
|
||||
function extractPropertyElementType(propDecl: SyntaxNode): string | null {
|
||||
// Strategy 1: PHPDoc @var on a preceding comment sibling
|
||||
let sibling = propDecl.previousSibling;
|
||||
while (sibling !== null) {
|
||||
if (sibling.type === 'comment') {
|
||||
const m = PHPDOC_VAR_RE.exec(sibling.text);
|
||||
if (m !== null) return normalizePhpDocType(m[1]);
|
||||
} else if (sibling.isNamed && !SKIP_SIBLING_TYPES.has(sibling.type)) {
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
// Strategy 2: native type field — skip generic 'array'
|
||||
const typeNode = propDecl.childForFieldName('type');
|
||||
if (typeNode === null) return null;
|
||||
const typeName = typeNode.text.trim();
|
||||
if (typeName === 'array' || typeName === '') return null;
|
||||
return normalizePhpDocType(typeName);
|
||||
}
|
||||
304
gitnexus/src/core/ingestion/languages/php/import-decomposer.ts
Normal file
304
gitnexus/src/core/ingestion/languages/php/import-decomposer.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
/**
|
||||
* Decompose a PHP `namespace_use_declaration` into one or more
|
||||
* `CaptureMatch` objects carrying the synthesized markers
|
||||
* `@import.kind` / `@import.source` / `@import.name` / `@import.alias`
|
||||
* that `interpretPhpImport` consumes.
|
||||
*
|
||||
* PHP import forms handled:
|
||||
*
|
||||
* use Foo\Bar; → namespace, localName=Bar
|
||||
* use Foo\Bar as Baz; → alias, localName=Baz
|
||||
* use function Foo\bar; → function, localName=bar
|
||||
* use const Foo\BAR; → const, localName=BAR
|
||||
* use Foo\{A, B as C}; → grouped: one match per clause
|
||||
* use function Foo\{f, g as h}; → grouped function variants
|
||||
* use const Foo\{X, Y as Z}; → grouped const variants
|
||||
*
|
||||
* Unlike C#'s decomposer this is 1:N — each grouped use_declaration
|
||||
* fans out to one CaptureMatch per inner clause.
|
||||
*/
|
||||
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
export type PhpImportKind = 'namespace' | 'alias' | 'function' | 'const';
|
||||
|
||||
interface PhpImportSpec {
|
||||
readonly kind: PhpImportKind;
|
||||
/** Full backslash-separated path (backslashes intact): `Foo\Bar\Baz`. */
|
||||
readonly source: string;
|
||||
/** Local binding name — last source segment for plain imports, the
|
||||
* alias identifier for aliased imports. */
|
||||
readonly name: string;
|
||||
/** Present iff kind === 'alias'. */
|
||||
readonly alias?: string;
|
||||
/** Anchor node for synthesized captures (range-wise). */
|
||||
readonly atNode: SyntaxNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompose a `namespace_use_declaration` node into one `CaptureMatch`
|
||||
* per logical import. Returns `[]` when the node is unrecognized or
|
||||
* carries no resolvable clauses.
|
||||
*/
|
||||
export function splitNamespaceUseDeclaration(stmtNode: SyntaxNode): CaptureMatch[] {
|
||||
if (stmtNode.type !== 'namespace_use_declaration') return [];
|
||||
|
||||
// Detect qualifier keyword: `use function` / `use const`
|
||||
// tree-sitter-php uses a `use_type` or `function`/`const` keyword
|
||||
// child to distinguish them. We scan the raw text before the first
|
||||
// backslash-path child.
|
||||
const qualifier = detectQualifier(stmtNode);
|
||||
|
||||
// Grouped use: `use Foo\{A, B as C}` — find namespace_use_group child.
|
||||
const groupNode = findNamedChild(stmtNode, 'namespace_use_group');
|
||||
if (groupNode !== null) {
|
||||
return decomposeGrouped(stmtNode, groupNode, qualifier);
|
||||
}
|
||||
|
||||
// Single use clause (possibly aliased).
|
||||
const spec = parseSingleUseClause(stmtNode, qualifier);
|
||||
if (spec === null) return [];
|
||||
return [buildImportMatch(stmtNode, spec)];
|
||||
}
|
||||
|
||||
// ── Qualifier detection ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return the qualifier keyword appearing after `use`:
|
||||
* `'function'`, `'const'`, or `null` for plain namespace use.
|
||||
*
|
||||
* tree-sitter-php emits the qualifier as a `name` node with text
|
||||
* "function" or "const" (not a keyword token in recent grammars),
|
||||
* or as a dedicated `use_type` node. We inspect the node's raw text
|
||||
* to be grammar-version-agnostic.
|
||||
*/
|
||||
function detectQualifier(node: SyntaxNode): PhpImportKind {
|
||||
const raw = node.text;
|
||||
// Match `use function` or `use const` at the start (after optional whitespace)
|
||||
if (/^\s*use\s+function\s/i.test(raw)) return 'function';
|
||||
if (/^\s*use\s+const\s/i.test(raw)) return 'const';
|
||||
return 'namespace';
|
||||
}
|
||||
|
||||
// ── Single clause parsing ──────────────────────────────────────────────────
|
||||
|
||||
function parseSingleUseClause(node: SyntaxNode, qualifier: PhpImportKind): PhpImportSpec | null {
|
||||
// A plain `namespace_use_declaration` has one or more
|
||||
// `namespace_use_clause` named children (each clause is one import,
|
||||
// comma-separated for multiple). For the single case there is one.
|
||||
const clause = findNamedChild(node, 'namespace_use_clause');
|
||||
if (clause !== null) return parseUseClause(clause, qualifier);
|
||||
|
||||
// Older grammar versions may put the qualified_name directly under
|
||||
// the declaration node. Check for a qualified_name or name child.
|
||||
const qualName = findNamedChild(node, 'qualified_name') ?? findNamedChild(node, 'name');
|
||||
if (qualName === null) return null;
|
||||
const source = qualName.text.trim();
|
||||
if (source === '') return null;
|
||||
return {
|
||||
kind: qualifier,
|
||||
source,
|
||||
name: lastSegment(source),
|
||||
atNode: node,
|
||||
};
|
||||
}
|
||||
|
||||
function parseUseClause(clause: SyntaxNode, qualifier: PhpImportKind): PhpImportSpec | null {
|
||||
// namespace_use_clause:
|
||||
// qualified_name (or name)
|
||||
// optional: alias_clause → "as" name (some grammar versions)
|
||||
// optional: bare name node (tree-sitter-php ≥ 0.22 emits the
|
||||
// alias as a sibling `name` node
|
||||
// directly, not inside alias_clause)
|
||||
const qualName = findNamedChild(clause, 'qualified_name') ?? findNamedChild(clause, 'name');
|
||||
if (qualName === null) return null;
|
||||
const source = qualName.text.trim();
|
||||
if (source === '') return null;
|
||||
|
||||
// Strategy 1: explicit alias_clause wrapper (older grammar versions).
|
||||
const aliasClause = findNamedChild(clause, 'alias_clause');
|
||||
if (aliasClause !== null) {
|
||||
// alias_clause: "as" name
|
||||
const aliasName = findNamedChild(aliasClause, 'name') ?? aliasClause.firstNamedChild;
|
||||
const alias = aliasName?.text.trim() ?? '';
|
||||
if (alias === '') return null;
|
||||
return {
|
||||
kind: 'alias',
|
||||
source,
|
||||
name: alias,
|
||||
alias,
|
||||
atNode: clause,
|
||||
};
|
||||
}
|
||||
|
||||
// Strategy 2: bare sibling `name` node after the qualified_name.
|
||||
// tree-sitter-php (≥ 0.22) emits `use Foo\Bar as Baz` as:
|
||||
// namespace_use_clause
|
||||
// qualified_name "Foo\Bar"
|
||||
// name "Baz" ← alias, no alias_clause wrapper
|
||||
// Detect by: clause has ≥2 named children AND the last named child is
|
||||
// a `name` node that differs from the qualName node.
|
||||
if (clause.namedChildCount >= 2) {
|
||||
const lastChild = clause.namedChild(clause.namedChildCount - 1);
|
||||
if (lastChild !== null && lastChild !== qualName && lastChild.type === 'name') {
|
||||
const alias = lastChild.text.trim();
|
||||
if (alias !== '') {
|
||||
return {
|
||||
kind: 'alias',
|
||||
source,
|
||||
name: alias,
|
||||
alias,
|
||||
atNode: clause,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: qualifier,
|
||||
source,
|
||||
name: lastSegment(source),
|
||||
atNode: clause,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Grouped use decomposition ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Decompose `use Foo\Bar\{A, B as C, function f, const X}` into one
|
||||
* `CaptureMatch` per inner clause.
|
||||
*
|
||||
* The leading prefix (`Foo\Bar`) is prepended to each inner path.
|
||||
* Inner clauses can override the qualifier with their own `function` /
|
||||
* `const` keyword inside the group.
|
||||
*/
|
||||
function decomposeGrouped(
|
||||
stmtNode: SyntaxNode,
|
||||
groupNode: SyntaxNode,
|
||||
outerQualifier: PhpImportKind,
|
||||
): CaptureMatch[] {
|
||||
// The prefix is the qualified_name that precedes the `{...}` group.
|
||||
const prefixNode = findNamedChild(stmtNode, 'qualified_name') ?? findNamedChild(stmtNode, 'name');
|
||||
const prefix = prefixNode?.text.trim() ?? '';
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
for (let i = 0; i < groupNode.namedChildCount; i++) {
|
||||
const child = groupNode.namedChild(i);
|
||||
if (child === null) continue;
|
||||
|
||||
// Each child in a group may be:
|
||||
// namespace_use_clause — plain or aliased
|
||||
// namespace_use_type — `function` or `const` qualifier inside group
|
||||
// We detect an inline qualifier by checking the raw text of the clause.
|
||||
if (child.type !== 'namespace_use_clause') continue;
|
||||
|
||||
const innerQualifier = detectInnerQualifier(child) ?? outerQualifier;
|
||||
const spec = parseInnerClause(child, prefix, innerQualifier);
|
||||
if (spec !== null) {
|
||||
out.push(buildImportMatch(stmtNode, spec));
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect an inline qualifier keyword inside a grouped clause.
|
||||
* e.g. `use Foo\{function bar, const BAZ}` — each clause may start with
|
||||
* `function` or `const`.
|
||||
*/
|
||||
function detectInnerQualifier(clause: SyntaxNode): PhpImportKind | null {
|
||||
const raw = clause.text.trim();
|
||||
if (/^function\s/i.test(raw)) return 'function';
|
||||
if (/^const\s/i.test(raw)) return 'const';
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseInnerClause(
|
||||
clause: SyntaxNode,
|
||||
prefix: string,
|
||||
qualifier: PhpImportKind,
|
||||
): PhpImportSpec | null {
|
||||
const qualName = findNamedChild(clause, 'qualified_name') ?? findNamedChild(clause, 'name');
|
||||
if (qualName === null) return null;
|
||||
|
||||
// Strip inline `function` / `const` text prefix if present in the text.
|
||||
let innerPath = qualName.text.trim();
|
||||
innerPath = innerPath.replace(/^(?:function|const)\s+/i, '').trim();
|
||||
if (innerPath === '') return null;
|
||||
|
||||
const source = prefix !== '' ? `${prefix}\\${innerPath}` : innerPath;
|
||||
|
||||
// Strategy 1: explicit alias_clause wrapper (older grammar versions).
|
||||
const aliasClause = findNamedChild(clause, 'alias_clause');
|
||||
if (aliasClause !== null) {
|
||||
const aliasName = findNamedChild(aliasClause, 'name') ?? aliasClause.firstNamedChild;
|
||||
const alias = aliasName?.text.trim() ?? '';
|
||||
if (alias === '') return null;
|
||||
return {
|
||||
kind: 'alias',
|
||||
source,
|
||||
name: alias,
|
||||
alias,
|
||||
atNode: clause,
|
||||
};
|
||||
}
|
||||
|
||||
// Strategy 2: bare sibling `name` node after the qualified_name (tree-sitter-php ≥ 0.22).
|
||||
if (clause.namedChildCount >= 2) {
|
||||
const lastChild = clause.namedChild(clause.namedChildCount - 1);
|
||||
if (lastChild !== null && lastChild !== qualName && lastChild.type === 'name') {
|
||||
const alias = lastChild.text.trim();
|
||||
if (alias !== '') {
|
||||
return {
|
||||
kind: 'alias',
|
||||
source,
|
||||
name: alias,
|
||||
alias,
|
||||
atNode: clause,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: qualifier,
|
||||
source,
|
||||
name: lastSegment(innerPath),
|
||||
atNode: clause,
|
||||
};
|
||||
}
|
||||
|
||||
// ── CaptureMatch builder ───────────────────────────────────────────────────
|
||||
|
||||
function buildImportMatch(stmtNode: SyntaxNode, spec: PhpImportSpec): CaptureMatch {
|
||||
const m: Record<string, Capture> = {
|
||||
'@import.statement': nodeToCapture('@import.statement', stmtNode),
|
||||
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
|
||||
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
|
||||
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
|
||||
};
|
||||
if (spec.alias !== undefined) {
|
||||
m['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Last backslash-separated segment: `Foo\Bar\Baz` → `Baz`. */
|
||||
function lastSegment(path: string): string {
|
||||
const parts = path.split('\\').filter(Boolean);
|
||||
return parts[parts.length - 1] ?? path;
|
||||
}
|
||||
|
||||
/** Find the first named child with a given node type. */
|
||||
function findNamedChild(node: SyntaxNode, type: string): SyntaxNode | null {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child !== null && child.type === type) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
140
gitnexus/src/core/ingestion/languages/php/import-target.ts
Normal file
140
gitnexus/src/core/ingestion/languages/php/import-target.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Adapter from `(ParsedImport, WorkspaceIndex)` → concrete file path.
|
||||
*
|
||||
* Delegates to the existing `resolvePhpImportInternal` (PSR-4 via
|
||||
* composer.json + suffix matching fallback). The `WorkspaceIndex` is
|
||||
* opaque at this layer; consumers wire a `PhpResolveContext` shape
|
||||
* carrying `fromFile` + `allFilePaths`.
|
||||
*
|
||||
* `loadPhpComposerConfig` is the `ScopeResolver.loadResolutionConfig`
|
||||
* implementation — it loads `composer.json` once per workspace pass and
|
||||
* threads the parsed config into every subsequent `resolveImportTarget`
|
||||
* call via the opaque `resolutionConfig` parameter.
|
||||
*
|
||||
* Returning `null` lets the finalize algorithm mark the edge as
|
||||
* `linkStatus: 'unresolved'`.
|
||||
*/
|
||||
|
||||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
import { resolvePhpImportInternal } from '../../import-resolvers/php.js';
|
||||
import type { ComposerConfig } from '../../language-config.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface PhpResolveContext {
|
||||
readonly fromFile: string;
|
||||
readonly allFilePaths: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
// ─── loadResolutionConfig ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load and parse `composer.json` from the repo root. Returns a
|
||||
* `ComposerConfig` object (PSR-4 namespace → directory mappings) or
|
||||
* `null` when no `composer.json` is present or it cannot be parsed.
|
||||
*
|
||||
* The result is threaded into each `resolvePhpImportInternal` call as
|
||||
* the `composerConfig` argument.
|
||||
*/
|
||||
export function loadPhpComposerConfig(repoPath: string): ComposerConfig | null {
|
||||
try {
|
||||
const composerPath = join(repoPath, 'composer.json');
|
||||
const raw = readFileSync(composerPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||
|
||||
const composer = parsed as Record<string, unknown>;
|
||||
const autoload = composer['autoload'] as Record<string, unknown> | undefined;
|
||||
if (autoload === undefined) return null;
|
||||
|
||||
const psr4Raw = (autoload['psr-4'] ?? {}) as Record<string, string | string[]>;
|
||||
const psr4 = new Map<string, string>();
|
||||
|
||||
for (const [ns, dirs] of Object.entries(psr4Raw)) {
|
||||
// namespace prefix ends with `\` — keep as-is; resolver strips it
|
||||
const normalizedNs = ns.replace(/\\$/, '');
|
||||
const dir = Array.isArray(dirs) ? dirs[0] : dirs;
|
||||
if (typeof dir === 'string') {
|
||||
// Normalize directory path (strip trailing slash)
|
||||
const normalizedDir = dir.replace(/\/+$/, '');
|
||||
psr4.set(normalizedNs, normalizedDir);
|
||||
}
|
||||
}
|
||||
|
||||
return { psr4 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── resolvePhpImportTarget ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* LanguageProvider-shaped adapter: `(ParsedImport, WorkspaceIndex) → string | null`.
|
||||
*
|
||||
* The `WorkspaceIndex` is `unknown` in the shared contract. The scope-resolution
|
||||
* orchestrator hands us a `PhpResolveContext`-shaped object; narrow structurally
|
||||
* rather than via a cast chain so unexpected shapes return `null` cleanly.
|
||||
*/
|
||||
export function resolvePhpImportTarget(
|
||||
parsedImport: ParsedImport,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | null {
|
||||
const ctx = workspaceIndex as PhpResolveContext | undefined;
|
||||
if (
|
||||
ctx === undefined ||
|
||||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
|
||||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (parsedImport.kind === 'dynamic-unresolved') return null;
|
||||
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
|
||||
|
||||
const allFiles = ctx.allFilePaths as Set<string>;
|
||||
const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/'));
|
||||
const allFileList = [...allFiles];
|
||||
|
||||
return resolvePhpImportInternal(
|
||||
parsedImport.targetRaw,
|
||||
null, // composerConfig not available through LanguageProvider path
|
||||
allFiles,
|
||||
normalizedFileList,
|
||||
allFileList,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ScopeResolver-shaped adapter: `(targetRaw, fromFile, allFilePaths, resolutionConfig?) → string | null`.
|
||||
*
|
||||
* Used inside `scope-resolver.ts`. Accepts the optional `resolutionConfig`
|
||||
* (a `ComposerConfig | null` loaded once per workspace by
|
||||
* `loadPhpComposerConfig`) and threads it into `resolvePhpImportInternal`.
|
||||
*/
|
||||
export function resolvePhpImportTargetInternal(
|
||||
targetRaw: string,
|
||||
_fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
resolutionConfig?: unknown,
|
||||
): string | null {
|
||||
if (targetRaw === '') return null;
|
||||
|
||||
const composerConfig =
|
||||
resolutionConfig !== undefined && resolutionConfig !== null
|
||||
? (resolutionConfig as ComposerConfig)
|
||||
: null;
|
||||
|
||||
const allFiles = allFilePaths as Set<string>;
|
||||
const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/'));
|
||||
const allFileList = [...allFiles];
|
||||
|
||||
return resolvePhpImportInternal(
|
||||
targetRaw,
|
||||
composerConfig,
|
||||
allFiles,
|
||||
normalizedFileList,
|
||||
allFileList,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
73
gitnexus/src/core/ingestion/languages/php/index.ts
Normal file
73
gitnexus/src/core/ingestion/languages/php/index.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* PHP scope-resolution hooks (RFC #909 Ring 3 LANG-php, #938).
|
||||
*
|
||||
* Public API barrel. Consumers should import from this file rather than
|
||||
* the individual modules.
|
||||
*
|
||||
* Module layout (each file is a single concern):
|
||||
*
|
||||
* - `query.ts` — tree-sitter query + lazy parser/query singletons
|
||||
* - `captures.ts` — `emitPhpScopeCaptures` orchestrator
|
||||
* - `import-decomposer.ts` — each `namespace_use_declaration` → ParsedImport captures
|
||||
* - `interpret.ts` — capture-match → `ParsedImport` / `ParsedTypeBinding`
|
||||
* - `simple-hooks.ts` — small/no-op hooks made explicit
|
||||
* - `receiver-binding.ts` — synthesize `$this` / `parent` type-bindings on
|
||||
* instance-method entry
|
||||
* - `merge-bindings.ts` — PHP `use` precedence (local > import > wildcard)
|
||||
* - `arity.ts` — PHP arity compatibility (variadic, defaults)
|
||||
* - `arity-metadata.ts` — synthesize arity metadata from declarations
|
||||
* - `import-target.ts` — `(ParsedImport, WorkspaceIndex) → file path` adapter
|
||||
* wrapping `resolvePhpImportInternal` (PSR-4 + composer.json)
|
||||
* - `scope-resolver.ts` — `ScopeResolver` registered in `SCOPE_RESOLVERS`
|
||||
* - `cache-stats.ts` — PROF_SCOPE_RESOLUTION cache hit/miss counters
|
||||
*
|
||||
* ## Known limitations
|
||||
*
|
||||
* The PHP registry-primary path intentionally does NOT resolve the following.
|
||||
* Each is a conscious trade-off at migration time.
|
||||
*
|
||||
* 1. **Trait `$this` → using-class binding** — for methods defined in a
|
||||
* trait, `$this` is synthesized as a binding to the trait itself.
|
||||
* Resolving `$this` to the actual using-class type requires cross-file
|
||||
* analysis of all `use TraitName;` declarations in class bodies.
|
||||
* Deferred to a follow-up; trait method resolution falls back to the
|
||||
* trait scope.
|
||||
*
|
||||
* 2. **Anonymous classes** — `new class extends Foo { }` have no stable
|
||||
* class name and are skipped by receiver-binding synthesis. The class
|
||||
* body is still scoped; member lookups inside it will fall back to
|
||||
* free-call resolution.
|
||||
*
|
||||
* 3. **Dynamic property/method access** — `$obj->{$name}()` and
|
||||
* `$$varName` are not followed. The dynamic receiver is ignored and
|
||||
* the call falls through to the shared free-call resolver.
|
||||
*
|
||||
* 4. **Magic methods** — `__get`, `__set`, `__call`, `__callStatic` are
|
||||
* not modeled as virtual dispatch; they appear as regular method
|
||||
* declarations in the graph but calls that would route through them
|
||||
* at runtime are not distinguished.
|
||||
*
|
||||
* 5. **Laravel facade magic** — `App::make(...)`, `Cache::get(...)` etc.
|
||||
* resolve statically to the Facade class rather than the underlying
|
||||
* bound implementation. Deferred to a Laravel-specific plugin.
|
||||
*
|
||||
* 6. **Intersection types in parameters** — `T&U $param` takes the first
|
||||
* named part (`T`). This matches the legacy type-extractor's behavior.
|
||||
*
|
||||
* Shadow-harness corpus parity is the authoritative signal for which of
|
||||
* these matter in practice. The CI parity gate blocks any PR that regresses
|
||||
* either the legacy or registry-primary run of
|
||||
* `test/integration/resolvers/php.test.ts`.
|
||||
*/
|
||||
|
||||
export { emitPhpScopeCaptures } from './captures.js';
|
||||
export { getPhpCaptureCacheStats, resetPhpCaptureCacheStats } from './cache-stats.js';
|
||||
export { interpretPhpImport, interpretPhpTypeBinding } from './interpret.js';
|
||||
export { phpMergeBindings } from './merge-bindings.js';
|
||||
export { phpArityCompatibility } from './arity.js';
|
||||
export { resolvePhpImportTarget, type PhpResolveContext } from './import-target.js';
|
||||
export { phpBindingScopeFor, phpImportOwningScope, phpReceiverBinding } from './simple-hooks.js';
|
||||
// NOTE: phpScopeResolver is intentionally NOT re-exported from this barrel.
|
||||
// Importing it here would create a circular dependency:
|
||||
// php.ts → php/index.js → php/scope-resolver.js → ../php.js
|
||||
// Registry and other consumers must import directly from './php/scope-resolver.js'.
|
||||
250
gitnexus/src/core/ingestion/languages/php/interpret.ts
Normal file
250
gitnexus/src/core/ingestion/languages/php/interpret.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
/**
|
||||
* Capture-match → semantic-shape interpreters for PHP.
|
||||
*
|
||||
* - `interpretPhpImport` → `ParsedImport`
|
||||
* - `interpretPhpTypeBinding` → `ParsedTypeBinding`
|
||||
*
|
||||
* Import matches arrive pre-decomposed by `emitPhpScopeCaptures` (one
|
||||
* CaptureMatch per logical import, with synthesized `@import.kind /
|
||||
* source / name / alias` markers). Type-binding matches arrive from
|
||||
* the raw query captures — each `@type-binding.*` anchor carries
|
||||
* `@type-binding.name` + `@type-binding.type`.
|
||||
*/
|
||||
|
||||
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
|
||||
|
||||
// ─── interpretImport ──────────────────────────────────────────────────────
|
||||
|
||||
export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null {
|
||||
const kindCap = captures['@import.kind'];
|
||||
const sourceCap = captures['@import.source'];
|
||||
const nameCap = captures['@import.name'];
|
||||
const aliasCap = captures['@import.alias'];
|
||||
|
||||
const kind = kindCap?.text;
|
||||
if (kind === undefined || sourceCap === undefined) return null;
|
||||
|
||||
const source = sourceCap.text.trim();
|
||||
if (source === '') return null;
|
||||
|
||||
switch (kind) {
|
||||
case 'namespace': {
|
||||
// `use Foo\Bar;` — PHP `use` is a NAMED import (binds the class
|
||||
// `Bar`, not the namespace `Foo`). This differs from C# `using`,
|
||||
// which is a true namespace import. Producing 'named' here makes
|
||||
// `new Bar()` resolve to the imported class def.
|
||||
const localName = nameCap?.text.trim() ?? lastSegment(source);
|
||||
return {
|
||||
kind: 'named',
|
||||
localName,
|
||||
importedName: localName,
|
||||
targetRaw: source,
|
||||
};
|
||||
}
|
||||
case 'alias': {
|
||||
// `use Foo\Bar as Baz;`
|
||||
if (aliasCap === undefined) return null;
|
||||
const alias = aliasCap.text.trim();
|
||||
if (alias === '') return null;
|
||||
const importedName = lastSegment(source);
|
||||
return {
|
||||
kind: 'alias',
|
||||
localName: alias,
|
||||
importedName,
|
||||
alias,
|
||||
targetRaw: source,
|
||||
};
|
||||
}
|
||||
case 'function': {
|
||||
// `use function Foo\bar;` — treat as named import; importedName is
|
||||
// the function name (last segment). targetRaw is the full path.
|
||||
const localName = nameCap?.text.trim() ?? lastSegment(source);
|
||||
return {
|
||||
kind: 'named',
|
||||
localName,
|
||||
importedName: localName,
|
||||
targetRaw: source,
|
||||
};
|
||||
}
|
||||
case 'const': {
|
||||
// `use const Foo\BAR;` — same shape as function.
|
||||
const localName = nameCap?.text.trim() ?? lastSegment(source);
|
||||
return {
|
||||
kind: 'named',
|
||||
localName,
|
||||
importedName: localName,
|
||||
targetRaw: source,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── interpretTypeBinding ─────────────────────────────────────────────────
|
||||
|
||||
export function interpretPhpTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
|
||||
const nameCap = captures['@type-binding.name'];
|
||||
const typeCap = captures['@type-binding.type'];
|
||||
if (nameCap === undefined || typeCap === undefined) return null;
|
||||
|
||||
// Determine source from anchor captures. Order: most-specific first.
|
||||
let source: TypeRef['source'] = 'parameter-annotation';
|
||||
if (captures['@type-binding.self'] !== undefined) source = 'self';
|
||||
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
|
||||
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
|
||||
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
|
||||
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
|
||||
|
||||
let rawType: string | null;
|
||||
|
||||
if (source === 'assignment-inferred') {
|
||||
// `@type-binding.alias` captures cover several assignment RHS shapes:
|
||||
// - `$alias = $u` → rawType = '$u' (variable alias)
|
||||
// - `$u = getUser()` → rawType = 'getUser' (callable alias)
|
||||
// - `$u = new User()` → rawType = 'User' (constructor — via @type-binding.constructor; handled below)
|
||||
// - `$role = UserRole::Viewer` → rawType = 'UserRole' (enum/class constant)
|
||||
//
|
||||
// For variable aliases (`$u`), `normalizePhpType` returns null because
|
||||
// `$` is not a word character. We must preserve the raw `$`-prefixed name
|
||||
// so `followChainedRef` can walk the chain `$alias → $u → User`.
|
||||
// For callable/class names, `normalizePhpType` strips qualifiers correctly.
|
||||
const rawText = typeCap.text.trim();
|
||||
if (rawText.startsWith('$')) {
|
||||
// Variable alias: keep as-is for chain-following.
|
||||
rawType = rawText;
|
||||
} else {
|
||||
rawType = normalizePhpType(rawText);
|
||||
}
|
||||
} else {
|
||||
// All other sources: strip PHP type decoration to get the simple class name:
|
||||
// ?User → User (nullable prefix)
|
||||
// User|null → User (union with null/false/void)
|
||||
// User&Loggable → User (intersection — take first meaningful)
|
||||
// Collection<User> → User (PHPDoc generic wrapper)
|
||||
// User[] → User (array suffix)
|
||||
// \App\Models\User → User (backslash qualifier)
|
||||
rawType = normalizePhpType(typeCap.text.trim());
|
||||
}
|
||||
|
||||
if (rawType === null) return null;
|
||||
|
||||
// PHP variable names include the `$` sigil (e.g. `$user`). Most
|
||||
// bindings keep it because they are looked up via the variable
|
||||
// (`$user->method()` finds binding `$user`). Property field bindings
|
||||
// are different: `$user->address` looks up `address` (no sigil) on
|
||||
// the User class. Property declarations carry source `'annotation'`,
|
||||
// so we strip the leading `$` for that source only.
|
||||
let boundName = nameCap.text.trim();
|
||||
if (source === 'annotation' && boundName.startsWith('$')) {
|
||||
boundName = boundName.slice(1);
|
||||
}
|
||||
|
||||
return { boundName, rawTypeName: rawType, source };
|
||||
}
|
||||
|
||||
// ─── Type normalization ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize a PHP type string to a simple class identifier, or `null`
|
||||
* when the type is uninformative (primitive, void, mixed, self, etc.).
|
||||
*
|
||||
* Rules applied in order:
|
||||
* 1. Strip nullable prefix `?`
|
||||
* 2. Split on `|` (union) — keep only if exactly one non-null part
|
||||
* 3. Take first part of `&` intersection
|
||||
* 4. Strip array suffix `[]`
|
||||
* 5. Strip generic wrapper `Collection<User>` → `User`
|
||||
* 6. Canonicalize leading backslash off: `\App\Models\User` → `App\Models\User`
|
||||
* 7. Reject PHP primitive / pseudo types
|
||||
*
|
||||
* The qualified form is preserved on `TypeRef.rawName` so downstream PHP
|
||||
* receiver resolution can distinguish `\App\Other\User` from a same-simple-name
|
||||
* `User` reachable via `use`. Without this, fully-qualified type hints collapse
|
||||
* to ambiguous simple names and resolve against the caller's scope chain
|
||||
* instead of the explicit target the source named (Codex PR #1497 review,
|
||||
* finding 1).
|
||||
*/
|
||||
export function normalizePhpType(raw: string): string | null {
|
||||
// 1. Strip nullable prefix
|
||||
let type = raw.startsWith('?') ? raw.slice(1).trim() : raw;
|
||||
|
||||
// 2. Union type — keep only if one non-null/false/void part remains
|
||||
if (type.includes('|')) {
|
||||
const parts = type
|
||||
.split('|')
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed' && p !== '');
|
||||
if (parts.length !== 1) return null;
|
||||
type = parts[0];
|
||||
}
|
||||
|
||||
// 3. Intersection type — take the first part
|
||||
if (type.includes('&')) {
|
||||
const first = type.split('&')[0].trim();
|
||||
if (first === '') return null;
|
||||
type = first;
|
||||
}
|
||||
|
||||
// 4. Strip array suffix
|
||||
if (type.endsWith('[]')) type = type.slice(0, -2).trim();
|
||||
|
||||
// 5. Strip single-arg generic wrapper: Collection<User> → User
|
||||
// Qualified inner types (Collection<\App\Models\User>) survive — the
|
||||
// capture group preserves whatever the writer named.
|
||||
const genericMatch = type.match(/^\w[\w\\]*\s*<([^,<>]+)>$/);
|
||||
if (genericMatch) {
|
||||
type = genericMatch[1].trim();
|
||||
}
|
||||
|
||||
// 6. Canonicalize leading backslash off — keep the qualified path intact.
|
||||
// `\App\Models\User` → `App\Models\User`. `App\Models\User` → unchanged.
|
||||
// Unqualified `User` stays as `User`. The qualified form is the lookup
|
||||
// key into the workspace QualifiedNameIndex (PHP defs are indexed by
|
||||
// namespace-joined qualifiedName); the leading-backslash distinction in
|
||||
// source is only an "absolute path" anchor, not part of the canonical key.
|
||||
if (type.startsWith('\\')) type = type.replace(/^\\+/, '');
|
||||
|
||||
// 7. Reject primitives / pseudo-types
|
||||
if (isPrimitiveOrPseudo(type)) return null;
|
||||
|
||||
// Must be a (possibly qualified) PHP identifier — segments of word chars
|
||||
// separated by single backslashes. Empty segments (consecutive backslashes,
|
||||
// trailing backslash) are rejected.
|
||||
if (!/^\w+(?:\\\w+)*$/.test(type)) return null;
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
const PHP_PRIMITIVE_TYPES = new Set([
|
||||
'int',
|
||||
'integer',
|
||||
'float',
|
||||
'double',
|
||||
'string',
|
||||
'bool',
|
||||
'boolean',
|
||||
'array',
|
||||
'object',
|
||||
'callable',
|
||||
'iterable',
|
||||
'null',
|
||||
'void',
|
||||
'never',
|
||||
'mixed',
|
||||
'false',
|
||||
'true',
|
||||
'self',
|
||||
'static',
|
||||
'parent',
|
||||
]);
|
||||
|
||||
function isPrimitiveOrPseudo(type: string): boolean {
|
||||
return PHP_PRIMITIVE_TYPES.has(type.toLowerCase());
|
||||
}
|
||||
|
||||
/** Last backslash-separated segment: `Foo\Bar\Baz` → `Baz`. */
|
||||
function lastSegment(path: string): string {
|
||||
const parts = path.split('\\').filter(Boolean);
|
||||
return parts[parts.length - 1] ?? path;
|
||||
}
|
||||
51
gitnexus/src/core/ingestion/languages/php/merge-bindings.ts
Normal file
51
gitnexus/src/core/ingestion/languages/php/merge-bindings.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* PHP shadowing precedence for the `mergeBindings` hook.
|
||||
*
|
||||
* Tier ranking (lower wins in shadowing):
|
||||
*
|
||||
* - 0: `local` — a class member, method, local variable, or parameter
|
||||
* declared in this scope.
|
||||
* - 1: `import` / `namespace` / `reexport` — `use Foo\Bar;`,
|
||||
* `use Foo\Bar as Baz;`, `use function`, `use const`.
|
||||
* All use-statement flavors that introduce a name sit at this tier.
|
||||
* - 2: `wildcard` — grouped uses / wildcard imports (deferred; mapped
|
||||
* here for completeness).
|
||||
*
|
||||
* Within a surviving tier we de-dup by `DefId`, last-write-wins so a
|
||||
* `use` re-declared further down the file cleanly replaces the earlier
|
||||
* binding.
|
||||
*/
|
||||
|
||||
import type { BindingRef } from 'gitnexus-shared';
|
||||
|
||||
const TIER_LOCAL = 0;
|
||||
const TIER_IMPORT = 1;
|
||||
const TIER_WILDCARD = 2;
|
||||
const TIER_UNKNOWN = 3;
|
||||
|
||||
function tierOf(b: BindingRef): number {
|
||||
switch (b.origin) {
|
||||
case 'local':
|
||||
return TIER_LOCAL;
|
||||
case 'reexport':
|
||||
case 'import':
|
||||
case 'namespace':
|
||||
return TIER_IMPORT;
|
||||
case 'wildcard':
|
||||
return TIER_WILDCARD;
|
||||
default:
|
||||
return TIER_UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
export function phpMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
|
||||
if (bindings.length === 0) return bindings;
|
||||
|
||||
let bestTier = Number.POSITIVE_INFINITY;
|
||||
for (const b of bindings) bestTier = Math.min(bestTier, tierOf(b));
|
||||
const survivors = bindings.filter((b) => tierOf(b) === bestTier);
|
||||
|
||||
const seen = new Map<string, BindingRef>();
|
||||
for (const b of survivors) seen.set(b.def.nodeId, b);
|
||||
return [...seen.values()];
|
||||
}
|
||||
335
gitnexus/src/core/ingestion/languages/php/namespace-siblings.ts
Normal file
335
gitnexus/src/core/ingestion/languages/php/namespace-siblings.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
/**
|
||||
* PHP same-namespace cross-file visibility.
|
||||
*
|
||||
* In PHP, every class declared in `namespace Foo\Bar` is visible to all
|
||||
* other files in the same namespace WITHOUT an explicit `use` statement.
|
||||
* Without this pass, `Service.php` (namespace `App\Services`) can't see
|
||||
* `User` declared in `Models.php` (namespace `App\Models`) unless
|
||||
* `UserService.php` has an explicit `use App\Models\User` statement.
|
||||
*
|
||||
* More importantly, A.php (namespace `App\Models`) can return `Greeting`
|
||||
* (same namespace `App\Models`) without importing it, and the compound-
|
||||
* receiver resolver needs to find `Greeting` as a class binding in the
|
||||
* scope chain.
|
||||
*
|
||||
* Implementation mirrors C#'s `namespace-siblings.ts`:
|
||||
* 1. Extract the declared namespace from each PHP file's source.
|
||||
* 2. Group class-like defs by namespace.
|
||||
* 3. Inject sibling class defs into each file's Module scope's
|
||||
* `bindingAugmentations` with `origin: 'namespace'`.
|
||||
* 4. Also mirror return-type bindings from same-namespace siblings
|
||||
* so cross-file chain-follow finds return types without explicit imports.
|
||||
*
|
||||
* Uses the PHP tree-sitter parser (via the lazy singleton in `query.ts`)
|
||||
* to extract namespace declarations — same AST that `extractParsedFile`
|
||||
* already parsed, reused via `treeCache` to avoid double-parsing.
|
||||
*/
|
||||
|
||||
import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { getPhpParser } from './query.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
|
||||
// ─── PHP file structure extraction ──────────────────────────────────────────
|
||||
|
||||
interface PhpFileStructure {
|
||||
/** The declared namespace (backslash-separated), or '' for global namespace. */
|
||||
readonly namespace: string;
|
||||
}
|
||||
|
||||
type PhpTree = ReturnType<ReturnType<typeof getPhpParser>['parse']>;
|
||||
|
||||
/**
|
||||
* Extract the declared namespace from a PHP file's source.
|
||||
* Uses the cached AST tree when available to avoid re-parsing.
|
||||
*/
|
||||
function extractPhpFileStructure(content: string, cachedTree: unknown): PhpFileStructure {
|
||||
const tree =
|
||||
(cachedTree as PhpTree | undefined) ??
|
||||
parseSourceSafe(getPhpParser(), content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(content),
|
||||
});
|
||||
|
||||
// Walk top-level nodes looking for namespace_definition.
|
||||
// PHP files have at most one namespace declaration (PSR-4 convention).
|
||||
// `namespace_definition` has a `name:` field of type `namespace_name`.
|
||||
const root = tree.rootNode;
|
||||
for (let i = 0; i < root.namedChildCount; i++) {
|
||||
const child = root.namedChild(i);
|
||||
if (child === null) continue;
|
||||
if (child.type === 'namespace_definition') {
|
||||
const nameNode = child.childForFieldName('name');
|
||||
if (nameNode !== null) {
|
||||
return { namespace: nameNode.text };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { namespace: '' };
|
||||
}
|
||||
|
||||
// ─── Augmentation bucket helper ─────────────────────────────────────────────
|
||||
|
||||
function getAugmentationBucket(
|
||||
augmentations: Map<ScopeId, Map<string, BindingRef[]>>,
|
||||
scopeId: ScopeId,
|
||||
name: string,
|
||||
): BindingRef[] {
|
||||
let scopeBindings = augmentations.get(scopeId);
|
||||
if (scopeBindings === undefined) {
|
||||
scopeBindings = new Map<string, BindingRef[]>();
|
||||
augmentations.set(scopeId, scopeBindings);
|
||||
}
|
||||
let bucket = scopeBindings.get(name);
|
||||
if (bucket === undefined) {
|
||||
bucket = [];
|
||||
scopeBindings.set(name, bucket);
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
|
||||
function isClassLikeDef(def: SymbolDefinition): boolean {
|
||||
return (
|
||||
def.type === 'Class' ||
|
||||
def.type === 'Interface' ||
|
||||
def.type === 'Struct' ||
|
||||
def.type === 'Enum' ||
|
||||
def.type === 'Trait'
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Public entry point ──────────────────────────────────────────────────────
|
||||
|
||||
export interface PhpSiblingInputs {
|
||||
readonly fileContents: ReadonlyMap<string, string>;
|
||||
readonly treeCache?: { get(filePath: string): unknown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Side-channel cache populated by `populatePhpNamespaceSiblings` so that
|
||||
* later visibility-check hooks (e.g., `isCallableVisibleFromCaller`) can
|
||||
* look up a file's PHP namespace without re-parsing. Cleared at the start
|
||||
* of every populate run so stale entries don't leak across resolutions.
|
||||
*/
|
||||
const namespaceByFilePath = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Read the cached PHP namespace for a given filePath. Returns `''` (global)
|
||||
* when the file has no namespace_definition or hasn't been processed yet.
|
||||
* Callers should only consult this AFTER either `populatePhpClassQualifiedNames`
|
||||
* or `populatePhpNamespaceSiblings` has run for the current resolution.
|
||||
*/
|
||||
export function getPhpNamespaceForFile(filePath: string): string {
|
||||
return namespaceByFilePath.get(filePath) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject same-namespace class defs and return-type bindings into each
|
||||
* PHP file's Module scope's `bindingAugmentations`. This makes classes
|
||||
* in the same PHP namespace visible to each other without explicit `use`
|
||||
* statements, mirroring PHP's actual runtime behavior.
|
||||
*
|
||||
* Uses `origin: 'namespace'` so `phpMergeBindings` tiers it below
|
||||
* explicit `use` imports (`origin: 'import'`) and local declarations.
|
||||
*/
|
||||
export function populatePhpNamespaceSiblings(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
indexes: ScopeResolutionIndexes,
|
||||
inputs: PhpSiblingInputs,
|
||||
): void {
|
||||
// Step 1: extract namespace structure for each file. Also seed the
|
||||
// side-channel cache used by visibility-check hooks downstream.
|
||||
namespaceByFilePath.clear();
|
||||
const structureByFile = new Map<string, PhpFileStructure>();
|
||||
for (const parsed of parsedFiles) {
|
||||
const content = inputs.fileContents.get(parsed.filePath);
|
||||
if (content === undefined) continue;
|
||||
const cachedTree = inputs.treeCache?.get(parsed.filePath);
|
||||
const struct = extractPhpFileStructure(content, cachedTree);
|
||||
structureByFile.set(parsed.filePath, struct);
|
||||
namespaceByFilePath.set(parsed.filePath, struct.namespace);
|
||||
}
|
||||
|
||||
// Step 2: group class-like defs and module scopes by namespace.
|
||||
interface NamespaceBucket {
|
||||
readonly scopes: { filePath: string; scopeId: ScopeId; scope: Scope }[];
|
||||
readonly classDefs: SymbolDefinition[];
|
||||
}
|
||||
const buckets = new Map<string, NamespaceBucket>();
|
||||
const getBucket = (ns: string): NamespaceBucket => {
|
||||
let b = buckets.get(ns);
|
||||
if (b === undefined) {
|
||||
b = { scopes: [], classDefs: [] };
|
||||
buckets.set(ns, b);
|
||||
}
|
||||
return b;
|
||||
};
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const struct = structureByFile.get(parsed.filePath);
|
||||
if (struct === undefined) continue;
|
||||
const ns = struct.namespace;
|
||||
const bucket = getBucket(ns);
|
||||
|
||||
// Register the file's module scope in the bucket.
|
||||
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (moduleScope !== undefined) {
|
||||
bucket.scopes.push({
|
||||
filePath: parsed.filePath,
|
||||
scopeId: moduleScope.id,
|
||||
scope: moduleScope,
|
||||
});
|
||||
}
|
||||
|
||||
// Collect class-like defs declared at the top-level of this file
|
||||
// (defs in Class or Module scopes, excluding nested inner classes).
|
||||
for (const scope of parsed.scopes) {
|
||||
if (scope.kind !== 'Class') continue;
|
||||
// Only top-level class scopes (parent is Module or Namespace scope).
|
||||
if (scope.parent === null) continue;
|
||||
const parentScope = parsed.scopes.find((s) => s.id === scope.parent);
|
||||
if (
|
||||
parentScope === undefined ||
|
||||
(parentScope.kind !== 'Module' && parentScope.kind !== 'Namespace')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const def of scope.ownedDefs) {
|
||||
if (isClassLikeDef(def)) {
|
||||
bucket.classDefs.push(def);
|
||||
break; // one class-like per scope
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const augmentations = indexes.bindingAugmentations as Map<ScopeId, Map<string, BindingRef[]>>;
|
||||
|
||||
// Step 3: For each namespace bucket, inject sibling class bindings
|
||||
// into every file's Module scope (that is NOT the declaring file).
|
||||
for (const [, bucket] of buckets) {
|
||||
// Build name → def map (simple name of qualifiedName).
|
||||
const defsByName = new Map<string, SymbolDefinition[]>();
|
||||
for (const def of bucket.classDefs) {
|
||||
const q = def.qualifiedName ?? '';
|
||||
const simpleName = q.includes('.')
|
||||
? q.slice(q.lastIndexOf('.') + 1)
|
||||
: q.includes('\\')
|
||||
? q.slice(q.lastIndexOf('\\') + 1)
|
||||
: q;
|
||||
if (simpleName === '') continue;
|
||||
const arr = defsByName.get(simpleName) ?? [];
|
||||
arr.push(def);
|
||||
defsByName.set(simpleName, arr);
|
||||
}
|
||||
|
||||
for (const { filePath, scopeId, scope } of bucket.scopes) {
|
||||
for (const [name, defs] of defsByName) {
|
||||
// Skip if already locally declared (origin: 'local' wins).
|
||||
const local = scope.bindings.get(name);
|
||||
if (local !== undefined && local.some((b) => b.origin === 'local')) continue;
|
||||
|
||||
for (const def of defs) {
|
||||
if (def.filePath === filePath) continue; // don't self-inject
|
||||
const arr = getAugmentationBucket(augmentations, scopeId, name);
|
||||
if (arr.some((b) => b.def.nodeId === def.nodeId)) continue;
|
||||
arr.push({ def, origin: 'namespace' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3b: Inject fully-qualified-name bindings into every PHP file's
|
||||
// Module scope. PHP `\App\Models\User` (leading-backslash FQN) and
|
||||
// `App\Models\User` (already-qualified relative) on a parameter or
|
||||
// typed receiver must resolve to the exact namespace-qualified class
|
||||
// regardless of which simple-name `User` the caller's `use` imports
|
||||
// shadowed. The shared `findClassBindingInScope` scope-chain walk
|
||||
// consumes these augmentations via `lookupBindingsAt`, so adding the
|
||||
// qualified key on every file's module scope routes FQN-receivers to
|
||||
// the right def. Codex PR #1497 review, finding 1.
|
||||
//
|
||||
// Cost: O(PHP files × class-like defs in the workspace) augmentation
|
||||
// entries. Bounded and acceptable in practice — typical PHP projects
|
||||
// have hundreds of files and classes, not tens of thousands.
|
||||
for (const parsed of parsedFiles) {
|
||||
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (moduleScope === undefined) continue;
|
||||
const moduleScopeId = moduleScope.id;
|
||||
|
||||
for (const [ns, bucket] of buckets) {
|
||||
if (ns === '') continue; // global-namespace classes have no qualified form to register
|
||||
for (const def of bucket.classDefs) {
|
||||
const q = def.qualifiedName ?? '';
|
||||
const simpleName = q.includes('\\') ? q.slice(q.lastIndexOf('\\') + 1) : q;
|
||||
if (simpleName === '') continue;
|
||||
const fqn = `${ns}\\${simpleName}`;
|
||||
const arr = getAugmentationBucket(augmentations, moduleScopeId, fqn);
|
||||
if (arr.some((b) => b.def.nodeId === def.nodeId)) continue;
|
||||
arr.push({ def, origin: 'namespace' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Mirror return-type bindings from same-namespace sibling files.
|
||||
// This enables chain-follow like `$c->greet()->save()` where `greet()`
|
||||
// returns `Greeting` (declared in A.php, same namespace) and `Greeting`
|
||||
// isn't imported in the calling file. Without this, the compound-receiver
|
||||
// resolver can't resolve `Greeting` as a class binding in the importer's
|
||||
// scope chain.
|
||||
//
|
||||
// Additionally, mirror from files that are imported via `use` (different
|
||||
// namespace) so return types from dependencies are chain-followable too.
|
||||
for (const parsed of parsedFiles) {
|
||||
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (moduleScope === undefined) continue;
|
||||
const moduleTypeBindings = moduleScope.typeBindings as Map<
|
||||
string,
|
||||
import('gitnexus-shared').TypeRef
|
||||
>;
|
||||
|
||||
const struct = structureByFile.get(parsed.filePath);
|
||||
const ownNs = struct?.namespace ?? '';
|
||||
|
||||
// Collect namespaces accessible from this file:
|
||||
// 1. Own namespace (same-ns siblings)
|
||||
// 2. Namespaces of directly imported files (via parsedImports → targetRaw → PSR-4 namespace)
|
||||
const accessibleFiles = new Set<string>();
|
||||
|
||||
// Same-namespace siblings.
|
||||
const sameBucket = buckets.get(ownNs);
|
||||
if (sameBucket !== undefined) {
|
||||
for (const { filePath } of sameBucket.scopes) {
|
||||
if (filePath !== parsed.filePath) accessibleFiles.add(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Files directly imported by this file (finalized import edges).
|
||||
const ownModuleScopeBindings = indexes.bindings.get(moduleScope.id);
|
||||
if (ownModuleScopeBindings !== undefined) {
|
||||
for (const [, refs] of ownModuleScopeBindings) {
|
||||
for (const ref of refs) {
|
||||
if (ref.origin === 'import' || ref.origin === 'namespace') {
|
||||
const importFilePath = ref.def.filePath;
|
||||
if (importFilePath !== parsed.filePath) {
|
||||
accessibleFiles.add(importFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror return-type bindings from accessible files.
|
||||
for (const srcFilePath of accessibleFiles) {
|
||||
const srcParsed = parsedFiles.find((p) => p.filePath === srcFilePath);
|
||||
if (srcParsed === undefined) continue;
|
||||
const srcModuleScope = srcParsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (srcModuleScope === undefined) continue;
|
||||
for (const [boundName, typeRef] of srcModuleScope.typeBindings) {
|
||||
if (moduleTypeBindings.has(boundName)) continue;
|
||||
moduleTypeBindings.set(boundName, typeRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
332
gitnexus/src/core/ingestion/languages/php/query.ts
Normal file
332
gitnexus/src/core/ingestion/languages/php/query.ts
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
/**
|
||||
* Tree-sitter query for PHP scope captures (RFC #909 Ring 3 LANG-php).
|
||||
*
|
||||
* Captures the structural skeleton the generic scope-resolution pipeline
|
||||
* consumes: scopes (program/namespace/class/function), declarations
|
||||
* (class-likes, method-likes, properties, variables), imports
|
||||
* (namespace_use_declaration), type bindings (parameter annotations,
|
||||
* property types, constructor-inferred locals, return types), and
|
||||
* references (call sites, member writes).
|
||||
*
|
||||
* PHP specifics that shape this query:
|
||||
*
|
||||
* - `namespace_use_declaration` is an import only at top level / inside
|
||||
* namespace blocks. Class-body `use_declaration` (trait-use) is a
|
||||
* different node type and is NOT captured here.
|
||||
*
|
||||
* - `object_creation_expression` has `name` and `qualified_name` as
|
||||
* direct children (no wrapping node).
|
||||
*
|
||||
* - `method_declaration` exposes a `return_type:` named field containing
|
||||
* a `type` node, which may be `named_type`, `optional_type`, etc.
|
||||
*
|
||||
* - `property_element` has a `name:` field of type `variable_name`.
|
||||
*
|
||||
* - `variable_name` nodes always include the `$` sigil in their text.
|
||||
*
|
||||
* Exposes lazy `Parser` and `Query` singletons so callers don't pay
|
||||
* tree-sitter init cost per file.
|
||||
*/
|
||||
|
||||
import Parser from 'tree-sitter';
|
||||
import Php from 'tree-sitter-php';
|
||||
|
||||
// tree-sitter-php exports `{ php, php_only, html }` in recent versions, or the
|
||||
// language directly in older versions.
|
||||
//
|
||||
// IMPORTANT: must match the grammar used by the central parse phase
|
||||
// (`src/core/tree-sitter/parser-loader.ts` line: `[SupportedLanguages.PHP]: PHP.php_only`).
|
||||
// Using a different grammar variant causes tree-sitter to throw when running
|
||||
// a query built against grammar A on a tree parsed by grammar B — this error
|
||||
// is swallowed by `scope-extractor-bridge.ts`, producing silent empty results.
|
||||
const Php_typed = Php as unknown as { php_only?: unknown; php?: unknown };
|
||||
const PHP_LANG = Php_typed.php_only ?? Php_typed.php ?? Php;
|
||||
|
||||
const PHP_SCOPE_QUERY = `
|
||||
;; ── Scopes ────────────────────────────────────────────────────────────────
|
||||
|
||||
(program) @scope.module
|
||||
|
||||
;; Both block-scoped and statement-scoped namespace declarations.
|
||||
(namespace_definition) @scope.namespace
|
||||
|
||||
(class_declaration) @scope.class
|
||||
(interface_declaration) @scope.class
|
||||
(trait_declaration) @scope.class
|
||||
(enum_declaration) @scope.class
|
||||
|
||||
(method_declaration) @scope.function
|
||||
(function_definition) @scope.function
|
||||
(anonymous_function) @scope.function
|
||||
(arrow_function) @scope.function
|
||||
|
||||
;; ── Declarations — types ──────────────────────────────────────────────────
|
||||
|
||||
(class_declaration
|
||||
name: (name) @declaration.name) @declaration.class
|
||||
|
||||
(interface_declaration
|
||||
name: (name) @declaration.name) @declaration.interface
|
||||
|
||||
(trait_declaration
|
||||
name: (name) @declaration.name) @declaration.trait
|
||||
|
||||
(enum_declaration
|
||||
name: (name) @declaration.name) @declaration.enum
|
||||
|
||||
;; ── Declarations — methods / functions / constructors ─────────────────────
|
||||
|
||||
(method_declaration
|
||||
name: (name) @declaration.name) @declaration.method
|
||||
|
||||
(function_definition
|
||||
name: (name) @declaration.name) @declaration.function
|
||||
|
||||
;; ── Declarations — properties ─────────────────────────────────────────────
|
||||
|
||||
;; PHP 7.4+ typed property: private UserRepo $repo;
|
||||
;; property_element has name: (variable_name) field.
|
||||
;; Emits BOTH a declaration (so SemanticModel registers the property) AND a type-binding.
|
||||
(property_declaration
|
||||
type: (_) @type-binding.type
|
||||
(property_element
|
||||
name: (variable_name) @type-binding.name)) @type-binding.annotation
|
||||
|
||||
(property_declaration
|
||||
type: (_)
|
||||
(property_element
|
||||
name: (variable_name) @declaration.name)) @declaration.property
|
||||
|
||||
;; Untyped property: public $id; — capture as plain declaration.
|
||||
(property_declaration
|
||||
(property_element
|
||||
name: (variable_name) @declaration.name)) @declaration.variable
|
||||
|
||||
;; ── Imports — namespace_use_declaration ───────────────────────────────────
|
||||
;;
|
||||
;; Captures ALL forms: plain, alias, function/const qualifiers, and grouped.
|
||||
;; The import-decomposer in captures.ts fans out grouped uses.
|
||||
;;
|
||||
;; NOTE: class-body use_declaration = trait-use, NOT an import.
|
||||
;; Only namespace_use_declaration (top-level / namespace scope) is an import.
|
||||
|
||||
(namespace_use_declaration) @import.statement
|
||||
|
||||
;; ── Type bindings — parameters ────────────────────────────────────────────
|
||||
|
||||
;; simple_parameter with a type hint: function f(User $u)
|
||||
;; type field is a 'type' supertype (named_type, optional_type, union_type, etc.)
|
||||
(simple_parameter
|
||||
type: (_) @type-binding.type
|
||||
name: (variable_name) @type-binding.name) @type-binding.parameter
|
||||
|
||||
;; property_promotion_parameter: function __construct(private User $u)
|
||||
;; Emits type-binding so the constructor body can resolve $u as the typed param.
|
||||
(property_promotion_parameter
|
||||
type: (_) @type-binding.type
|
||||
name: (variable_name) @type-binding.name) @type-binding.parameter
|
||||
|
||||
;; Also emit a @type-binding.annotation for the promoted parameter so that
|
||||
;; phpBindingScopeFor can hoist it to the Class scope (stripping the $ sigil).
|
||||
;; This enables compound-receiver resolution: $user->address->save() resolves
|
||||
;; address → Address via the Class scope's typeBindings.
|
||||
;; The @type-binding.parameter above stays for constructor-body resolution ($address).
|
||||
(property_promotion_parameter
|
||||
type: (_) @type-binding.type
|
||||
name: (variable_name) @type-binding.name) @type-binding.annotation
|
||||
|
||||
;; Also emit a @declaration.property so SemanticModel registers the promoted
|
||||
;; parameter as a class-owned property (enabling $obj->propName lookups).
|
||||
(property_promotion_parameter
|
||||
name: (variable_name) @declaration.name) @declaration.property
|
||||
|
||||
;; ── Type bindings — local assignment: $u = new User() ─────────────────────
|
||||
|
||||
;; new ClassName() — name is a direct child of object_creation_expression
|
||||
(assignment_expression
|
||||
left: (variable_name) @type-binding.name
|
||||
right: (object_creation_expression
|
||||
(name) @type-binding.type)) @type-binding.constructor
|
||||
|
||||
;; new Foo\Bar\ClassName() — qualified_name wraps name
|
||||
(assignment_expression
|
||||
left: (variable_name) @type-binding.name
|
||||
right: (object_creation_expression
|
||||
(qualified_name
|
||||
(name) @type-binding.type))) @type-binding.constructor
|
||||
|
||||
;; ── Type bindings — $alias = $u (identifier alias) ───────────────────────
|
||||
|
||||
(assignment_expression
|
||||
left: (variable_name) @type-binding.name
|
||||
right: (variable_name) @type-binding.type) @type-binding.alias
|
||||
|
||||
;; ── Type bindings — $u = factory() (free call return alias) ──────────────
|
||||
|
||||
(assignment_expression
|
||||
left: (variable_name) @type-binding.name
|
||||
right: (function_call_expression
|
||||
function: (name) @type-binding.type)) @type-binding.alias
|
||||
|
||||
;; ── Type bindings — $u = $svc->getUser() (method call return alias) ───────
|
||||
|
||||
(assignment_expression
|
||||
left: (variable_name) @type-binding.name
|
||||
right: (member_call_expression
|
||||
name: (name) @type-binding.type)) @type-binding.alias
|
||||
|
||||
;; ── Type bindings — method return type ───────────────────────────────────
|
||||
|
||||
;; method_declaration exposes return_type: field (type node supertype).
|
||||
;; named_type wraps the class name: function getUser(): User
|
||||
(method_declaration
|
||||
name: (name) @type-binding.name
|
||||
return_type: (named_type
|
||||
(name) @type-binding.type)) @type-binding.return
|
||||
|
||||
;; nullable return type via optional_type: function getUser(): ?User
|
||||
(method_declaration
|
||||
name: (name) @type-binding.name
|
||||
return_type: (optional_type
|
||||
(named_type
|
||||
(name) @type-binding.type))) @type-binding.return
|
||||
|
||||
;; function_definition (top-level or namespace-level) return type: User
|
||||
;; Enables cross-file return-type propagation for free functions.
|
||||
(function_definition
|
||||
name: (name) @type-binding.name
|
||||
return_type: (named_type
|
||||
(name) @type-binding.type)) @type-binding.return
|
||||
|
||||
;; nullable return type for function_definition: ?User
|
||||
(function_definition
|
||||
name: (name) @type-binding.name
|
||||
return_type: (optional_type
|
||||
(named_type
|
||||
(name) @type-binding.type))) @type-binding.return
|
||||
|
||||
;; ── References — free calls: foo() ───────────────────────────────────────
|
||||
|
||||
(function_call_expression
|
||||
function: (name) @reference.name) @reference.call.free
|
||||
|
||||
;; ── References — member calls: $obj->method() ────────────────────────────
|
||||
;;
|
||||
;; SAFETY-INVARIANT (Finding 1 of PR #1497 adversarial review): the name:
|
||||
;; field is constrained to (name), NOT (_) — tree-sitter-php emits
|
||||
;; variable_name nodes for dynamic method names ($obj->$method(),
|
||||
;; $obj->{$method}()). Keeping the pattern at (name) is what suppresses
|
||||
;; capture of those dynamic shapes. The resolver is structural-only and
|
||||
;; cannot infer the bound method name from runtime values; relaxing this
|
||||
;; pattern to (_) would silently emit zero-confidence false-positive
|
||||
;; edges. Regression: test/fixtures/lang-resolution/php-dynamic-calls/.
|
||||
|
||||
(member_call_expression
|
||||
object: (_) @reference.receiver
|
||||
name: (name) @reference.name) @reference.call.member
|
||||
|
||||
;; ── References — null-safe member calls: $obj?->method() (PHP 8+) ─────────
|
||||
|
||||
(nullsafe_member_call_expression
|
||||
object: (_) @reference.receiver
|
||||
name: (name) @reference.name) @reference.call.member
|
||||
|
||||
;; ── References — static calls: X::method() ───────────────────────────────
|
||||
;;
|
||||
;; Same SAFETY-INVARIANT as member_call_expression above: name: (name)
|
||||
;; deliberately excludes variable_name so Class::$method() and
|
||||
;; $className::$method() shapes do not capture. The receiver field uses
|
||||
;; (_) because static dispatch on a variable receiver
|
||||
;; ($className::method()) IS captured — but resolution falls through
|
||||
;; harmlessly when $className has no class type binding. See
|
||||
;; php-dynamic-calls/ regression suite.
|
||||
|
||||
(scoped_call_expression
|
||||
scope: (_) @reference.receiver
|
||||
name: (name) @reference.name) @reference.call.member
|
||||
|
||||
;; ── Type bindings — $x = X::Constant or $x = X::CASE (enum case) ─────────
|
||||
;; Binds the variable to the class name X so member calls on $x dispatch
|
||||
;; to X's methods (e.g. UserRole::Viewer → label()).
|
||||
;;
|
||||
;; tree-sitter-php emits class_constant_access_expression with two name
|
||||
;; children: [0]=class/enum name, [1]=constant/case name. The dot-anchor
|
||||
;; before (name) matches only the FIRST name child (the class).
|
||||
|
||||
(assignment_expression
|
||||
left: (variable_name) @type-binding.name
|
||||
right: (class_constant_access_expression
|
||||
. (name) @type-binding.type)) @type-binding.alias
|
||||
|
||||
(assignment_expression
|
||||
left: (variable_name) @type-binding.name
|
||||
right: (class_constant_access_expression
|
||||
(qualified_name
|
||||
(name) @type-binding.type))) @type-binding.alias
|
||||
|
||||
;; ── Type bindings — $x = SomeClass::staticFactory() ──────────────────────
|
||||
;; Binds $x to the type returned by the static factory method, anchored on
|
||||
;; the method name (chain-follow resolves the actual return type later).
|
||||
|
||||
(assignment_expression
|
||||
left: (variable_name) @type-binding.name
|
||||
right: (scoped_call_expression
|
||||
name: (name) @type-binding.type)) @type-binding.alias
|
||||
|
||||
;; ── Type bindings — null-safe member-call result: $x = $a?->getY() ───────
|
||||
|
||||
(assignment_expression
|
||||
left: (variable_name) @type-binding.name
|
||||
right: (nullsafe_member_call_expression
|
||||
name: (name) @type-binding.type)) @type-binding.alias
|
||||
|
||||
;; ── References — constructor calls: new User() ───────────────────────────
|
||||
|
||||
(object_creation_expression
|
||||
(name) @reference.name) @reference.call.constructor
|
||||
|
||||
(object_creation_expression
|
||||
(qualified_name
|
||||
(name) @reference.name)) @reference.call.constructor
|
||||
|
||||
;; ── References — member writes: $obj->prop = $x ──────────────────────────
|
||||
|
||||
(assignment_expression
|
||||
left: (member_access_expression
|
||||
object: (_) @reference.receiver
|
||||
name: (name) @reference.name)) @reference.write.member
|
||||
|
||||
;; ── References — static property writes: User::$count = $x ──────────────
|
||||
;; Uses @reference.write.static anchor so captures.ts can strip the leading
|
||||
;; $ from the variable_name capture (static props are stored without $ in graph).
|
||||
;;
|
||||
;; SAFETY-INVARIANT (Finding 2 of PR #1497 adversarial review): no
|
||||
;; read-access property capture exists in this query — dynamic property
|
||||
;; reads ($obj->$prop, $obj->{$prop}) produce no captures, which is the
|
||||
;; desired behavior for a structural-only resolver. Adding a read pattern
|
||||
;; in the future MUST keep name: (name) (not (_)) to preserve the
|
||||
;; suppression. Regression: php-dynamic-calls/ fixture dynamicPropertyRead.
|
||||
|
||||
(assignment_expression
|
||||
left: (scoped_property_access_expression
|
||||
scope: (_) @reference.receiver
|
||||
name: (variable_name) @reference.name)) @reference.write.static
|
||||
`;
|
||||
|
||||
let _parser: Parser | null = null;
|
||||
let _query: Parser.Query | null = null;
|
||||
|
||||
export function getPhpParser(): Parser {
|
||||
if (_parser === null) {
|
||||
_parser = new Parser();
|
||||
_parser.setLanguage(PHP_LANG as Parameters<Parser['setLanguage']>[0]);
|
||||
}
|
||||
return _parser;
|
||||
}
|
||||
|
||||
export function getPhpScopeQuery(): Parser.Query {
|
||||
if (_query === null) {
|
||||
_query = new Parser.Query(PHP_LANG as Parameters<Parser['setLanguage']>[0], PHP_SCOPE_QUERY);
|
||||
}
|
||||
return _query;
|
||||
}
|
||||
136
gitnexus/src/core/ingestion/languages/php/receiver-binding.ts
Normal file
136
gitnexus/src/core/ingestion/languages/php/receiver-binding.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Synthesize `@type-binding.self` captures for PHP instance methods —
|
||||
* one for `$this` (always on non-static methods inside a type
|
||||
* declaration) and optionally one for `parent` (only on class methods
|
||||
* when the enclosing class has an explicit `base_clause`).
|
||||
*
|
||||
* Mirrors `languages/csharp/receiver-binding.ts` in structure. PHP's
|
||||
* grammar doesn't give us a clean `.scm` pattern for "implicit receiver
|
||||
* on every instance method inside an enclosing type" because `$this` is
|
||||
* not a parameter — it's an implicit receiver. Synthesis in code is the
|
||||
* same approach C# uses for `this` / `base`.
|
||||
*
|
||||
* ## Known limitations
|
||||
*
|
||||
* - **Trait `$this`**: for methods defined in a trait, `$this` is
|
||||
* synthesized as a binding to the trait itself. The actual using-class
|
||||
* type is not known at single-file parse time. V1 limitation —
|
||||
* documented in `index.ts`.
|
||||
* - **Anonymous classes**: skipped (no stable enclosing class name).
|
||||
*/
|
||||
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
const TYPE_DECL_NODE_TYPES = new Set([
|
||||
'class_declaration',
|
||||
'interface_declaration',
|
||||
'trait_declaration',
|
||||
'enum_declaration',
|
||||
]);
|
||||
|
||||
const FUNCTION_NODE_TYPES = new Set([
|
||||
'method_declaration',
|
||||
'function_definition',
|
||||
'anonymous_function',
|
||||
'arrow_function',
|
||||
]);
|
||||
|
||||
/** Walk up to find the enclosing type declaration. */
|
||||
function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null {
|
||||
let cur: SyntaxNode | null = node.parent;
|
||||
while (cur !== null) {
|
||||
if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur;
|
||||
cur = cur.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function typeName(typeNode: SyntaxNode): string | null {
|
||||
return typeNode.childForFieldName('name')?.text ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the base class name from a `base_clause` child of the class node.
|
||||
* `base_clause` contains a `qualified_name` or `name` child.
|
||||
*/
|
||||
function baseClauseText(typeNode: SyntaxNode): string | null {
|
||||
for (let i = 0; i < typeNode.namedChildCount; i++) {
|
||||
const child = typeNode.namedChild(i);
|
||||
if (child === null || child.type !== 'base_clause') continue;
|
||||
const nameNode = child.firstNamedChild;
|
||||
if (nameNode === null) return null;
|
||||
// Take last segment of qualified name (e.g. \App\Models\BaseModel → BaseModel)
|
||||
const text = nameNode.text.trim();
|
||||
const segments = text.split('\\').filter(Boolean);
|
||||
return segments[segments.length - 1] ?? text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Check whether this method has a `static_modifier` child. */
|
||||
function isStaticMethod(fnNode: SyntaxNode): boolean {
|
||||
for (let i = 0; i < fnNode.namedChildCount; i++) {
|
||||
const child = fnNode.namedChild(i);
|
||||
if (child !== null && child.type === 'static_modifier') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build zero, one, or two `@type-binding.self` matches for `fnNode`:
|
||||
*
|
||||
* - Returns `[]` if the function is free (no enclosing type), static,
|
||||
* or the enclosing type has no resolvable name.
|
||||
* - Returns one match (`$this`) for non-static methods inside a
|
||||
* class / trait / interface / enum body.
|
||||
* - Returns two matches (`$this` + `parent`) only when the function
|
||||
* lives in a `class_declaration` that has an explicit `base_clause`.
|
||||
*
|
||||
* The caller is responsible for guaranteeing
|
||||
* `FUNCTION_NODE_TYPES.has(fnNode.type)`.
|
||||
*/
|
||||
export function synthesizePhpReceiverBinding(fnNode: SyntaxNode): CaptureMatch[] {
|
||||
if (!FUNCTION_NODE_TYPES.has(fnNode.type)) return [];
|
||||
if (isStaticMethod(fnNode)) return [];
|
||||
|
||||
const enclosingType = findEnclosingTypeDeclaration(fnNode);
|
||||
if (enclosingType === null) return [];
|
||||
|
||||
// Anonymous class — skip (no stable name).
|
||||
if (enclosingType.type === 'anonymous_class_declaration') return [];
|
||||
|
||||
const enclosingName = typeName(enclosingType);
|
||||
if (enclosingName === null) return [];
|
||||
|
||||
// Anchor the synthesized captures to the method body (compound_statement)
|
||||
// so they land inside the function scope, not at the class scope.
|
||||
// For interface/abstract methods that have no body, skip.
|
||||
const bodyNode =
|
||||
fnNode.childForFieldName('body') ??
|
||||
// arrow_function: body is the expression after `=>`
|
||||
fnNode.childForFieldName('return_value');
|
||||
if (bodyNode === null) return [];
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
out.push(buildReceiverMatch(bodyNode, '$this', enclosingName));
|
||||
|
||||
// `parent` applies only to class methods with an explicit base_clause.
|
||||
if (enclosingType.type === 'class_declaration') {
|
||||
const baseText = baseClauseText(enclosingType);
|
||||
if (baseText !== null) {
|
||||
out.push(buildReceiverMatch(bodyNode, 'parent', baseText));
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildReceiverMatch(anchorNode: SyntaxNode, name: string, typeText: string): CaptureMatch {
|
||||
const m: Record<string, Capture> = {
|
||||
'@type-binding.self': nodeToCapture('@type-binding.self', anchorNode),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText),
|
||||
};
|
||||
return m;
|
||||
}
|
||||
421
gitnexus/src/core/ingestion/languages/php/scope-resolver.ts
Normal file
421
gitnexus/src/core/ingestion/languages/php/scope-resolver.ts
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
/**
|
||||
* PHP `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
|
||||
* the generic `runScopeResolution` orchestrator (RFC #909 Ring 3 LANG-php).
|
||||
*
|
||||
* Third migration after Python and C#. See `pythonScopeResolver` for the
|
||||
* canonical shape.
|
||||
*
|
||||
* ## Circular-import avoidance
|
||||
*
|
||||
* The old PR had `php/scope-resolver.ts` importing `phpProvider` from
|
||||
* `../php.js` while `php.ts` imported `phpScopeResolver` from `./php/index.js`
|
||||
* — undefined at module load. The canonical fix (mirroring C#):
|
||||
*
|
||||
* - `scope-resolver.ts` imports `phpProvider` from `../php.js` ✓
|
||||
* - `php.ts` imports individual hook FUNCTIONS from `./php/index.js` ✗
|
||||
*
|
||||
* Node's ESM handles the cycle correctly because `phpProvider` is a named
|
||||
* export that is live-binding — by the time `phpScopeResolver` is first
|
||||
* read (lazily, at resolution time), `phpProvider` is fully initialized.
|
||||
*/
|
||||
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
|
||||
import {
|
||||
findReceiverTypeBinding,
|
||||
populateClassOwnedMembers,
|
||||
} from '../../scope-resolution/scope/walkers.js';
|
||||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
||||
import {
|
||||
resolveCallerGraphId,
|
||||
resolveDefGraphId,
|
||||
} from '../../scope-resolution/graph-bridge/ids.js';
|
||||
import { narrowOverloadCandidates } from '../../scope-resolution/passes/overload-narrowing.js';
|
||||
import type { SemanticModel } from '../../model/semantic-model.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { SymbolDefinition } from 'gitnexus-shared';
|
||||
import { phpProvider } from '../php.js';
|
||||
import { phpArityCompatibility, phpMergeBindings } from './index.js';
|
||||
import { resolvePhpImportTargetInternal, loadPhpComposerConfig } from './import-target.js';
|
||||
import { populatePhpNamespaceSiblings, getPhpNamespaceForFile } from './namespace-siblings.js';
|
||||
|
||||
/**
|
||||
* PHP MRO builder — extends the generic EXTENDS-only MRO with trait-use
|
||||
* relationships encoded as IMPLEMENTS edges.
|
||||
*
|
||||
* PHP trait-use (`use TraitName;` inside a class body) is recorded in the
|
||||
* graph as an IMPLEMENTS edge from the using class to the Trait node. The
|
||||
* generic `buildMro` only walks EXTENDS edges, so trait methods are invisible
|
||||
* to the MRO-based dispatch index. This variant:
|
||||
*
|
||||
* 1. Runs the generic `buildMro` (EXTENDS edges, Class defs only).
|
||||
* 2. Indexes Trait defs from `parsedFiles` alongside Class defs.
|
||||
* 3. Walks IMPLEMENTS edges; for each edge whose target resolves to a
|
||||
* Trait DefId, prepends that Trait DefId to the source class's MRO.
|
||||
*
|
||||
* Trait methods are searched BEFORE parent-class methods (PHP semantics:
|
||||
* a trait method shadows the parent-class method but is overridden by the
|
||||
* using class's own methods).
|
||||
*/
|
||||
/**
|
||||
* PHP free-call visibility check for `pickUniqueGlobalCallable`. Returns
|
||||
* true when the candidate function is reachable from the caller's PHP
|
||||
* namespace context, false when the cross-namespace bridge would be a
|
||||
* false positive (e.g., `\App\Utils\format` is not visible from `\App`
|
||||
* without an explicit `use function App\Utils\format;`).
|
||||
*
|
||||
* Rules (PHP semantics):
|
||||
* 1. Same-namespace candidates are always visible.
|
||||
* 2. Global-namespace candidates (no namespace prefix) are visible from
|
||||
* every caller — PHP's global fallback for functions/constants.
|
||||
* 3. Candidates in a different namespace are visible only when the
|
||||
* caller has a `use function` import that matches the candidate's
|
||||
* fully-qualified name.
|
||||
*/
|
||||
function phpIsCallableVisibleFromCaller(ctx: {
|
||||
callerParsed: ParsedFile;
|
||||
candidate: SymbolDefinition;
|
||||
}): boolean {
|
||||
const { callerParsed, candidate } = ctx;
|
||||
const callerNs = getPhpNamespaceForFile(callerParsed.filePath);
|
||||
const candNs = getPhpNamespaceForFile(candidate.filePath);
|
||||
|
||||
// Global-namespace candidate: PHP falls back to global for functions
|
||||
// and constants when the local namespace doesn't define them.
|
||||
if (candNs === '') return true;
|
||||
|
||||
// Same-namespace: caller can see the candidate without an explicit use.
|
||||
if (candNs === callerNs) return true;
|
||||
|
||||
// Cross-namespace: require an explicit `use function` import in the
|
||||
// caller's parsedImports that matches the candidate's fully-qualified
|
||||
// name. interpret.ts maps `use function Foo\bar` to a named import with
|
||||
// localName = 'bar' and targetRaw = 'Foo\\bar'.
|
||||
const candQualified =
|
||||
candidate.qualifiedName === undefined
|
||||
? ''
|
||||
: candNs !== '' && !candidate.qualifiedName.includes('\\')
|
||||
? `${candNs}\\${candidate.qualifiedName}`
|
||||
: candidate.qualifiedName;
|
||||
if (candQualified === '') return false;
|
||||
return callerParsed.parsedImports.some(
|
||||
(imp) =>
|
||||
imp.kind === 'named' &&
|
||||
imp.targetRaw.replace(/^\\+/, '') === candQualified.replace(/^\\+/, ''),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the EXTENDS-only ancestor chain for every class — no trait
|
||||
* augmentation. PHP semantics: `parent::method()` walks this view so
|
||||
* that `parent::` resolves to the parent class's method, even when a
|
||||
* composed trait shadows the same name.
|
||||
*
|
||||
* Returns the same shape as `buildPhpMro` so callers can swap views
|
||||
* without changing dispatch logic. Just `buildMro` + `defaultLinearize`
|
||||
* — no trait IMPLEMENTS edge walk.
|
||||
*/
|
||||
function buildPhpExtendsOnlyMro(
|
||||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
): Map<string, string[]> {
|
||||
return buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
|
||||
}
|
||||
|
||||
function buildPhpMro(
|
||||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
): Map<string, string[]> {
|
||||
// Step 1: run generic MRO (Class-only, EXTENDS-only).
|
||||
const mro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
|
||||
|
||||
// Step 2: build a graphId → defId map for ALL class-like defs including Traits.
|
||||
// After the `isLinkableLabel` fix, Trait nodes are now indexed in nodeLookup.
|
||||
const defIdByGraphId = new Map<string, string>();
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const def of parsed.localDefs) {
|
||||
if (def.type !== 'Class' && def.type !== 'Trait') continue;
|
||||
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
|
||||
if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2b: build a Set of Trait defIds for O(1) trait-vs-interface checks.
|
||||
const traitDefIds = new Set<string>();
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const def of parsed.localDefs) {
|
||||
if (def.type === 'Trait') traitDefIds.add(def.nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: collect direct trait-use edges (IMPLEMENTS where target is a Trait).
|
||||
// Maps class/trait defId → [traitDefId, ...] for direct `use TraitName;`.
|
||||
const directTraitUse = new Map<string, string[]>();
|
||||
for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
|
||||
const sourceDefId = defIdByGraphId.get(rel.sourceId);
|
||||
if (sourceDefId === undefined) continue;
|
||||
const targetDefId = defIdByGraphId.get(rel.targetId);
|
||||
if (targetDefId === undefined) continue;
|
||||
if (!traitDefIds.has(targetDefId)) continue;
|
||||
|
||||
let list = directTraitUse.get(sourceDefId);
|
||||
if (list === undefined) {
|
||||
list = [];
|
||||
directTraitUse.set(sourceDefId, list);
|
||||
}
|
||||
if (!list.includes(targetDefId)) list.push(targetDefId);
|
||||
}
|
||||
|
||||
// Step 4: augment every class's MRO by prepending the traits used by
|
||||
// any class in its ancestor chain (transitively closed). PHP semantics:
|
||||
// a trait used by a parent class is also visible on the child, and a
|
||||
// trait-using-trait chain is flattened to a single ancestor set.
|
||||
//
|
||||
// For each class, walk its (already-computed) EXTENDS-based MRO and
|
||||
// collect all transitively-used traits via BFS — `trait A { use B; }
|
||||
// trait B { use C; } class X { use A; }` must include C in X's MRO.
|
||||
// Prepend them before the EXTENDS ancestors so the method dispatch
|
||||
// index finds trait methods before falling back to the parent class
|
||||
// hierarchy.
|
||||
for (const [classDefId, extendsMro] of mro) {
|
||||
const ancestorChain = [classDefId, ...extendsMro];
|
||||
const seeds: string[] = [];
|
||||
for (const ancestorId of ancestorChain) {
|
||||
for (const traitId of directTraitUse.get(ancestorId) ?? []) {
|
||||
seeds.push(traitId);
|
||||
}
|
||||
}
|
||||
const allTraits = collectTransitiveTraits(seeds, directTraitUse);
|
||||
|
||||
if (allTraits.length > 0) {
|
||||
// Prepend traits before EXTENDS ancestors: own class's traits first,
|
||||
// then parent traits (in ancestor order). This ensures trait methods
|
||||
// are found before falling back to the inheritance chain.
|
||||
mro.set(classDefId, [...allTraits, ...extendsMro]);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: also insert Trait-only entries for classes that use traits
|
||||
// directly but have no EXTENDS parents (not in `mro` yet).
|
||||
for (const [classDefId, traits] of directTraitUse) {
|
||||
if (!mro.has(classDefId) && !traitDefIds.has(classDefId)) {
|
||||
// Class with no EXTENDS but with trait-use — add to MRO map.
|
||||
const allTraits = collectTransitiveTraits([...traits], directTraitUse);
|
||||
mro.set(classDefId, allTraits);
|
||||
}
|
||||
}
|
||||
|
||||
return mro;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the transitive closure of traits reachable from the seed set.
|
||||
* BFS over `directTraitUse` until fixpoint. The `seen` set guards against
|
||||
* cycles (invalid PHP but defensively handled) and prevents duplicate
|
||||
* entries when multiple seeds converge on the same trait. Insertion order
|
||||
* is preserved — first-seen wins for MRO ordering.
|
||||
*/
|
||||
function collectTransitiveTraits(
|
||||
seeds: readonly string[],
|
||||
directTraitUse: ReadonlyMap<string, readonly string[]>,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const queue: string[] = [...seeds];
|
||||
while (queue.length > 0) {
|
||||
const t = queue.shift()!;
|
||||
if (seen.has(t)) continue;
|
||||
seen.add(t);
|
||||
out.push(t);
|
||||
for (const next of directTraitUse.get(t) ?? []) {
|
||||
if (!seen.has(next)) queue.push(next);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit CALLS edges for PHP member-call sites whose receiver has no type
|
||||
* binding (e.g. `mixed`-typed parameters, untyped variables).
|
||||
*
|
||||
* PHP is dynamically typed: a parameter declared as `mixed` (or with no
|
||||
* type hint) cannot be resolved by the generic receiver-bound pass, which
|
||||
* requires a `TypeRef` in scope. This hook does a workspace-wide method
|
||||
* name lookup: when exactly one def in the workspace matches the called
|
||||
* method name, emit the CALLS edge.
|
||||
*
|
||||
* Only fires for sites that are NOT already in `handledSites` and whose
|
||||
* receiver has no type binding in the scope chain. Unique-name-match
|
||||
* constraint avoids false positives for common method names.
|
||||
*/
|
||||
function phpEmitUnresolvedReceiverEdges(
|
||||
graph: KnowledgeGraph,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
handledSites: Set<string>,
|
||||
model: SemanticModel,
|
||||
): number {
|
||||
let emitted = 0;
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const site of parsed.referenceSites) {
|
||||
if (site.kind !== 'call') continue;
|
||||
if (site.explicitReceiver === undefined) continue;
|
||||
|
||||
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
|
||||
if (handledSites.has(siteKey)) continue;
|
||||
|
||||
// Only proceed when the receiver has NO type binding — it's unresolvable
|
||||
// by the generic pass. This is the `mixed` / unannotated case.
|
||||
const typeRef = findReceiverTypeBinding(site.inScope, site.explicitReceiver.name, scopes);
|
||||
if (typeRef !== undefined) continue;
|
||||
|
||||
// Workspace-wide lookup: collect all methods matching the called name.
|
||||
// Filter out defs with no qualifiedName (legacy parse stubs without full
|
||||
// metadata) and deduplicate by nodeId so reconcileOwnership double-registration
|
||||
// doesn't inflate the count.
|
||||
const allCandidates = model.methods.lookupMethodByName(site.name);
|
||||
const seen2 = new Set<string>();
|
||||
const candidates = allCandidates.filter((c) => {
|
||||
if (c.qualifiedName === undefined) return false;
|
||||
if (seen2.has(c.nodeId)) return false;
|
||||
seen2.add(c.nodeId);
|
||||
return true;
|
||||
});
|
||||
if (candidates.length !== 1) continue; // ambiguous or missing — skip
|
||||
|
||||
const fnDef = candidates[0];
|
||||
if (fnDef === undefined) continue;
|
||||
|
||||
// Apply arity narrowing — a unique method name match is not enough
|
||||
// when arity says the call is definitively incompatible (e.g., PHP
|
||||
// f(int $req, ...$rest) called with zero args). This prevents the
|
||||
// fallback from emitting edges that the receiver-bound pass already
|
||||
// rejected for arity reasons.
|
||||
if (narrowOverloadCandidates([fnDef], site.arity, site.argumentTypes).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tighten the fallback further with an EXACT-required-arity gate
|
||||
// (Finding 8 / U4): the first-stage `narrowOverloadCandidates`
|
||||
// accepts any argCount in `min..max` (or `>= min` when variadic),
|
||||
// which over-emits 0.6-confidence edges for common method names
|
||||
// whose only workspace candidate has optional / defaulted params.
|
||||
// For the fallback path only, require argCount === required for
|
||||
// fixed-arity candidates. Variadic candidates keep the relaxed
|
||||
// `argCount >= required` semantics (already enforced by the first-
|
||||
// stage check, so no extra work here).
|
||||
const min = fnDef.requiredParameterCount;
|
||||
const hasVarArgs =
|
||||
fnDef.parameterTypes !== undefined &&
|
||||
fnDef.parameterTypes.some((t) => t === '...' || t.startsWith('...'));
|
||||
if (
|
||||
min !== undefined &&
|
||||
Number.isFinite(site.arity) &&
|
||||
site.arity >= 0 &&
|
||||
!hasVarArgs &&
|
||||
site.arity !== min
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
|
||||
if (callerGraphId === undefined) continue;
|
||||
const tgtGraphId = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup);
|
||||
if (tgtGraphId === undefined) continue;
|
||||
|
||||
handledSites.add(siteKey);
|
||||
const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`;
|
||||
if (seen.has(relId)) continue;
|
||||
seen.add(relId);
|
||||
graph.addRelationship({
|
||||
id: relId,
|
||||
sourceId: callerGraphId,
|
||||
targetId: tgtGraphId,
|
||||
type: 'CALLS',
|
||||
confidence: 0.6,
|
||||
reason: 'php-unresolved-receiver-fallback',
|
||||
});
|
||||
emitted++;
|
||||
}
|
||||
}
|
||||
return emitted;
|
||||
}
|
||||
|
||||
const phpScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.PHP,
|
||||
languageProvider: phpProvider,
|
||||
importEdgeReason: 'php-scope: use',
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
|
||||
resolvePhpImportTargetInternal(targetRaw, fromFile, allFilePaths, resolutionConfig),
|
||||
|
||||
loadResolutionConfig: (repoPath) => loadPhpComposerConfig(repoPath),
|
||||
|
||||
// PHP LEGB-like precedence: local > import/namespace/reexport > wildcard.
|
||||
// The per-scope id is unused by phpMergeBindings (tier ordering computed
|
||||
// purely from BindingRef.origin), so we don't synthesize a Scope.
|
||||
mergeBindings: (existing, incoming) => [...phpMergeBindings([...existing, ...incoming])],
|
||||
|
||||
// Adapter: phpArityCompatibility uses (def, callsite); the contract is (callsite, def).
|
||||
arityCompatibility: (callsite, def) => phpArityCompatibility(def, callsite),
|
||||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) => buildPhpMro(graph, parsedFiles, nodeLookup),
|
||||
|
||||
// PHP-specific: parent::method() must walk inheritance only, skipping
|
||||
// composed traits. See buildPhpExtendsOnlyMro and the super-branch use
|
||||
// in `passes/receiver-bound-calls.ts`.
|
||||
buildExtendsOnlyMro: (graph, parsedFiles, nodeLookup) =>
|
||||
buildPhpExtendsOnlyMro(graph, parsedFiles, nodeLookup),
|
||||
|
||||
// PHP free-call visibility: cross-namespace candidates are blocked
|
||||
// unless explicitly `use function`-imported by the caller. Prevents
|
||||
// false-positive CALLS edges between unrelated namespaces sharing a
|
||||
// function name. Same-namespace and global-namespace candidates pass
|
||||
// unchanged.
|
||||
isCallableVisibleFromCaller: phpIsCallableVisibleFromCaller,
|
||||
|
||||
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
|
||||
|
||||
// PHP same-namespace cross-file visibility — classes in the same
|
||||
// PHP namespace are visible without explicit `use` statements.
|
||||
// Mirrors C#'s `populateNamespaceSiblings`.
|
||||
populateNamespaceSiblings: populatePhpNamespaceSiblings,
|
||||
|
||||
// PHP uses `parent` for super-class dispatch (not `super()`).
|
||||
isSuperReceiver: (text) => text.trim() === 'parent',
|
||||
|
||||
// PHP is dynamically typed — field-fallback heuristic on so that
|
||||
// method calls on `mixed`-typed receivers (no annotation) fall back
|
||||
// to a workspace-wide name search rather than silently dropping the edge.
|
||||
fieldFallbackOnMethodLookup: true,
|
||||
|
||||
// PHP: allow free-call fallback to unique workspace-wide callable when
|
||||
// lexical/import bindings miss. Needed for two cases:
|
||||
// 1. `use function` imports where PSR-4 directory resolution is
|
||||
// non-deterministic (multiple .php files in same namespace dir).
|
||||
// 2. Unimported free calls within the same namespace (same-namespace
|
||||
// visibility without an explicit use statement, e.g. test fixtures).
|
||||
allowGlobalFreeCallFallback: true,
|
||||
|
||||
// Return-type propagation on — PHP method signatures are authoritative
|
||||
// enough for cross-file chain-follow.
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
|
||||
// PHP hoists method return-type bindings to the Module scope so
|
||||
// `propagateImportedReturnTypes` can pick them up across files.
|
||||
hoistTypeBindingsToModule: true,
|
||||
|
||||
// PHP recovers member calls on `mixed`/untyped receivers via a
|
||||
// workspace-wide unique-method-name lookup, mirroring the legacy DAG.
|
||||
emitUnresolvedReceiverEdges: phpEmitUnresolvedReceiverEdges,
|
||||
};
|
||||
|
||||
export { phpScopeResolver };
|
||||
134
gitnexus/src/core/ingestion/languages/php/simple-hooks.ts
Normal file
134
gitnexus/src/core/ingestion/languages/php/simple-hooks.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* Trivial / no-op-ish hooks for the PHP provider. Made explicit so
|
||||
* reviewers don't have to re-derive the analysis from "absence == default".
|
||||
*/
|
||||
|
||||
import type {
|
||||
CaptureMatch,
|
||||
ParsedImport,
|
||||
Scope,
|
||||
ScopeId,
|
||||
ScopeTree,
|
||||
TypeRef,
|
||||
} from 'gitnexus-shared';
|
||||
|
||||
// ─── bindingScopeFor ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* PHP method return-type bindings (`@type-binding.return`) must hoist
|
||||
* to the enclosing Module scope so `propagateImportedReturnTypes` can
|
||||
* mirror them across files. Without this hoist, the return binding gets
|
||||
* stuck at the Class scope and is invisible to the cross-file propagation
|
||||
* pass that reads only `sourceModule.typeBindings`.
|
||||
*
|
||||
* All other bindings delegate to the default "innermost scope" rule.
|
||||
*/
|
||||
export function phpBindingScopeFor(
|
||||
decl: CaptureMatch,
|
||||
innermost: Scope,
|
||||
tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
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;
|
||||
}
|
||||
|
||||
// Constructor-promoted properties (`function __construct(public User $u)`)
|
||||
// are declared inside the constructor's Function scope in the AST, but they
|
||||
// are class-owned fields. Hoist the @declaration.property binding to the
|
||||
// enclosing Class scope so `populateClassOwnedMembers` assigns the correct
|
||||
// ownerId and `findOwnedMember` can resolve `$obj->u`.
|
||||
if (decl['@declaration.property'] !== undefined && innermost.kind === 'Function') {
|
||||
let cur: Scope | undefined = innermost;
|
||||
while (cur !== undefined && cur.kind !== 'Class') {
|
||||
const parentId: ScopeId | null = cur.parent ?? null;
|
||||
if (parentId === null) break;
|
||||
cur = tree.getScope(parentId);
|
||||
}
|
||||
if (cur !== undefined && cur.kind === 'Class') return cur.id;
|
||||
}
|
||||
|
||||
// Constructor-promoted property TYPE BINDING (`function __construct(public Address $address)`)
|
||||
// produces both a @type-binding.parameter (stays in Function scope for `$address` lookups
|
||||
// inside the constructor body) AND a @type-binding.annotation (query.ts). The annotation
|
||||
// capture is emitted so this hoist branch can place `address → Address` in the CLASS scope.
|
||||
//
|
||||
// The compound-receiver resolver (`resolveCompoundReceiverClass`) reads typeBindings from
|
||||
// the class scope: `cs.typeBindings.get('address')`. Without hoisting, `$user->address->save()`
|
||||
// fails to resolve `address` because the type binding is in the constructor's Function scope.
|
||||
//
|
||||
// `@type-binding.annotation` for a promoted param appears with innermost = Function scope
|
||||
// (the constructor). Regular typed class properties (`private Address $addr;`) have their
|
||||
// annotation already in the Class scope, so this branch only fires for promoted params.
|
||||
if (decl['@type-binding.annotation'] !== undefined && innermost.kind === 'Function') {
|
||||
let cur: Scope | undefined = innermost;
|
||||
while (cur !== undefined && cur.kind !== 'Class') {
|
||||
const parentId: ScopeId | null = cur.parent ?? null;
|
||||
if (parentId === null) break;
|
||||
cur = tree.getScope(parentId);
|
||||
}
|
||||
if (cur !== undefined && cur.kind === 'Class') return cur.id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── importOwningScope ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Determine which scope owns a `use` import declaration.
|
||||
*
|
||||
* - `use` inside `namespace Foo { }` → attach to that Namespace scope.
|
||||
* - Top-level `use` (no enclosing namespace) → innermost (Module).
|
||||
* - `use TraitName;` inside a class body → this is a trait-use
|
||||
* (heritage), NOT a namespace import. The grammar emits
|
||||
* `use_declaration` for trait-use (distinct from
|
||||
* `namespace_use_declaration`). Our query only captures
|
||||
* `namespace_use_declaration`, so trait-use never reaches this hook
|
||||
* in practice. Returning `null` here is a safety fallback.
|
||||
*/
|
||||
export function phpImportOwningScope(
|
||||
_imp: ParsedImport,
|
||||
innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
// Namespace-scoped or module-scoped imports attach to the innermost scope
|
||||
// (either Namespace or Module). Class-scoped imports should not occur for
|
||||
// namespace_use_declaration; if they do, attach to the class scope.
|
||||
if (
|
||||
innermost.kind === 'Namespace' ||
|
||||
innermost.kind === 'Module' ||
|
||||
innermost.kind === 'Class' ||
|
||||
innermost.kind === 'Function'
|
||||
) {
|
||||
return innermost.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── receiverBinding ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Look up `$this` or `parent` in the function scope's type bindings.
|
||||
*
|
||||
* Both are synthesized as `@type-binding.self` captures during capture
|
||||
* emission (`receiver-binding.ts`) — `$this` for every non-static
|
||||
* method inside a class/trait/interface/enum body, `parent` additionally
|
||||
* for class methods with an explicit `base_clause`.
|
||||
*
|
||||
* Returns `null` for:
|
||||
* - static methods (no `$this` synthesized)
|
||||
* - free functions (no enclosing class)
|
||||
* - non-Function scopes
|
||||
*/
|
||||
export function phpReceiverBinding(functionScope: Scope): TypeRef | null {
|
||||
if (functionScope.kind !== 'Function') return null;
|
||||
return (
|
||||
functionScope.typeBindings.get('$this') ?? functionScope.typeBindings.get('parent') ?? null
|
||||
);
|
||||
}
|
||||
|
|
@ -36,7 +36,12 @@ export const processMarkdown = (
|
|||
// Skip if file node doesn't exist (shouldn't happen, structure-processor creates it)
|
||||
if (!graph.getNode(fileNodeId)) continue;
|
||||
|
||||
const lines = file.content.split('\n');
|
||||
// Normalize CRLF/CR to LF before splitting so that line-end agnostic
|
||||
// markdown files (Windows-authored, mixed) yield correct headings.
|
||||
// Without this, splitting on `\n` alone leaves `## Heading\r` on each line;
|
||||
// `$` in HEADING_RE only matches at end-of-string, while `.+` stops before
|
||||
// the trailing `\r`, so the line never matches as a heading.
|
||||
const lines = file.content.split(/\r\n|\r|\n/);
|
||||
|
||||
// --- Extract headings and build hierarchy ---
|
||||
// First pass: collect all heading positions so we can compute endLine spans
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -82,6 +83,89 @@ export interface WorkerExtractedData {
|
|||
// Worker-based parallel parsing
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Merge a list of `ParseWorkerResult`s into the running graph + symbol
|
||||
* table state and produce the chunk-aggregated `WorkerExtractedData`.
|
||||
*
|
||||
* Extracted from `processParsingWithWorkers` so the same merge logic can
|
||||
* be applied to both freshly-parsed worker output AND cached worker
|
||||
* output replayed during incremental analyze. Idempotent on the
|
||||
* accumulator fields (push-only); idempotent on graph if the caller
|
||||
* starts from a clean graph (otherwise duplicate `addNode` calls are
|
||||
* silently no-op'd by `KnowledgeGraph`).
|
||||
*/
|
||||
export const mergeChunkResults = (
|
||||
graph: KnowledgeGraph,
|
||||
symbolTable: SymbolTableWriter,
|
||||
chunkResults: readonly ParseWorkerResult[],
|
||||
): WorkerExtractedData => {
|
||||
const allImports: ExtractedImport[] = [];
|
||||
const allCalls: ExtractedCall[] = [];
|
||||
const allAssignments: ExtractedAssignment[] = [];
|
||||
const allHeritage: ExtractedHeritage[] = [];
|
||||
const allRoutes: ExtractedRoute[] = [];
|
||||
const allFetchCalls: ExtractedFetchCall[] = [];
|
||||
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
|
||||
const allToolDefs: ExtractedToolDef[] = [];
|
||||
const allORMQueries: ExtractedORMQuery[] = [];
|
||||
const allConstructorBindings: FileConstructorBindings[] = [];
|
||||
const fileScopeBindingsByFile: FileScopeBindings[] = [];
|
||||
const allParsedFiles: ParsedFile[] = [];
|
||||
|
||||
for (const result of chunkResults) {
|
||||
for (const node of result.nodes) {
|
||||
graph.addNode({
|
||||
id: node.id,
|
||||
label: node.label as NodeLabel,
|
||||
properties: node.properties,
|
||||
});
|
||||
}
|
||||
for (const rel of result.relationships) {
|
||||
graph.addRelationship(rel);
|
||||
}
|
||||
for (const sym of result.symbols) {
|
||||
symbolTable.add(sym.filePath, sym.name, sym.nodeId, sym.type, {
|
||||
parameterCount: sym.parameterCount,
|
||||
requiredParameterCount: sym.requiredParameterCount,
|
||||
parameterTypes: sym.parameterTypes,
|
||||
returnType: sym.returnType,
|
||||
declaredType: sym.declaredType,
|
||||
templateArguments: sym.templateArguments,
|
||||
ownerId: sym.ownerId,
|
||||
qualifiedName: sym.qualifiedName,
|
||||
});
|
||||
}
|
||||
for (const item of result.imports) allImports.push(item);
|
||||
for (const item of result.calls) allCalls.push(item);
|
||||
for (const item of result.assignments) allAssignments.push(item);
|
||||
for (const item of result.heritage) allHeritage.push(item);
|
||||
for (const item of result.routes) allRoutes.push(item);
|
||||
for (const item of result.fetchCalls) allFetchCalls.push(item);
|
||||
for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item);
|
||||
for (const item of result.toolDefs) allToolDefs.push(item);
|
||||
if (result.ormQueries) for (const item of result.ormQueries) allORMQueries.push(item);
|
||||
for (const item of result.constructorBindings) allConstructorBindings.push(item);
|
||||
if (result.fileScopeBindings)
|
||||
for (const item of result.fileScopeBindings) fileScopeBindingsByFile.push(item);
|
||||
if (result.parsedFiles) for (const item of result.parsedFiles) allParsedFiles.push(item);
|
||||
}
|
||||
|
||||
return {
|
||||
imports: allImports,
|
||||
calls: allCalls,
|
||||
assignments: allAssignments,
|
||||
heritage: allHeritage,
|
||||
routes: allRoutes,
|
||||
fetchCalls: allFetchCalls,
|
||||
decoratorRoutes: allDecoratorRoutes,
|
||||
toolDefs: allToolDefs,
|
||||
ormQueries: allORMQueries,
|
||||
constructorBindings: allConstructorBindings,
|
||||
fileScopeBindings: fileScopeBindingsByFile,
|
||||
parsedFiles: allParsedFiles,
|
||||
};
|
||||
};
|
||||
|
||||
const processParsingWithWorkers = async (
|
||||
graph: KnowledgeGraph,
|
||||
files: { path: string; content: string }[],
|
||||
|
|
@ -89,6 +173,14 @@ const processParsingWithWorkers = async (
|
|||
astCache: ASTCache,
|
||||
workerPool: WorkerPool,
|
||||
onFileProgress?: FileProgressCallback,
|
||||
/**
|
||||
* When provided, populated with the raw worker results before merging.
|
||||
* Used by the incremental-indexing parse cache to capture the per-chunk
|
||||
* worker output for caching across runs. The mutation happens in-place
|
||||
* so the caller (parse-impl) can keep a reference. See
|
||||
* `gitnexus/src/storage/parse-cache.ts`.
|
||||
*/
|
||||
outRawResults?: ParseWorkerResult[],
|
||||
): Promise<WorkerExtractedData> => {
|
||||
// Filter to parseable files only
|
||||
const parseableFiles: ParseWorkerInput[] = [];
|
||||
|
|
@ -123,63 +215,16 @@ const processParsingWithWorkers = async (
|
|||
},
|
||||
);
|
||||
|
||||
// Merge results from all workers into graph and symbol table
|
||||
const allImports: ExtractedImport[] = [];
|
||||
const allCalls: ExtractedCall[] = [];
|
||||
const allAssignments: ExtractedAssignment[] = [];
|
||||
const allHeritage: ExtractedHeritage[] = [];
|
||||
const allRoutes: ExtractedRoute[] = [];
|
||||
const allFetchCalls: ExtractedFetchCall[] = [];
|
||||
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
|
||||
const allToolDefs: ExtractedToolDef[] = [];
|
||||
const allORMQueries: ExtractedORMQuery[] = [];
|
||||
const allConstructorBindings: FileConstructorBindings[] = [];
|
||||
const fileScopeBindingsByFile: FileScopeBindings[] = [];
|
||||
const allParsedFiles: ParsedFile[] = [];
|
||||
for (const result of chunkResults) {
|
||||
for (const node of result.nodes) {
|
||||
graph.addNode({
|
||||
id: node.id,
|
||||
label: node.label as NodeLabel,
|
||||
properties: node.properties,
|
||||
});
|
||||
}
|
||||
|
||||
for (const rel of result.relationships) {
|
||||
graph.addRelationship(rel);
|
||||
}
|
||||
|
||||
for (const sym of result.symbols) {
|
||||
symbolTable.add(sym.filePath, sym.name, sym.nodeId, sym.type, {
|
||||
parameterCount: sym.parameterCount,
|
||||
requiredParameterCount: sym.requiredParameterCount,
|
||||
parameterTypes: sym.parameterTypes,
|
||||
returnType: sym.returnType,
|
||||
declaredType: sym.declaredType,
|
||||
ownerId: sym.ownerId,
|
||||
qualifiedName: sym.qualifiedName,
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of result.imports) allImports.push(item);
|
||||
for (const item of result.calls) allCalls.push(item);
|
||||
for (const item of result.assignments) allAssignments.push(item);
|
||||
for (const item of result.heritage) allHeritage.push(item);
|
||||
for (const item of result.routes) allRoutes.push(item);
|
||||
for (const item of result.fetchCalls) allFetchCalls.push(item);
|
||||
for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item);
|
||||
for (const item of result.toolDefs) allToolDefs.push(item);
|
||||
if (result.ormQueries) for (const item of result.ormQueries) allORMQueries.push(item);
|
||||
for (const item of result.constructorBindings) allConstructorBindings.push(item);
|
||||
if (result.fileScopeBindings)
|
||||
for (const item of result.fileScopeBindings) fileScopeBindingsByFile.push(item);
|
||||
// RFC #909 Ring 2: aggregate per-file scope artifacts. Tolerant of
|
||||
// workers that don't emit the field yet (older worker builds or
|
||||
// partial rollouts), since the additive contract means undefined =
|
||||
// "this worker produced no ParsedFiles for this chunk".
|
||||
if (result.parsedFiles) for (const item of result.parsedFiles) allParsedFiles.push(item);
|
||||
// Capture the raw chunk results for the incremental parse cache before
|
||||
// merging — the cache stores the unmerged worker output so a future run
|
||||
// can re-merge them into a fresh graph state.
|
||||
if (outRawResults) {
|
||||
for (const r of chunkResults) outRawResults.push(r);
|
||||
}
|
||||
|
||||
// Merge results from all workers into graph and symbol table.
|
||||
const merged = mergeChunkResults(graph, symbolTable, chunkResults);
|
||||
|
||||
// Merge and log skipped languages from workers
|
||||
const skippedLanguages = new Map<string, number>();
|
||||
for (const result of chunkResults) {
|
||||
|
|
@ -196,20 +241,7 @@ const processParsingWithWorkers = async (
|
|||
|
||||
// Final progress
|
||||
onFileProgress?.(total, total, 'done');
|
||||
return {
|
||||
imports: allImports,
|
||||
calls: allCalls,
|
||||
assignments: allAssignments,
|
||||
heritage: allHeritage,
|
||||
routes: allRoutes,
|
||||
fetchCalls: allFetchCalls,
|
||||
decoratorRoutes: allDecoratorRoutes,
|
||||
toolDefs: allToolDefs,
|
||||
ormQueries: allORMQueries,
|
||||
constructorBindings: allConstructorBindings,
|
||||
fileScopeBindings: fileScopeBindingsByFile,
|
||||
parsedFiles: allParsedFiles,
|
||||
};
|
||||
return merged;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -453,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');
|
||||
|
|
@ -580,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 ??
|
||||
|
|
@ -613,6 +686,9 @@ const processParsingSequential = async (
|
|||
nodeName,
|
||||
),
|
||||
...(qualifiedTypeName !== undefined ? { qualifiedName: qualifiedTypeName } : {}),
|
||||
...(classTemplateArguments !== undefined && classTemplateArguments.length > 0
|
||||
? { templateArguments: classTemplateArguments }
|
||||
: {}),
|
||||
...(frameworkHint
|
||||
? {
|
||||
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
|
||||
|
|
@ -670,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,
|
||||
});
|
||||
|
|
@ -732,6 +809,14 @@ export const processParsing = async (
|
|||
scopeTreeCache: ASTCache | undefined,
|
||||
onFileProgress?: FileProgressCallback,
|
||||
workerPool?: WorkerPool,
|
||||
/**
|
||||
* Optional out-parameter for the incremental parse cache. When
|
||||
* provided AND the worker-pool path runs successfully, populated
|
||||
* with the raw `ParseWorkerResult[]` from the workers (pre-merge).
|
||||
* Stays empty for the sequential fallback path (no per-chunk
|
||||
* artifact to cache there). See `gitnexus/src/storage/parse-cache.ts`.
|
||||
*/
|
||||
outRawResults?: ParseWorkerResult[],
|
||||
): Promise<WorkerExtractedData | null> => {
|
||||
let lastProgress = 0;
|
||||
const reportProgress: FileProgressCallback | undefined = onFileProgress
|
||||
|
|
@ -759,6 +844,7 @@ export const processParsing = async (
|
|||
astCache,
|
||||
workerPool,
|
||||
reportProgress,
|
||||
outRawResults,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ import {
|
|||
enrichExportedTypeMap,
|
||||
type BindingEntry,
|
||||
} from '../binding-accumulator.js';
|
||||
import { processParsing } from '../parsing-processor.js';
|
||||
import { processParsing, mergeChunkResults } from '../parsing-processor.js';
|
||||
import { fileContentHash, computeChunkHash } from '../../../storage/parse-cache.js';
|
||||
import type { ParseWorkerResult } from '../workers/parse-worker.js';
|
||||
import type { WorkerExtractedData } from '../parsing-processor.js';
|
||||
import {
|
||||
processImports,
|
||||
processImportsFromExtracted,
|
||||
|
|
@ -72,8 +75,21 @@ import { extractORMQueriesInline } from './orm-extraction.js';
|
|||
import { logger } from '../../logger.js';
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Max bytes of source content to load per parse chunk. */
|
||||
const CHUNK_BYTE_BUDGET = 20 * 1024 * 1024; // 20MB
|
||||
/** Max bytes of source content to load per parse chunk.
|
||||
*
|
||||
* Memory bound for the worker pool dispatch + a granularity knob for
|
||||
* the parse cache. A single file change invalidates only its enclosing
|
||||
* chunk, so smaller budgets → finer-grained invalidation.
|
||||
*
|
||||
* Override via GITNEXUS_CHUNK_BYTE_BUDGET (bytes) — the default of 2MB
|
||||
* gives a useful invalidation floor (~1/N chunks on a multi-MB repo)
|
||||
* while keeping worker dispatch overhead under 5% on cold runs.
|
||||
*/
|
||||
const CHUNK_BYTE_BUDGET = (() => {
|
||||
const env = Number(process.env.GITNEXUS_CHUNK_BYTE_BUDGET);
|
||||
if (Number.isFinite(env) && env > 0) return env;
|
||||
return 2 * 1024 * 1024;
|
||||
})();
|
||||
|
||||
// ── Main parse + resolve function ──────────────────────────────────────────
|
||||
|
||||
|
|
@ -119,6 +135,11 @@ export async function runChunkedParseAndResolve(
|
|||
* source. See plan
|
||||
* docs/plans/2026-04-20-002-perf-parse-heritage-mro-plan.md (Unit 4). */
|
||||
scopeTreeCache: ASTCache;
|
||||
/** Worker-produced ParsedFile artifacts aggregated across chunks.
|
||||
* Threaded into scope-resolution as a re-extract cache so the warm-
|
||||
* cache analyze run can skip the dominant `extractParsedFile` cost
|
||||
* (otherwise ~58s on a 1000-file repo). */
|
||||
parsedFiles: import('gitnexus-shared').ParsedFile[];
|
||||
}> {
|
||||
const ctx = createResolutionContext();
|
||||
const symbolTable = ctx.model.symbols;
|
||||
|
|
@ -142,6 +163,15 @@ export async function runChunkedParseAndResolve(
|
|||
);
|
||||
}
|
||||
|
||||
// Sort parseableScanned alphabetically for stable chunk membership
|
||||
// across runs (Finding 4). Without this, filesystem-scan order can
|
||||
// shift between runs (notably on macOS APFS where directory entry
|
||||
// order can change after modifications) — different files in the
|
||||
// same chunk → different chunk hash → cache miss even when no file
|
||||
// content changed. The cache also becomes platform-specific: a
|
||||
// Linux-built cache misses on macOS for the same repo.
|
||||
parseableScanned.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
||||
|
||||
const totalParseable = parseableScanned.length;
|
||||
|
||||
if (totalParseable === 0) {
|
||||
|
|
@ -271,6 +301,20 @@ export async function runChunkedParseAndResolve(
|
|||
const deferredWorkerHeritage: ExtractedHeritage[] = [];
|
||||
const deferredConstructorBindings: FileConstructorBindings[] = [];
|
||||
const deferredAssignments: ExtractedAssignment[] = [];
|
||||
// Aggregated per-file ParsedFile artifacts produced by workers' calls
|
||||
// to `extractParsedFile`. Threaded through to the scope-resolution
|
||||
// phase so it can SKIP its own re-extraction on cache hits — this is
|
||||
// the second-half of the parse-cache speedup since scope-resolution's
|
||||
// re-parse otherwise dominates the warm-cache wall-clock time.
|
||||
const allParsedFiles: import('gitnexus-shared').ParsedFile[] = [];
|
||||
|
||||
// Incremental parse cache (Option B): chunk-level content-addressed.
|
||||
// When the chunk's (filePath, content-hash) signature matches a prior
|
||||
// run's, replay the cached ParseWorkerResult[] instead of dispatching
|
||||
// to workers. See gitnexus/src/storage/parse-cache.ts.
|
||||
const parseCache = options?.parseCache;
|
||||
let chunkCacheHits = 0;
|
||||
let chunkCacheMisses = 0;
|
||||
|
||||
try {
|
||||
for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) {
|
||||
|
|
@ -281,29 +325,89 @@ export async function runChunkedParseAndResolve(
|
|||
.filter((p) => chunkContents.has(p))
|
||||
.map((p) => ({ path: p, content: chunkContents.get(p)! }));
|
||||
|
||||
const chunkWorkerData = await processParsing(
|
||||
graph,
|
||||
chunkFiles,
|
||||
symbolTable,
|
||||
astCache,
|
||||
scopeTreeCache,
|
||||
(current, _total, filePath) => {
|
||||
const globalCurrent = filesParsedSoFar + current;
|
||||
const parsingProgress = 20 + (globalCurrent / totalParseable) * 62;
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: Math.round(parsingProgress),
|
||||
message: `Parsing chunk ${chunkIdx + 1}/${numChunks}...`,
|
||||
detail: filePath,
|
||||
stats: {
|
||||
filesProcessed: globalCurrent,
|
||||
totalFiles: totalParseable,
|
||||
nodesCreated: graph.nodeCount,
|
||||
},
|
||||
});
|
||||
},
|
||||
workerPool,
|
||||
);
|
||||
// Compute the chunk's content-hash signature (if cache available).
|
||||
let chunkHash: string | null = null;
|
||||
if (parseCache) {
|
||||
const entries = chunkFiles.map((f) => ({
|
||||
filePath: f.path,
|
||||
contentHash: fileContentHash(f.content),
|
||||
}));
|
||||
chunkHash = computeChunkHash(entries);
|
||||
}
|
||||
|
||||
let chunkWorkerData: WorkerExtractedData | null;
|
||||
const cachedRaw = chunkHash ? parseCache!.entries.get(chunkHash) : undefined;
|
||||
|
||||
// Track every chunk hash we touched so the orchestrator can
|
||||
// prune stale entries (chunks whose composition no longer
|
||||
// corresponds to a live chunk in the current scan) before saving.
|
||||
if (parseCache && chunkHash) parseCache.usedKeys.add(chunkHash);
|
||||
|
||||
if (cachedRaw && cachedRaw.length > 0) {
|
||||
// Cache hit: replay the cached worker output through the same
|
||||
// merge logic the live worker path uses.
|
||||
chunkCacheHits++;
|
||||
chunkWorkerData = mergeChunkResults(graph, symbolTable, cachedRaw);
|
||||
if (isDev) {
|
||||
logger.info(
|
||||
`📦 parse-cache HIT: chunk ${chunkIdx + 1}/${numChunks} (${chunkFiles.length} files, ${chunkHash!.slice(0, 8)})`,
|
||||
);
|
||||
}
|
||||
// Progress update so UI advances even on a cache hit.
|
||||
const cachedFiles = chunkFiles.length;
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: Math.round(20 + ((filesParsedSoFar + cachedFiles) / totalParseable) * 62),
|
||||
message: `Parsing chunk ${chunkIdx + 1}/${numChunks} (cache)...`,
|
||||
stats: {
|
||||
filesProcessed: filesParsedSoFar + cachedFiles,
|
||||
totalFiles: totalParseable,
|
||||
nodesCreated: graph.nodeCount,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Cache miss: dispatch to workers, capture the raw results, store
|
||||
// them under the chunk hash for the next run.
|
||||
chunkCacheMisses++;
|
||||
const rawResults: ParseWorkerResult[] = [];
|
||||
chunkWorkerData = await processParsing(
|
||||
graph,
|
||||
chunkFiles,
|
||||
symbolTable,
|
||||
astCache,
|
||||
scopeTreeCache,
|
||||
(current, _total, filePath) => {
|
||||
const globalCurrent = filesParsedSoFar + current;
|
||||
const parsingProgress = 20 + (globalCurrent / totalParseable) * 62;
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: Math.round(parsingProgress),
|
||||
message: `Parsing chunk ${chunkIdx + 1}/${numChunks}...`,
|
||||
detail: filePath,
|
||||
stats: {
|
||||
filesProcessed: globalCurrent,
|
||||
totalFiles: totalParseable,
|
||||
nodesCreated: graph.nodeCount,
|
||||
},
|
||||
});
|
||||
},
|
||||
workerPool,
|
||||
// Capture raw results only when we have a cache to write to —
|
||||
// otherwise we'd retain extra arrays for nothing.
|
||||
parseCache && chunkHash ? rawResults : undefined,
|
||||
);
|
||||
// Persist the raw results for this chunk hash. Sequential path
|
||||
// doesn't populate rawResults (it writes directly to graph), so
|
||||
// small repos without worker pool simply don't cache. That's fine.
|
||||
if (parseCache && chunkHash && rawResults.length > 0) {
|
||||
parseCache.entries.set(chunkHash, rawResults);
|
||||
if (isDev) {
|
||||
logger.info(
|
||||
`📦 parse-cache MISS+store: chunk ${chunkIdx + 1}/${numChunks} (${chunkFiles.length} files, ${chunkHash.slice(0, 8)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chunkBasePercent = 20 + (filesParsedSoFar / totalParseable) * 62;
|
||||
|
||||
|
|
@ -349,6 +453,12 @@ export async function runChunkedParseAndResolve(
|
|||
for (const item of chunkWorkerData.heritage) deferredWorkerHeritage.push(item);
|
||||
for (const item of chunkWorkerData.constructorBindings)
|
||||
deferredConstructorBindings.push(item);
|
||||
// Aggregate worker-produced ParsedFile artifacts so scope-
|
||||
// resolution can use them as a re-extraction cache (skips its
|
||||
// own tree-sitter re-parse on warm runs).
|
||||
if (chunkWorkerData.parsedFiles?.length) {
|
||||
for (const item of chunkWorkerData.parsedFiles) allParsedFiles.push(item);
|
||||
}
|
||||
if (chunkWorkerData.assignments?.length) {
|
||||
for (const item of chunkWorkerData.assignments) deferredAssignments.push(item);
|
||||
}
|
||||
|
|
@ -422,6 +532,12 @@ export async function runChunkedParseAndResolve(
|
|||
astCache.clear();
|
||||
}
|
||||
|
||||
if (isDev && parseCache && (chunkCacheHits > 0 || chunkCacheMisses > 0)) {
|
||||
logger.info(
|
||||
`📦 parse-cache summary: ${chunkCacheHits} chunk hit(s), ${chunkCacheMisses} miss(es) across ${numChunks} chunk(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
const fullWorkerHeritageMap =
|
||||
deferredWorkerHeritage.length > 0
|
||||
? buildHeritageMap(deferredWorkerHeritage, ctx, getHeritageStrategyForLanguage)
|
||||
|
|
@ -621,5 +737,12 @@ export async function runChunkedParseAndResolve(
|
|||
// chunk-local `astCache` above is intentionally NOT exposed
|
||||
// because parse-impl clears it between chunks.
|
||||
scopeTreeCache,
|
||||
// Per-file ParsedFile artifacts produced by workers' calls to
|
||||
// `extractParsedFile`. Empty when only the sequential path ran
|
||||
// (sequential doesn't go through the worker, and extracts ParsedFile
|
||||
// inline rather than emitting it). Consumed by scope-resolution as
|
||||
// a re-extraction cache: when the file's ParsedFile is here,
|
||||
// scope-resolution skips its own `extractParsedFile` call.
|
||||
parsedFiles: allParsedFiles,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
|
|||
import { getPhaseOutput } from './types.js';
|
||||
import type { StructureOutput } from './structure.js';
|
||||
import type { BindingAccumulator } from '../binding-accumulator.js';
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import type {
|
||||
ExtractedFetchCall,
|
||||
ExtractedRoute,
|
||||
|
|
@ -81,6 +82,19 @@ export interface ParseOutput {
|
|||
* `scopeTreeCache.clear()` after its extract loop finishes.
|
||||
*/
|
||||
readonly scopeTreeCache: ASTCache;
|
||||
/**
|
||||
* Per-file `ParsedFile` artifacts produced by workers' calls to
|
||||
* `extractParsedFile`. Threaded through to `scopeResolutionPhase`
|
||||
* as a re-extraction cache: when a file's ParsedFile is present here,
|
||||
* scope-resolution can skip its own `extractParsedFile` (which would
|
||||
* otherwise re-parse the file with tree-sitter on the main thread,
|
||||
* costing ~58s on a 1000-file repo).
|
||||
*
|
||||
* Empty for files that went through the sequential parse fallback —
|
||||
* sequential doesn't emit ParsedFile artifacts; scope-resolution
|
||||
* falls back to a fresh extract for those.
|
||||
*/
|
||||
readonly parsedFiles: readonly ParsedFile[];
|
||||
}
|
||||
|
||||
export const parsePhase: PipelinePhase<ParseOutput> = {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,19 @@ export interface PipelineOptions {
|
|||
minFiles?: number;
|
||||
minBytes?: number;
|
||||
};
|
||||
/**
|
||||
* Incremental-indexing parse cache. When provided:
|
||||
* - The parse phase looks up each chunk's content hash in
|
||||
* `parseCache.entries`. On hit, it replays the cached
|
||||
* `ParseWorkerResult[]` instead of dispatching to workers.
|
||||
* - On miss, it runs the workers as today and stores the new
|
||||
* results in `parseCache.entries` keyed by chunk hash.
|
||||
* The caller (`run-analyze.ts`) is responsible for loading the cache
|
||||
* before the pipeline runs and persisting it after. Cache survives
|
||||
* `--force` because keys are content-addressed.
|
||||
* See `gitnexus/src/storage/parse-cache.ts`.
|
||||
*/
|
||||
parseCache?: import('../../storage/parse-cache.js').ParseCache;
|
||||
}
|
||||
|
||||
// ── Phase registry ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
|
|||
SupportedLanguages.TypeScript,
|
||||
SupportedLanguages.Go,
|
||||
SupportedLanguages.C,
|
||||
SupportedLanguages.CPlusPlus,
|
||||
SupportedLanguages.PHP,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -386,6 +389,26 @@ export interface ScopeResolver {
|
|||
nodeLookup: GraphNodeLookup,
|
||||
): Map<string /* DefId */, string[] /* ancestor DefIds */>;
|
||||
|
||||
/**
|
||||
* Optional parallel MRO that EXCLUDES mixin-like augmentation (e.g., PHP
|
||||
* traits). Returns the inheritance-only ancestor chain — the same kind
|
||||
* of map as `buildMro` but built only from inheritance edges (EXTENDS).
|
||||
*
|
||||
* Used by the shared super-branch dispatch in `receiver-bound-calls`
|
||||
* so that `parent::method()` walks the inheritance chain only, not the
|
||||
* trait-augmented one. PHP semantics: `parent::` explicitly bypasses
|
||||
* traits, even when a composed trait shadows a same-named parent method.
|
||||
*
|
||||
* Languages without mixin-like semantics leave this undefined — callers
|
||||
* fall back to `buildMro`/`mroFor`, which for those languages is already
|
||||
* the inheritance chain.
|
||||
*/
|
||||
readonly buildExtendsOnlyMro?: (
|
||||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
) => Map<string /* DefId */, string[] /* ancestor DefIds */>;
|
||||
|
||||
/**
|
||||
* Mutate `parsed.localDefs[i].ownerId` to point at the structural
|
||||
* owner. Python's rule: methods (Function defs whose parent scope
|
||||
|
|
@ -412,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 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -484,6 +545,100 @@ export interface ScopeResolver {
|
|||
*/
|
||||
readonly isFileLocalDef?: (def: SymbolDefinition) => boolean;
|
||||
|
||||
/**
|
||||
* Optional predicate to gate free-call fallback emission by caller-side
|
||||
* visibility. When provided, `pickUniqueGlobalCallable` rejects candidates
|
||||
* the caller cannot legally reach — e.g., a PHP function in a different
|
||||
* namespace with no `use function` import, which PHP runtime would treat
|
||||
* as `Call to undefined function`. Returning `false` blocks the candidate;
|
||||
* returning `true` allows it; undefined-default keeps current behavior
|
||||
* (no visibility filtering, equivalent to "all candidates visible").
|
||||
*
|
||||
* The hook receives the caller's `ParsedFile` (so it can consult
|
||||
* `parsedImports`, `moduleScope`, etc.) and the candidate `SymbolDefinition`.
|
||||
* The predicate must be pure: same inputs → same answer.
|
||||
*
|
||||
* Languages without namespace-scoped function resolution leave this undefined.
|
||||
*/
|
||||
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
|
||||
|
|
@ -576,4 +731,32 @@ export interface ScopeResolver {
|
|||
readonly treeCache?: { get(filePath: string): unknown };
|
||||
},
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Optional post-resolution pass: emit CALLS edges for member-call sites
|
||||
* whose receiver cannot be typed by the scope chain (no `TypeRef`).
|
||||
* Dynamically-typed languages with untyped/`mixed`/`Any` parameters use
|
||||
* this hook to recover the call edge via workspace-wide method-name
|
||||
* lookup, mirroring what their legacy resolvers did.
|
||||
*
|
||||
* Runs AFTER `emitReceiverBoundCalls` and BEFORE `emitFreeCallFallback`.
|
||||
* Implementations MUST:
|
||||
* - Skip sites already in `handledSites` (Invariant I2).
|
||||
* - Add resolved site keys to `handledSites` before returning.
|
||||
* - Stay narrow: a unique workspace-wide match is the safe baseline.
|
||||
* Multi-candidate fallbacks should narrow by arity / argument types
|
||||
* before emitting to keep false-positive rate bounded.
|
||||
*
|
||||
* Returns the number of edges emitted (for telemetry).
|
||||
*
|
||||
* Default: undefined (no unresolved-receiver fallback).
|
||||
*/
|
||||
readonly emitUnresolvedReceiverEdges?: (
|
||||
graph: KnowledgeGraph,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
handledSites: Set<string>,
|
||||
model: SemanticModel,
|
||||
) => number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,9 @@ const EMPTY_DEFS: readonly string[] = Object.freeze([]);
|
|||
|
||||
export function buildPopulatedMethodDispatch(
|
||||
mroByDefId: ReadonlyMap<string, readonly string[]>,
|
||||
extendsOnlyMroByDefId?: ReadonlyMap<string, readonly string[]>,
|
||||
): MethodDispatchIndex {
|
||||
return {
|
||||
const base: MethodDispatchIndex = {
|
||||
mroByOwnerDefId: mroByDefId,
|
||||
implsByInterfaceDefId: new Map(),
|
||||
mroFor(ownerDefId) {
|
||||
|
|
@ -33,4 +34,14 @@ export function buildPopulatedMethodDispatch(
|
|||
return EMPTY_DEFS;
|
||||
},
|
||||
};
|
||||
if (extendsOnlyMroByDefId !== undefined) {
|
||||
return {
|
||||
...base,
|
||||
extendsOnlyMroByOwnerDefId: extendsOnlyMroByDefId,
|
||||
extendsOnlyMroFor(ownerDefId) {
|
||||
return extendsOnlyMroByDefId.get(ownerDefId) ?? EMPTY_DEFS;
|
||||
},
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue