From a839d029acf29a98f02acae971ec468ed89f42f7 Mon Sep 17 00:00:00 2001 From: Ankit Verma Date: Wed, 9 Sep 2026 21:58:45 +0530 Subject: [PATCH 01/33] feat(api): report branch and index freshness on the serve repo routes (#3232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): report branch and index freshness on the serve repo routes `GET /api/repos` and `GET /api/repo` now return the branch an index was built from, its `lastCommit`, and — where it can be computed — how far behind the working tree it is. All of it already existed: the fields are on `RegistryEntry`, and `checkStalenessAsync` is the helper MCP `list_repos` and `gitnexus status` already use. Only HTTP never asked. #3199 made this pointed. A branch-pinned analyze registers its own entry, so one repository yields two rows in /api/repos, and telling them apart over HTTP meant pattern-matching the clone-directory suffix — a layout detail that is trimmed for long refs and absent for path-registered repos. Staleness is reported in the shape `list_repos` already returns: present only when the index is behind, carrying commitsBehind and hint. It is meaningful for path-registered repos; a url-registered repo is cloned --depth 1, so its recorded commit is HEAD and a diverged history cannot be walked by rev-list anyway. Documented in repo-projection.ts rather than left to be discovered. The projections live in their own module so the field list is assertable. Route-inline, they were reachable only by booting a server, which is how `branch` stayed unexposed while `gitnexus list` printed it. /api/repos also gains the rate limiter it lacked: this change makes one unauthenticated GET cost a `git rev-list` per registered repo, the shape this codebase already limits elsewhere. Co-Authored-By: Claude Opus 5 * fix(api): bound the staleness probe and keep liveness off the fan-out route Addresses the tri-review on #3232. P1. `checkStalenessAsync` caught every git ERROR but not a HANG — a working tree on a disconnected mount or behind a stuck lock never settles, and /api/repos fans that out once per registered repo. Worse, the web liveness probe used /api/repos on a 2s budget and re-polls on failure, so each failed probe stacked another N children on a server that was actually healthy. Two independent fixes, because either alone still leaves a sharp edge: - `execFileAsync` now carries a 5s timeout, so a hang is killed and routed into the same fail-closed "not stale" answer the existing catch already gives a bad SHA. This also protects MCP `list_repos`, which shares the helper. - `probeBackendStatus` (and `isDatabaseReady` through it) asks /api/health instead. Liveness should not cost one subprocess per indexed repo. Health sits behind the same /api/* edge gate, so the 401 "gated vs absent" distinction is preserved. P2. rate-limit.test.ts pins which routes carry a limiter so a dropped one fails a test before CodeQL has to catch it; /api/repos was newly limited but not pinned. Added, and verified it fails when the limiter is removed. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 Co-authored-by: Gergő Magyar --- gitnexus-web/src/services/backend-client.ts | 24 +- gitnexus/src/core/git-staleness.ts | 15 ++ gitnexus/src/server/api.ts | 34 +-- gitnexus/src/server/repo-projection.ts | 81 +++++++ .../integration/server-repo-freshness.test.ts | 223 ++++++++++++++++++ gitnexus/test/unit/rate-limit.test.ts | 8 + gitnexus/test/unit/repo-projection.test.ts | 165 +++++++++++++ 7 files changed, 534 insertions(+), 16 deletions(-) create mode 100644 gitnexus/src/server/repo-projection.ts create mode 100644 gitnexus/test/integration/server-repo-freshness.test.ts create mode 100644 gitnexus/test/unit/repo-projection.test.ts diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts index 50b9f2683..1cf1abde2 100644 --- a/gitnexus-web/src/services/backend-client.ts +++ b/gitnexus-web/src/services/backend-client.ts @@ -23,6 +23,21 @@ export interface BackendRepo { repoPath?: string; // git HEAD returns "repoPath"; older versions return "path" indexedAt: string; lastCommit?: string; + /** + * Branch this index was built from. Absent on legacy entries and non-git + * repos. Since #3199 a branch-pinned analyze registers its own entry, so this + * is what tells two entries for the same repository apart — the name is + * derived from the clone directory and is not a contract. + */ + branch?: string; + /** Non-primary branch indexes recorded for the same path. */ + branches?: Array<{ branch: string; indexedAt?: string; lastCommit?: string }>; + /** + * Present only when the index is behind the repo's checked-out HEAD; absent + * means either up to date or not answerable (see the server's + * `repo-projection.ts`). Same shape MCP `list_repos` returns. + */ + staleness?: { commitsBehind: number; hint?: string }; stats?: { files?: number; nodes?: number; @@ -663,7 +678,14 @@ export type BackendProbeStatus = 'ok' | 'unauthorized' | 'unreachable'; */ export const probeBackendStatus = async (): Promise => { try { - const response = await fetchWithTimeout(`${_backendUrl}/api/repos`, {}, PROBE_TIMEOUT_MS); + // `/api/health` rather than `/api/repos`: this is a liveness question on a + // 2s budget, and `/api/repos` now spawns a `git rev-list` per registered + // repo to answer freshness. Probing it made the cost of "is the server up?" + // scale with the number of indexed repos, and a failed probe re-polls, + // stacking more children on the way (#3232 review). `/api/health` is a + // constant, and still sits behind the same `/api/*` edge gate, so the 401 + // branch below keeps distinguishing "gated" from "not there". + const response = await fetchWithTimeout(`${_backendUrl}/api/health`, {}, PROBE_TIMEOUT_MS); if (response.status === 200) return 'ok'; return response.status === 401 ? 'unauthorized' : 'unreachable'; } catch { diff --git a/gitnexus/src/core/git-staleness.ts b/gitnexus/src/core/git-staleness.ts index 1abcd7dda..5fbad1223 100644 --- a/gitnexus/src/core/git-staleness.ts +++ b/gitnexus/src/core/git-staleness.ts @@ -11,6 +11,13 @@ import { findGitRootByDotGit, getCurrentCommit, getRemoteUrl } from '../storage/ const execFileAsync = promisify(execFile); +/** + * Ceiling for one `git rev-list` staleness probe. Generous for the local + * history walk this is, and short enough that an unresponsive working tree + * degrades to "not stale" quickly rather than holding a request open. + */ +const STALENESS_TIMEOUT_MS = 5_000; + export interface StalenessInfo { isStale: boolean; commitsBehind: number; @@ -61,6 +68,14 @@ export async function checkStalenessAsync( cwd: repoPath, encoding: 'utf-8', windowsHide: true, + // The catch below fails closed on every git ERROR, but a hang is not an + // error — it is silence, and without a bound this await never settles. + // A working tree on a disconnected network mount or behind a stuck lock + // does exactly that, and `/api/repos` fans this out once per registered + // repo, so one unreachable mount could hold the whole listing open + // (#3232 review). The timeout kills the child and rejects, which routes + // a hang into the same fail-closed "not stale" answer as a bad SHA. + timeout: STALENESS_TIMEOUT_MS, }); const commitsBehind = parseInt(stdout.trim(), 10) || 0; diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 5a731ae0b..7aa8635fd 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -65,6 +65,8 @@ import { GITHUB_TOKEN_HOSTS, } from './git-clone.js'; import { createAnalyzeUploadHandler } from './analyze-upload.js'; +import { checkStalenessAsync } from '../core/git-staleness.js'; +import { projectRepoDetail, projectRepoListEntry, resolveLastCommit } from './repo-projection.js'; // Shared with the CLI's `--branch` (via the analyze-config wrapper) so both // entry points accept the same refs. Imported from core — not cli/ — so // createServer does not close a cycle with cli/serve.ts. @@ -1000,18 +1002,24 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => }); // List all registered repos - app.get('/api/repos', async (_req, res) => { + // Rate-limited (CodeQL js/missing-rate-limiting) because this route now spawns + // one `git rev-list` per registered repo to answer freshness: an unauthenticated + // GET that costs N subprocesses is worth the same 60 rpm/IP ceiling `/api/repo` + // already carries. Web callers hit this on connect/switch, never in a loop. + app.get('/api/repos', createRouteLimiter(), async (_req, res) => { try { const repos = await listRegisteredRepos(); + // Checked in parallel, for the reason `list_repos` already does it that + // way: each check spawns an async `git rev-list`, and the sequential + // variant took ~50s across 200 repos (#1363). Projecting inside the map + // keeps the entry and its own check together — an index-matched second + // array is the shape that silently mispairs them if either is reordered. res.json( - repos.map((r) => ({ - name: r.name, - path: r.path, - repoPath: r.path, - indexedAt: r.indexedAt, - lastCommit: r.lastCommit, - stats: r.stats, - })), + await Promise.all( + repos.map(async (r) => + projectRepoListEntry(r, await checkStalenessAsync(r.path, r.lastCommit)), + ), + ), ); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to list repos' }); @@ -1038,12 +1046,8 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => return; } const meta = await loadMeta(entry.storagePath); - res.json({ - name: entry.name, - repoPath: entry.path, - indexedAt: meta?.indexedAt ?? entry.indexedAt, - stats: meta?.stats ?? entry.stats ?? {}, - }); + const staleness = await checkStalenessAsync(entry.path, resolveLastCommit(entry, meta)); + res.json(projectRepoDetail(entry, meta, staleness)); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to get repo info' }); } diff --git a/gitnexus/src/server/repo-projection.ts b/gitnexus/src/server/repo-projection.ts new file mode 100644 index 000000000..9ada19403 --- /dev/null +++ b/gitnexus/src/server/repo-projection.ts @@ -0,0 +1,81 @@ +/** + * Response projections for the `serve` repo routes. + * + * Extracted from the route bodies so the field list is assertable. Inline, the + * projections were only reachable by booting a server and indexing a real + * repository, which is how `branch` came to sit on `RegistryEntry` unexposed + * over HTTP while `gitnexus list` printed it, with no test to notice (#3226). + * + * These are pure: the caller resolves the registry entry, the on-disk metadata + * and the staleness check, and passes the results in. + */ +import type { StalenessInfo } from '../core/git-staleness.js'; +import type { RegistryEntry } from '../storage/repo-manager.js'; +import type { RepoMeta } from '../storage/repo-meta.js'; + +/** + * Staleness in the shape MCP `list_repos` already returns + * (`mcp/local/local-backend.ts`): the key is present only when the index is + * actually behind, so "fresh" stays the absence of a field rather than a second + * thing for a client to interpret. Deliberately identical across the two + * surfaces — the same fact should not have two shapes. + * + * `checkStalenessAsync` self-catches and reports 0 commits behind when the + * commit cannot be resolved, so an unanswerable check degrades to "not stale" + * rather than failing the request that carries it. + * + * That makes the field meaningful mainly for `path`-registered repositories, + * where an operator commits into the working tree the index was built from. + * A `url` repository is cloned `--depth 1` and is re-analyzed by the same run + * that pulls it, so its recorded commit is HEAD; and were the two ever to + * diverge, `git rev-list ..HEAD` cannot walk a shallow history and fails + * closed to "not stale". Reporting fresh there is not a claim that the remote + * has not moved — this measures the index against the local working tree, the + * same thing `gitnexus status` and MCP `list_repos` measure. + */ +export const stalenessField = ( + info: StalenessInfo, +): { staleness?: { commitsBehind: number; hint?: string } } => + info.isStale ? { staleness: { commitsBehind: info.commitsBehind, hint: info.hint } } : {}; + +/** One entry of `GET /api/repos`. */ +export const projectRepoListEntry = (entry: RegistryEntry, staleness: StalenessInfo) => ({ + name: entry.name, + path: entry.path, + repoPath: entry.path, + indexedAt: entry.indexedAt, + lastCommit: entry.lastCommit, + stats: entry.stats, + // The registry has carried these since #2106; #3199 made them load-bearing + // over HTTP, because a branch-pinned analyze now gets its own entry and the + // only other way to tell two entries apart is to parse the clone-directory + // slug — a layout detail, not an API contract. + branch: entry.branch, + branches: entry.branches, + ...stalenessField(staleness), +}); + +/** + * `GET /api/repo`. `meta ?? entry` throughout, for the reason `indexedAt` + * already did it: the on-disk metadata is the fresher record when the two + * disagree, and the entry is the fallback for a repo whose meta cannot be read. + */ +export const projectRepoDetail = ( + entry: RegistryEntry, + meta: RepoMeta | null | undefined, + staleness: StalenessInfo, +) => ({ + name: entry.name, + repoPath: entry.path, + indexedAt: meta?.indexedAt ?? entry.indexedAt, + stats: meta?.stats ?? entry.stats ?? {}, + lastCommit: meta?.lastCommit ?? entry.lastCommit, + branch: meta?.branch ?? entry.branch, + ...stalenessField(staleness), +}); + +/** The commit a `/api/repo` staleness check should be measured against. */ +export const resolveLastCommit = ( + entry: RegistryEntry, + meta: RepoMeta | null | undefined, +): string => meta?.lastCommit ?? entry.lastCommit; diff --git a/gitnexus/test/integration/server-repo-freshness.test.ts b/gitnexus/test/integration/server-repo-freshness.test.ts new file mode 100644 index 000000000..16d5312e6 --- /dev/null +++ b/gitnexus/test/integration/server-repo-freshness.test.ts @@ -0,0 +1,223 @@ +/** + * End-to-end HTTP test of the branch/freshness fields on the repo routes (#3226). + * + * `repo-projection.test.ts` covers the projections in isolation. This proves the + * REAL routes call them — the gap the #3199 review named directly ("the actual + * glue, and nothing asserts it"): a unit test of a projection stays green if the + * route stops using it, which is exactly how `branch` sat on `RegistryEntry` + * unexposed while `gitnexus list` printed it. + * + * The fixture is a real git repository with two commits, registered with + * `lastCommit` pinned to the FIRST one, so `git rev-list --count ..HEAD` + * has a genuine answer (1) rather than a trivial zero. No index is written: both + * routes read the registry, and `loadMeta` returning null is a case the + * projection must already handle. + * + * Mirrors the spawn+health-poll harness in server-analyze-branch-validation.test.ts. + */ +import { execFileSync } from 'node:child_process'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..', '..'); +const DIST_CLI = path.join(REPO_ROOT, 'dist', 'cli', 'index.js'); +const STARTUP_BUDGET_MS = process.env.CI ? 30_000 : 15_000; +const REPO_NAME = 'freshness-fixture'; +const INDEXED_AT = '2026-09-08T10:00:00.000Z'; + +const allocateFreePort = (): Promise => + new Promise((resolve, reject) => { + const probe = http.createServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const addr = probe.address(); + if (typeof addr !== 'object' || !addr) { + probe.close(); + reject(new Error('could not allocate ephemeral port')); + return; + } + const port = addr.port; + probe.close((err) => (err ? reject(err) : resolve(port))); + }); + }); + +const httpJson = (port: number, reqPath: string): Promise<{ status: number; body: string }> => + new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port, path: reqPath, method: 'GET' }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => + resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }), + ); + }); + req.on('error', reject); + req.setTimeout(5_000, () => { + req.destroy(); + reject(new Error(`GET ${reqPath} timed out`)); + }); + req.end(); + }); + +// Spawned `serve` on Windows can report ready before the socket is reachable +// from the parent (see server-http-startup.test.ts). +const describeBlock = process.platform === 'win32' ? describe.skip : describe; + +describeBlock('repo routes expose branch and freshness (real server)', () => { + let proc: ChildProcessWithoutNullStreams | undefined; + let homeDir: string | undefined; + let port = 0; + + beforeAll(async () => { + if (!fs.existsSync(DIST_CLI)) { + throw new Error(`Missing ${DIST_CLI} — run npm run build before integration tests`); + } + + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-freshness-')); + const repoPath = path.join(homeDir, 'repos', REPO_NAME); + fs.mkdirSync(repoPath, { recursive: true }); + + const git = (...args: string[]) => + execFileSync('git', args, { cwd: repoPath, encoding: 'utf-8' }).trim(); + git('init', '--quiet', '--initial-branch=main'); + git('config', 'user.email', 'test@example.com'); + git('config', 'user.name', 'GitNexus Test'); + fs.writeFileSync(path.join(repoPath, 'a.js'), 'export const a = 1;\n'); + git('add', '-A'); + git('commit', '--quiet', '-m', 'first'); + const firstCommit = git('rev-parse', 'HEAD'); + fs.writeFileSync(path.join(repoPath, 'b.js'), 'export const b = 2;\n'); + git('add', '-A'); + git('commit', '--quiet', '-m', 'second'); + + // The storage directory must exist: `serve` prunes registry rows whose + // `storagePath` is gone, so a fixture without it is silently dropped and the + // route legitimately returns nothing (found while writing this test). + const storagePath = path.join(repoPath, '.gitnexus'); + fs.mkdirSync(storagePath, { recursive: true }); + fs.writeFileSync( + path.join(storagePath, 'gitnexus.json'), + JSON.stringify({ + repoPath, + indexedAt: INDEXED_AT, + lastCommit: firstCommit, + branch: 'main', + stats: { files: 1, nodes: 2, edges: 1 }, + }), + ); + + // Registered as if indexed at the FIRST commit: one commit behind HEAD. + fs.writeFileSync( + path.join(homeDir, 'registry.json'), + JSON.stringify([ + { + name: REPO_NAME, + path: repoPath, + storagePath, + indexedAt: INDEXED_AT, + lastCommit: firstCommit, + branch: 'main', + stats: { files: 1, nodes: 2, edges: 1 }, + }, + ]), + ); + + port = await allocateFreePort(); + proc = spawn( + process.execPath, + [DIST_CLI, 'serve', '--port', String(port), '--host', '127.0.0.1'], + { + cwd: REPO_ROOT, + env: { ...process.env, GITNEXUS_HOME: homeDir, NODE_OPTIONS: '' }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + let stderr = ''; + proc.stderr.on('data', (buf) => { + stderr += buf.toString(); + }); + + const startedAt = Date.now(); + while (Date.now() - startedAt < STARTUP_BUDGET_MS) { + if (proc.exitCode !== null) { + throw new Error(`serve exited ${proc.exitCode} before ready.\nstderr:\n${stderr}`); + } + try { + const { status } = await httpJson(port, '/api/health'); + if (status === 200) return; + } catch { + // Server still starting — retry until budget expires. + } + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error( + `serve did not become ready within ${STARTUP_BUDGET_MS}ms.\nstderr:\n${stderr}`, + ); + }, 60_000); + + afterAll(async () => { + if (proc && !proc.killed) { + proc.kill('SIGTERM'); + await new Promise((resolve) => { + const timer = setTimeout(() => { + proc?.kill('SIGKILL'); + resolve(); + }, 3_000); + proc?.on('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); + } + proc = undefined; + if (homeDir) { + fs.rmSync(homeDir, { recursive: true, force: true }); + homeDir = undefined; + } + }); + + it('GET /api/repos returns branch and a real commits-behind count', async () => { + const { status, body } = await httpJson(port, '/api/repos'); + expect(status).toBe(200); + const entry = JSON.parse(body).find((r: { name: string }) => r.name === REPO_NAME); + expect(entry).toBeDefined(); + expect(entry.branch).toBe('main'); + // Genuinely computed by `git rev-list`, not a fixture constant. + expect(entry.staleness).toEqual({ + commitsBehind: 1, + hint: expect.stringContaining('1 commit behind'), + }); + }); + + it('GET /api/repos still returns the fields it always did', async () => { + // Additive only — an existing client must not notice this change. + const { body } = await httpJson(port, '/api/repos'); + const entry = JSON.parse(body).find((r: { name: string }) => r.name === REPO_NAME); + expect(entry).toMatchObject({ + name: REPO_NAME, + path: expect.any(String), + repoPath: expect.any(String), + indexedAt: expect.any(String), + lastCommit: expect.any(String), + stats: expect.any(Object), + }); + }); + + it('GET /api/repo returns lastCommit and branch, which it used to drop', async () => { + const { status, body } = await httpJson(port, `/api/repo?repo=${REPO_NAME}`); + expect(status).toBe(200); + const repo = JSON.parse(body); + expect(repo.lastCommit).toMatch(/^[0-9a-f]{40}$/); + expect(repo.branch).toBe('main'); + expect(repo.staleness).toEqual({ + commitsBehind: 1, + hint: expect.stringContaining('1 commit behind'), + }); + }); +}); diff --git a/gitnexus/test/unit/rate-limit.test.ts b/gitnexus/test/unit/rate-limit.test.ts index 85f3b9253..e8f5d831b 100644 --- a/gitnexus/test/unit/rate-limit.test.ts +++ b/gitnexus/test/unit/rate-limit.test.ts @@ -250,6 +250,14 @@ describe('production routes — rate-limit middleware wiring', () => { expect(apiSource).toMatch(/app\.get\('\/api\/repo',\s*createRouteLimiter\(/); }); + it('GET /api/repos is wired with createRouteLimiter', () => { + // Carries a limiter because it spawns a `git rev-list` per registered repo + // to answer freshness — an unauthenticated GET whose cost scales with the + // number of indexed repos. Pinned here so dropping the limiter fails a test + // before it has to be caught by CodeQL (#3232). + expect(apiSource).toMatch(/app\.get\('\/api\/repos',\s*createRouteLimiter\(/); + }); + it('POST /api/analyze is wired with createRouteLimiter', () => { // Tolerate Prettier wrapping the registration across lines (it does once // the route carries extra middleware like requireTrustedOrigin). diff --git a/gitnexus/test/unit/repo-projection.test.ts b/gitnexus/test/unit/repo-projection.test.ts new file mode 100644 index 000000000..2d2d82043 --- /dev/null +++ b/gitnexus/test/unit/repo-projection.test.ts @@ -0,0 +1,165 @@ +/** + * The `serve` repo-route projections (#3226). + * + * These exist as a separate module precisely so this file can exist: inline in + * `createServer`, the field list was only reachable by booting a server and + * indexing a real repository, which is how `branch` sat on `RegistryEntry` + * unexposed over HTTP while `gitnexus list` printed it and MCP `list_repos` + * returned staleness. + * + * The cases below are the ones a projection regresses on silently: a dropped + * optional field still serializes, and a "fresh" index is indistinguishable + * from one whose staleness check could not run unless the shape says so. + */ +import { describe, expect, it } from 'vitest'; +import { + projectRepoDetail, + projectRepoListEntry, + resolveLastCommit, + stalenessField, +} from '../../src/server/repo-projection.js'; +import type { StalenessInfo } from '../../src/core/git-staleness.js'; +import type { RegistryEntry } from '../../src/storage/repo-manager.js'; +import type { RepoMeta } from '../../src/storage/repo-meta.js'; + +const FRESH: StalenessInfo = { isStale: false, commitsBehind: 0 }; +const BEHIND: StalenessInfo = { + isStale: true, + commitsBehind: 3, + hint: '⚠️ Index is 3 commits behind HEAD. Run analyze tool to update.', +}; + +const meta = (over: Partial = {}): RepoMeta => + ({ + indexedAt: '2026-09-08T12:00:00.000Z', + lastCommit: 'ffffffffffffffffffffffffffffffffffffffff', + branch: 'develop', + stats: { files: 11, nodes: 111, edges: 555 }, + ...over, + }) as RepoMeta; + +const entry = (over: Partial = {}): RegistryEntry => ({ + name: 'Hello-World', + path: '/home/u/.gitnexus/repos/Hello-World', + storagePath: '/home/u/.gitnexus/repos/Hello-World/.gitnexus', + indexedAt: '2026-09-08T10:00:00.000Z', + lastCommit: '7fd1a60b01f91b314f59955a4e4d4e80d8edf11d', + stats: { files: 10, nodes: 100, edges: 500 }, + ...over, +}); + +describe('stalenessField', () => { + it('omits the key entirely for a fresh index', () => { + // Absence IS the "fresh" signal, matching MCP list_repos. A client must not + // have to distinguish `undefined` from `{commitsBehind: 0}`. + expect(stalenessField(FRESH)).toEqual({}); + expect(Object.hasOwn(stalenessField(FRESH), 'staleness')).toBe(false); + }); + + it('reports commits behind and the hint when the index is behind', () => { + expect(stalenessField(BEHIND)).toEqual({ + staleness: { commitsBehind: 3, hint: BEHIND.hint }, + }); + }); + + it('treats an unresolvable check as fresh rather than as an error', () => { + // checkStalenessAsync self-catches to {isStale:false, commitsBehind:0} for a + // shallow clone, rewritten history or a non-git path. That must degrade to a + // normal response, never a 500 on a route whose job is to list repos. + expect(stalenessField({ isStale: false, commitsBehind: 0 })).toEqual({}); + }); +}); + +describe('projectRepoListEntry — GET /api/repos', () => { + it('exposes branch and branches, which the registry has always carried', () => { + const out = projectRepoListEntry( + entry({ + branch: 'master', + branches: [{ branch: 'test', indexedAt: '2026-09-08T11:00:00.000Z', lastCommit: 'abc123' }], + }) as RegistryEntry, + FRESH, + ); + expect(out.branch).toBe('master'); + expect(out.branches).toHaveLength(1); + }); + + it('distinguishes the two entries #3199 creates for one repository', () => { + // A pinned analyze registers under its clone-directory name. Without + // `branch`, these two are only tellable apart by parsing that slug — a + // layout detail that is trimmed for long refs and absent for path entries. + const primary = projectRepoListEntry(entry({ branch: 'master' }), FRESH); + const pinned = projectRepoListEntry( + entry({ name: 'Hello-World__test-9f86d081', branch: 'test' }), + FRESH, + ); + expect([primary.branch, pinned.branch]).toEqual(['master', 'test']); + }); + + it('keeps every field the route returned before, unchanged', () => { + // Additive only: an existing client must not notice this change. + const e = entry(); + const out = projectRepoListEntry(e, FRESH); + expect(out).toMatchObject({ + name: e.name, + path: e.path, + repoPath: e.path, + indexedAt: e.indexedAt, + lastCommit: e.lastCommit, + stats: e.stats, + }); + }); + + it('leaves branch undefined for a legacy entry that never recorded one', () => { + const out = projectRepoListEntry(entry(), FRESH); + expect(out.branch).toBeUndefined(); + expect(out.branches).toBeUndefined(); + }); + + it('carries staleness through for a behind index', () => { + expect(projectRepoListEntry(entry(), BEHIND).staleness).toEqual({ + commitsBehind: 3, + hint: BEHIND.hint, + }); + }); +}); + +describe('projectRepoDetail — GET /api/repo', () => { + it('returns lastCommit and branch, which the route used to drop', () => { + const out = projectRepoDetail(entry({ branch: 'master' }), null, FRESH); + expect(out.lastCommit).toBe(entry().lastCommit); + expect(out.branch).toBe('master'); + }); + + it('prefers on-disk metadata over the registry entry, as indexedAt already did', () => { + const out = projectRepoDetail(entry({ branch: 'master' }), meta(), FRESH); + expect(out.indexedAt).toBe('2026-09-08T12:00:00.000Z'); + expect(out.lastCommit).toBe('ffffffffffffffffffffffffffffffffffffffff'); + expect(out.branch).toBe('develop'); + }); + + it('falls back to the entry when metadata cannot be read', () => { + const out = projectRepoDetail(entry({ branch: 'master' }), undefined, FRESH); + expect(out.indexedAt).toBe(entry().indexedAt); + expect(out.lastCommit).toBe(entry().lastCommit); + expect(out.branch).toBe('master'); + }); + + it('still returns an empty stats object rather than undefined', () => { + // Pre-existing contract: the route returned `{}` when neither side had stats. + expect(projectRepoDetail(entry({ stats: undefined }), null, FRESH).stats).toEqual({}); + }); +}); + +describe('resolveLastCommit', () => { + it('measures staleness against the commit the response reports', () => { + // If these two disagreed, the route would report one commit and compute + // commits-behind from another — a freshness number for a different index. + const e = entry(); + const m = meta(); + expect(resolveLastCommit(e, m)).toBe(projectRepoDetail(e, m, FRESH).lastCommit); + }); + + it('falls back to the registry entry with no metadata', () => { + expect(resolveLastCommit(entry(), null)).toBe(entry().lastCommit); + }); +}); From 20b13b3ed6a290fe2470d30282d7c9f8b5285143 Mon Sep 17 00:00:00 2001 From: Parafee41 Date: Thu, 10 Sep 2026 00:29:28 +0800 Subject: [PATCH 02/33] perf(resolution): avoid quadratic config walk queues (#3237) --- .../ingestion/import-resolvers/node-workspace-packages.ts | 5 +++-- gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts b/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts index 5750da917..0b98aa195 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts @@ -340,16 +340,17 @@ export async function loadNodeWorkspacePackages( const scope = await loadWorkspaceScope(repoRoot); const byName = new Map(); const queue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }]; + let queueHead = 0; let dirsScanned = 0; - while (queue.length > 0) { + while (queueHead < queue.length) { if (dirsScanned >= SCAN_MAX_DIRS) { logger.warn( `[node] package.json scan of ${repoRoot} hit the ${SCAN_MAX_DIRS}-directory cap; workspace packages below it will not resolve`, ); break; } - const { dir, depth } = queue.shift()!; + const { dir, depth } = queue[queueHead++]!; dirsScanned++; let entries: import('fs').Dirent[]; diff --git a/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts b/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts index 0e9871c64..d47374473 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts @@ -278,10 +278,11 @@ async function firstReadableConfig(base: string): Promise { async function findTsconfigFiles(repoRoot: string): Promise { const found: string[] = []; const queue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }]; + let queueHead = 0; let dirsScanned = 0; - while (queue.length > 0 && dirsScanned < SCAN_MAX_DIRS) { - const { dir, depth } = queue.shift()!; + while (queueHead < queue.length && dirsScanned < SCAN_MAX_DIRS) { + const { dir, depth } = queue[queueHead++]!; dirsScanned++; let entries: import('fs').Dirent[]; try { From b60c21d05df6af6235ce3f61603ca86469fba8fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Wed, 9 Sep 2026 17:30:00 +0100 Subject: [PATCH 03/33] fix(group): extract NestJS GraphQL contracts on real indexes (#3201) (#3227) * fix(group): extract NestJS GraphQL contracts against real 0-based indexes (#3201) Provider lookup used 1-based startLine while the graph stores tree-sitter rows, so every resolver missed. Also try PascalCased Document names and inline sibling FragmentDoc interpolations from graphql-codegen output. Co-authored-by: Cursor * fix(group): bind arrow-field providers and fail closed on interpolations Match Method startLine to the public_field_definition wrapper, decode template escape sequences, and reject FragmentDoc names that mix static and dynamic declarators. Co-authored-by: Cursor * fix(group): only inline interpolated templates under a gql tag Cooked reconstruction is not the runtime value for String.raw or unknown tags, so those interpolations stay fail-closed. Co-authored-by: Cursor * fix(group): tighten gql-tag trust and PascalCase Document lookup Only the identifier `gql` is a trusted interpolating tag. Underscored operation names now try the full pascal-case Document candidate graphql-codegen emits. Co-authored-by: Cursor * fix(group): memoize GraphQL interpolation source resolution Avoid exponential re-walks when the same fragment name is declared twice at each layer of a ${FragmentDoc} chain. Co-authored-by: Cursor * fix(group): fail closed on invalid tagged-template escapes Treat line continuations as empty cooked text and reject \8/\9 plus legacy octals so reconstructed gql source matches runtime. Co-authored-by: Cursor --------- Co-authored-by: Gergo Magyar Co-authored-by: Cursor --- .../group/extractors/graphql-extractor.ts | 176 +++++++- .../group/graphql-resolve-symbol.test.ts | 42 +- .../test/unit/group/graphql-extractor.test.ts | 404 ++++++++++++++++++ 3 files changed, 608 insertions(+), 14 deletions(-) diff --git a/gitnexus/src/core/group/extractors/graphql-extractor.ts b/gitnexus/src/core/group/extractors/graphql-extractor.ts index efafe4e2e..550a06155 100644 --- a/gitnexus/src/core/group/extractors/graphql-extractor.ts +++ b/gitnexus/src/core/group/extractors/graphql-extractor.ts @@ -92,6 +92,134 @@ function unquote(text: string): string | null { return value.includes('${') ? null : value; } +const SIMPLE_TEMPLATE_ESCAPES: Record = { + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + v: '\v', + '0': '\0', + "'": "'", + '"': '"', + '\\': '\\', + '`': '`', +}; + +/** Graph Method `startLine` is the 0-based wrapper row (see line-base.ts). */ +function providerMemberStartLine(member: Parser.SyntaxNode): number { + // Class-field arrows are `@declaration.property`; parse-worker falls back to + // the `public_field_definition` wrapper (decorator row when it is a child), + // not the initializer. Do not probe the `value` child. + return member.startPosition.row; +} + +/** tree-sitter `escape_sequence.text` is the source spelling (`\\n`), not the JS value. */ +function decodeEscapeSequence(text: string): string | null { + if (!text.startsWith('\\') || text.length < 2) return null; + const escaped = text.slice(1); + // Tagged-template cooked value: LineContinuation is empty; `\8`/`\9` and + // LegacyOctalEscapeSequence (`\1`–`\7`, `\00`…) make cooked undefined. + if (/^[\n\r\u2028\u2029]$/.test(escaped) || escaped === '\r\n') return ''; + if (/^[1-9]$/.test(escaped) || /^[0-7]{2,3}$/.test(escaped)) return null; + if (escaped.length === 1) return SIMPLE_TEMPLATE_ESCAPES[escaped] ?? escaped; + if (escaped[0] === 'x' && /^[0-9A-Fa-f]{2}$/.test(escaped.slice(1))) { + return String.fromCharCode(Number.parseInt(escaped.slice(1), 16)); + } + if (escaped.startsWith('u{') && escaped.endsWith('}')) { + const hex = escaped.slice(2, -1); + if (!/^[0-9A-Fa-f]{1,6}$/.test(hex)) return null; + const codePoint = Number.parseInt(hex, 16); + if (codePoint > 0x10ffff) return null; + return String.fromCodePoint(codePoint); + } + if (escaped[0] === 'u' && /^[0-9A-Fa-f]{4}$/.test(escaped.slice(1))) { + return String.fromCharCode(Number.parseInt(escaped.slice(1), 16)); + } + return null; +} + +function substitutionIdentifier(substitution: Parser.SyntaxNode): string | null { + if (substitution.namedChildren.length !== 1) return null; + const expr = unwrapExpression(substitution.namedChildren[0]); + return expr.type === 'identifier' ? expr.text : null; +} + +function isGraphqlTagCall(call: Parser.SyntaxNode): boolean { + const callee = call.childForFieldName('function'); + return callee?.type === 'identifier' && callee.text === 'gql'; +} + +function pascalCaseGraphqlName(name: string): string { + return name + .split(/[^A-Za-z0-9]+/) + .filter((part) => part.length > 0) + .map((part) => `${part[0]!.toUpperCase()}${part.slice(1)}`) + .join(''); +} + +type InterpolationCache = Map; + +function uniqueStaticSource( + name: string, + declarators: GeneratedSymbolIndex, + resolving: Set, + cache: InterpolationCache, +): string | null { + if (cache.has(name)) return cache.get(name) ?? null; + if (resolving.has(name) || resolving.size >= MAX_GRAPHQL_TRAVERSAL_DEPTH) return null; + const values = declarators.get(name) ?? []; + if (values.length === 0) { + cache.set(name, null); + return null; + } + resolving.add(name); + const sources = new Set(); + for (const value of values) { + const source = staticGraphqlSource(value, declarators, resolving, cache); + // A dynamic or unprovable sibling makes the name ambiguous — do not pick + // the one static spelling and ignore the rest. + if (source === null) { + resolving.delete(name); + cache.set(name, null); + return null; + } + sources.add(source); + } + resolving.delete(name); + const unique = sources.size === 1 ? [...sources][0]! : null; + cache.set(name, unique); + return unique; +} + +function interpolatedTemplateSource( + template: Parser.SyntaxNode, + declarators: GeneratedSymbolIndex, + resolving: Set, + cache: InterpolationCache, +): string | null { + let out = ''; + for (const child of template.namedChildren) { + if (child.type === 'string_fragment') { + out += child.text; + } else if (child.type === 'escape_sequence') { + const decoded = decodeEscapeSequence(child.text); + if (decoded === null) return null; + out += decoded; + } else if (child.type === 'template_substitution') { + const name = substitutionIdentifier(child); + if (!name) return null; + const inlined = uniqueStaticSource(name, declarators, resolving, cache); + if (inlined === null) return null; + out += inlined; + } else { + return null; + } + if (out.length > MAX_GRAPHQL_TOKENS) return null; + } + return out; +} + function unwrapExpression(node: Parser.SyntaxNode): Parser.SyntaxNode { let current = node; while ( @@ -214,7 +342,12 @@ function parsedDocumentProof( return false; } -function staticGraphqlSource(initializer: Parser.SyntaxNode): string | null { +function staticGraphqlSource( + initializer: Parser.SyntaxNode, + declarators?: GeneratedSymbolIndex, + resolving: Set = new Set(), + cache: InterpolationCache = new Map(), +): string | null { const value = unwrapExpression(initializer); if (value.type === 'string') { if (value.text.startsWith('"')) { @@ -226,11 +359,24 @@ function staticGraphqlSource(initializer: Parser.SyntaxNode): string | null { } return unquote(value.text); } - if (value.type === 'template_string') return unquote(value.text); + if (value.type === 'template_string') { + const hasSubstitution = value.namedChildren.some( + (child) => child.type === 'template_substitution', + ); + if (!hasSubstitution) return unquote(value.text); + return declarators ? interpolatedTemplateSource(value, declarators, resolving, cache) : null; + } if (value.type === 'call_expression') { const template = value.namedChildren.find((child) => child.type === 'template_string'); - return template ? unquote(template.text) : null; + if (!template) return null; + const hasSubstitution = template.namedChildren.some( + (child) => child.type === 'template_substitution', + ); + // Interpolated reconstruction is the cooked template. Only `gql` is + // treated as preserving that source; String.raw / unknown tags stay fail-closed. + if (hasSubstitution && !isGraphqlTagCall(value)) return null; + return staticGraphqlSource(template, declarators, resolving, cache); } if (value.type !== 'new_expression') return null; @@ -238,7 +384,7 @@ function staticGraphqlSource(initializer: Parser.SyntaxNode): string | null { if (!constructor || !constructor.text.endsWith('TypedDocumentString')) return null; const args = value.childForFieldName('arguments'); const first = args?.namedChildren[0]; - return first ? staticGraphqlSource(first) : null; + return first ? staticGraphqlSource(first, declarators, resolving, cache) : null; } export function hasGeneratedDocumentProof( @@ -246,9 +392,10 @@ export function hasGeneratedDocumentProof( operationKind: GraphqlOperationKind, operationName: string, requiredFields: readonly string[], + declarators?: GeneratedSymbolIndex, ): boolean { if (!withinGeneratedAstBudget(initializer)) return false; - const staticSource = staticGraphqlSource(initializer); + const staticSource = staticGraphqlSource(initializer, declarators); if (staticSource !== null) { return parsedDocumentProof(staticSource, operationKind, operationName, requiredFields); } @@ -430,7 +577,10 @@ function rootFields( function generatedCandidates(operation: OperationDefinitionNode): string[] { const name = operation.name?.value; - return name ? [`${name}Document`] : []; + if (!name) return []; + const exact = `${name}Document`; + const pascal = `${pascalCaseGraphqlName(name)}Document`; + return exact === pascal ? [exact] : [exact, pascal]; } async function generatedDocumentMatches( @@ -450,7 +600,13 @@ async function generatedDocumentMatches( const index = await pendingIndex; const values = index?.get(symbol.name) ?? []; return values.some((value) => - hasGeneratedDocumentProof(value, operationKind, operationName, requiredFields), + hasGeneratedDocumentProof( + value, + operationKind, + operationName, + requiredFields, + index ?? undefined, + ), ); } @@ -593,11 +749,7 @@ export class GraphqlExtractor implements ContractExtractor { await dbExecutor(RESOLVE_METHOD_QUERY, { name: methodName, filePath, - startLine: - member.type === 'public_field_definition' - ? (member.childForFieldName('value')?.startPosition.row ?? - member.startPosition.row) + 1 - : member.startPosition.row + 1, + startLine: providerMemberStartLine(member), }), ); if (!symbol) continue; diff --git a/gitnexus/test/integration/group/graphql-resolve-symbol.test.ts b/gitnexus/test/integration/group/graphql-resolve-symbol.test.ts index 283f68842..4d1d50802 100644 --- a/gitnexus/test/integration/group/graphql-resolve-symbol.test.ts +++ b/gitnexus/test/integration/group/graphql-resolve-symbol.test.ts @@ -7,9 +7,11 @@ import { closeLbug, executeParameterized } from '../../../src/core/lbug/pool-ada import type { GroupConfig, RepoHandle } from '../../../src/core/group/types.js'; import { withTestLbugDB } from '../../helpers/test-indexed-db.js'; +// Graph Method.startLine is 0-based (tree-sitter row). `health()` is source line 5. const SEED = [ - `CREATE (:Method {id:'method:health', name:'health', filePath:'src/health.resolver.ts', startLine:5, endLine:5, content:'', description:''})`, - `CREATE (:Const {id:'const:health-document', name:'HealthDocument', filePath:'src/generated.ts', startLine:1, endLine:1, content:'', description:''})`, + `CREATE (:Method {id:'method:health', name:'health', filePath:'src/health.resolver.ts', startLine:4, endLine:4, content:'', description:''})`, + `CREATE (:Method {id:'method:save', name:'save', filePath:'src/health.resolver.ts', startLine:6, endLine:6, content:'', description:''})`, + `CREATE (:Const {id:'const:health-document', name:'HealthDocument', filePath:'src/generated.ts', startLine:0, endLine:0, content:'', description:''})`, ]; withTestLbugDB( @@ -140,6 +142,42 @@ withTestLbugDB( registrySpy.mockRestore(); } }); + + it('binds a decorated arrow-field provider at the wrapper startLine (#3201)', async () => { + providerRoot = path.join(handle.tmpHandle.dbPath, 'arrow-provider-repo'); + await fs.mkdir(path.join(providerRoot, 'src'), { recursive: true }); + await fs.writeFile( + path.join(providerRoot, 'src/health.resolver.ts'), + `import { Query, Mutation, Resolver } from '@nestjs/graphql'; +@Resolver() +class HealthResolver { + @Query() + health() { return 'ok'; } + + @Mutation() + save = async () => true; +}`, + 'utf8', + ); + const providerRepo: RepoHandle = { + id: handle.repoId, + path: 'api', + repoPath: providerRoot, + storagePath: handle.tmpHandle.dbPath, + }; + const execute = (query: string, params: Record = {}) => + executeParameterized(handle.repoId, query, params); + const contracts = await new GraphqlExtractor().extract(execute, providerRoot, providerRepo); + expect(contracts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + contractId: 'graphql::mutation::save', + role: 'provider', + symbolUid: 'method:save', + }), + ]), + ); + }); }, { seed: SEED, poolAdapter: true }, ); diff --git a/gitnexus/test/unit/group/graphql-extractor.test.ts b/gitnexus/test/unit/group/graphql-extractor.test.ts index 5e6d69505..86b0492d4 100644 --- a/gitnexus/test/unit/group/graphql-extractor.test.ts +++ b/gitnexus/test/unit/group/graphql-extractor.test.ts @@ -499,4 +499,408 @@ export const TwoDocument = ${generatedDocument('query', 'Two', ['two'])}; ]); expect(parseSpy).toHaveBeenCalledTimes(1); }); + + it('binds NestJS providers at the 0-based graph startLine (#3201)', async () => { + const { root, repo } = await makeRepo({ + 'src/health.resolver.ts': `import { Query, Mutation, Resolver } from '@nestjs/graphql'; +@Resolver() +class HealthResolver { + @Query() + health() { return 'ok'; } + + @Mutation() + save = async () => true; +}`, + }); + const graphStartLine: Record = { health: 4, save: 6 }; + const startLines: Array<{ name: string; startLine: number }> = []; + const run: CypherExecutor = async (_query, params = {}) => { + const name = String(params.name); + const startLine = Number(params.startLine); + startLines.push({ name, startLine }); + if (graphStartLine[name] !== startLine) return []; + return [{ uid: `sym:${name}`, name, filePath: 'src/health.resolver.ts' }]; + }; + + const contracts = await new GraphqlExtractor().extract(run, root, repo); + + expect(startLines).toEqual([ + { name: 'health', startLine: 4 }, + { name: 'save', startLine: 6 }, + ]); + expect(contracts.map((contract) => contract.contractId)).toEqual([ + 'graphql::query::health', + 'graphql::mutation::save', + ]); + }); + + it('tries the PascalCased Document name graphql-codegen emits (#3201)', async () => { + const { root, repo } = await makeRepo({ + 'src/timeline.graphql': `query widgetTimeline { getWidget }`, + 'src/generated.ts': `export const WidgetTimelineDocument = ${generatedDocument( + 'query', + 'widgetTimeline', + ['getWidget'], + )};`, + }); + const lookedUp: string[] = []; + const run: CypherExecutor = async (_query, params = {}) => { + const name = String(params.name ?? ''); + lookedUp.push(name); + return name === 'WidgetTimelineDocument' + ? [ + { + uid: 'const:timeline', + name: 'WidgetTimelineDocument', + filePath: 'src/generated.ts', + }, + ] + : []; + }; + + const contracts = await new GraphqlExtractor().extract(run, root, repo); + + expect(lookedUp).toEqual(['widgetTimelineDocument', 'WidgetTimelineDocument']); + expect(contracts).toEqual([ + expect.objectContaining({ + contractId: 'graphql::query::getWidget', + role: 'consumer', + symbolUid: 'const:timeline', + }), + ]); + }); + + it('tries the full PascalCased Document name for underscored operations (#3201)', async () => { + const { root, repo } = await makeRepo({ + 'src/widget.graphql': `query get_widget { getWidget }`, + 'src/generated.ts': `export const GetWidgetDocument = ${generatedDocument( + 'query', + 'get_widget', + ['getWidget'], + )};`, + }); + const lookedUp: string[] = []; + const run: CypherExecutor = async (_query, params = {}) => { + const name = String(params.name ?? ''); + lookedUp.push(name); + return name === 'GetWidgetDocument' + ? [ + { + uid: 'const:widget', + name: 'GetWidgetDocument', + filePath: 'src/generated.ts', + }, + ] + : []; + }; + + const contracts = await new GraphqlExtractor().extract(run, root, repo); + + expect(lookedUp).toEqual(['get_widgetDocument', 'GetWidgetDocument']); + expect(contracts).toEqual([ + expect.objectContaining({ + contractId: 'graphql::query::getWidget', + role: 'consumer', + symbolUid: 'const:widget', + }), + ]); + }); + + it('inlines sibling ${FragmentDoc} interpolations to prove generated documents (#3201)', async () => { + const { root, repo } = await makeRepo({ + 'src/get-widget.graphql': ` +fragment widget on Widget { id } +query GetWidget { getWidget { ...widget } } +`, + 'src/root-spread.graphql': ` +fragment MoreRoots on Query { gadget } +query GetWidgets { ...MoreRoots } +`, + 'src/generated.ts': ` +export const WidgetFragmentDoc = /*#__PURE__*/ \` + fragment widget on Widget { id } + \`; +export const GetWidgetDocument = /*#__PURE__*/ \` + query GetWidget { + getWidget { + ...widget + } +} + \${WidgetFragmentDoc}\`; +export const MoreRootsFragmentDoc = /*#__PURE__*/ \` + fragment MoreRoots on Query { gadget } + \`; +export const GetWidgetsDocument = gql\` + query GetWidgets { ...MoreRoots } + \${MoreRootsFragmentDoc}\`; +`, + }); + + const contracts = await new GraphqlExtractor().extract( + executor({ + GetWidgetDocument: [ + { uid: 'const:widget', name: 'GetWidgetDocument', filePath: 'src/generated.ts' }, + ], + GetWidgetsDocument: [ + { uid: 'const:widgets', name: 'GetWidgetsDocument', filePath: 'src/generated.ts' }, + ], + }), + root, + repo, + ); + + expect(contracts.map((contract) => [contract.contractId, contract.symbolUid]).sort()).toEqual([ + ['graphql::query::gadget', 'const:widgets'], + ['graphql::query::getWidget', 'const:widget'], + ]); + }); + + it('fails closed for dynamic or cyclic generated interpolations (#3201)', async () => { + const { root, repo } = await makeRepo({ + 'src/dynamic.graphql': `query Dynamic { dynamic }`, + 'src/cycle.graphql': `query Cycle { cycle }`, + 'src/generated.ts': ` +export const DynamicDocument = \`query Dynamic { dynamic }\${foo.bar}\`; +export const CycleFragmentDoc = \`\${CycleDocument}\`; +export const CycleDocument = \`query Cycle { cycle }\${CycleFragmentDoc}\`; +`, + }); + + const contracts = await new GraphqlExtractor().extract( + executor({ + DynamicDocument: [ + { uid: 'const:dynamic', name: 'DynamicDocument', filePath: 'src/generated.ts' }, + ], + CycleDocument: [ + { uid: 'const:cycle', name: 'CycleDocument', filePath: 'src/generated.ts' }, + ], + }), + root, + repo, + ); + + expect(contracts).toEqual([]); + }); + + it('fails closed when sibling ${FragmentDoc} names resolve to two static sources (#3201)', async () => { + const { root, repo } = await makeRepo({ + 'src/get-widget.graphql': `query GetWidget { getWidget }`, + 'src/generated.ts': ` +{ + const WidgetFragmentDoc = /*#__PURE__*/ \` + fragment widget on Widget { id } + \`; +} +export const WidgetFragmentDoc = /*#__PURE__*/ \` + fragment widget on Widget { name } + \`; +export const GetWidgetDocument = /*#__PURE__*/ \` + query GetWidget { getWidget { ...widget } } + \${WidgetFragmentDoc}\`; +`, + }); + + const contracts = await new GraphqlExtractor().extract( + executor({ + GetWidgetDocument: [ + { uid: 'const:widget', name: 'GetWidgetDocument', filePath: 'src/generated.ts' }, + ], + }), + root, + repo, + ); + + expect(contracts).toEqual([]); + }); + + it('fails closed when a FragmentDoc name has a static and a dynamic declarator (#3201)', async () => { + const { root, repo } = await makeRepo({ + 'src/get-widget.graphql': `query GetWidget { getWidget }`, + 'src/generated.ts': ` +{ + const WidgetFragmentDoc = \`\${foo.bar}\`; +} +export const WidgetFragmentDoc = /*#__PURE__*/ \` + fragment widget on Widget { id } + \`; +export const GetWidgetDocument = /*#__PURE__*/ \` + query GetWidget { getWidget { ...widget } } + \${WidgetFragmentDoc}\`; +`, + }); + + const contracts = await new GraphqlExtractor().extract( + executor({ + GetWidgetDocument: [ + { uid: 'const:widget', name: 'GetWidgetDocument', filePath: 'src/generated.ts' }, + ], + }), + root, + repo, + ); + + expect(contracts).toEqual([]); + }); + + it('treats template line continuations as empty while inlining ${FragmentDoc} (#3201)', async () => { + const { root, repo } = await makeRepo({ + 'src/q.graphql': `query Q { q }`, + 'src/generated.ts': ` +export const QFragmentDoc = /*#__PURE__*/ \` + fragment extra on Query { q } + \`; +export const QDocument = gql\`query Q { q }\\ +\${QFragmentDoc}\`; +`, + }); + + const contracts = await new GraphqlExtractor().extract( + executor({ + QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }], + }), + root, + repo, + ); + + expect(contracts).toEqual([ + expect.objectContaining({ + contractId: 'graphql::query::q', + role: 'consumer', + symbolUid: 'const:q', + }), + ]); + }); + + it('fails closed for NonOctalDecimalEscapeSequence in interpolated gql templates (#3201)', async () => { + const { root, repo } = await makeRepo({ + 'src/q.graphql': `query Q { q }`, + 'src/generated.ts': ` +export const QFragmentDoc = /*#__PURE__*/ \` + fragment extra on Query { q } + \`; +export const QDocument = gql\`query Q { q }\\8\${QFragmentDoc}\`; +`, + }); + + const contracts = await new GraphqlExtractor().extract( + executor({ + QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }], + }), + root, + repo, + ); + + expect(contracts).toEqual([]); + }); + + it('decodes escape_sequence nodes while inlining ${FragmentDoc} (#3201)', async () => { + const { root, repo } = await makeRepo({ + 'src/q.graphql': `query Q { q }`, + 'src/generated.ts': ` +export const QFragmentDoc = /*#__PURE__*/ \` + fragment extra on Query { q } + \`; +export const QDocument = gql\`query Q { q }\\n\${QFragmentDoc}\`; +`, + }); + + const contracts = await new GraphqlExtractor().extract( + executor({ + QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }], + }), + root, + repo, + ); + + expect(contracts).toEqual([ + expect.objectContaining({ + contractId: 'graphql::query::q', + role: 'consumer', + symbolUid: 'const:q', + }), + ]); + }); + + it('fails closed for interpolated templates under a non-gql tag (#3201)', async () => { + const { root, repo } = await makeRepo({ + 'src/q.graphql': `query Q { q }`, + 'src/generated.ts': ` +export const QFragmentDoc = /*#__PURE__*/ \` + fragment extra on Query { q } + \`; +export const QDocument = String.raw\`query Q { q }\\n\${QFragmentDoc}\`; +`, + }); + + const contracts = await new GraphqlExtractor().extract( + executor({ + QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }], + }), + root, + repo, + ); + + expect(contracts).toEqual([]); + }); + + it('memoizes interpolation sources so duplicated fragment layers stay linear (#3201)', async () => { + const layers = 16; + let generated = ` +export const F0 = \`fragment f0 on Query { q }\`; +{ const F0 = \`fragment f0 on Query { q }\`; } +`; + for (let i = 1; i <= layers; i++) { + const prev = `F${i - 1}`; + const cur = `F${i}`; + generated += ` +export const ${cur} = \`\${${prev}}\`; +{ const ${cur} = \`\${${prev}}\`; } +`; + } + generated += ` +export const QDocument = gql\`query Q { q }\${F${layers}}\`; +`; + const { root, repo } = await makeRepo({ + 'src/q.graphql': `query Q { q }`, + 'src/generated.ts': generated, + }); + + const contracts = await new GraphqlExtractor().extract( + executor({ + QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }], + }), + root, + repo, + ); + + expect(contracts).toEqual([ + expect.objectContaining({ + contractId: 'graphql::query::q', + role: 'consumer', + symbolUid: 'const:q', + }), + ]); + }); + + it('fails closed for interpolated templates under a member .gql tag (#3201)', async () => { + const { root, repo } = await makeRepo({ + 'src/q.graphql': `query Q { q }`, + 'src/generated.ts': ` +export const QFragmentDoc = /*#__PURE__*/ \` + fragment extra on Query { q } + \`; +export const QDocument = formatter.gql\`query Q { q }\\n\${QFragmentDoc}\`; +`, + }); + + const contracts = await new GraphqlExtractor().extract( + executor({ + QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }], + }), + root, + repo, + ); + + expect(contracts).toEqual([]); + }); }); From 506432017fcf4321b7eb51ffa0db001a2014312a Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Wed, 9 Sep 2026 22:12:17 +0200 Subject: [PATCH 04/33] fix(zig): model callable-value references, and stop reporting their absence as `exact` (#3219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(impact): stop reporting 'exact' over unmodelled callable-value references A function named in VALUE position — `bridge.accessor(Element.getNamespaceUri, null, .{})`, `{ onClick: handler }`, a comparator handed to a sort — is registered somewhere rather than called. The registration is modelled (a `value-ref` site becomes a USES edge; Kythe `ref` vs `ref/call`, Joern METHOD_REF), but the invocation THROUGH the stored value is not: it happens later via a struct field, a registry lookup or comptime reflection. impact()/context() nonetheless reported such a target as `epistemic: 'exact'`. For lightpanda-io/browser that meant the DOM `Element.namespaceURI` accessor came back with two internal callers, LOW risk and a claim of completeness — worse than no answer, because 'exact' tells the reader not to look further. tools.ts defines 'lower-bound' as "the walk provably missed callers", which is precisely this case. computeEpistemicBoundary now probes for inbound USES edges stamped with the value-ref reason and hedges when it finds any, contributing a boundary note and a new `causes.callableValueReferences` (unit: distinct referrer symbols). Read from the graph, not from index metadata: unlike a dropped receiver — which leaves no edge to find and therefore needs a persisted summary — a value reference IS in the graph. So the signal needs no re-index and no analyzer change, and it works on indexes written before this commit. The cause gets its own slot rather than joining `dispatchBoundary`: a value referrer is neither an implementation nor an interface-level consumer, and the two differ in what the reader should do about them — a dispatch boundary is irreducible, a callable value usually becomes traceable once the provider models the store/load that carries it. Language-neutral: every provider that emits a `value-ref` capture participates. The writer and the reader now share VALUE_REF_EDGE_REASON, because drift between them would fail silently — the probe would match nothing and every answer would go back to claiming certainty. * fix(zig): emit `value-ref` for a callable named in value position `mapReferenceKindToEdgeType` has handled the registration-vs-invocation case since #2437, and TypeScript, JavaScript and C++ all emit `value-ref`. Zig emitted zero — so a Zig function handed somewhere as a VALUE was absent from the graph entirely. That is not a corner: Zig's JS bridge is built out of this one shape, pub const namespaceURI = bridge.accessor(Element.getNamespaceUri, null, .{}); and `bridge.{accessor,function,indexed,…}` appears 2,047 times across 257 files in lightpanda-io/browser — the project's whole JS<->Zig surface, none of it reaching the graph. `impact` on `Element.getNamespaceUri`, which IS the DOM `Element.namespaceURI` accessor, answered with its two in-file callers. Three query rules, tagging `@reference.value-ref` on: a bare identifier in argument position (`bridge.accessor(_tagName, …)`), a qualified one (`bridge.accessor(Element.getNamespaceUri, …)`), and a const binding initialiser (`pub const defaultHandler = onReset;`). Everything downstream already existed — no new edge type, no schema change, no capture-machinery change, no baseline edited. One grammar detail is load-bearing: in tree-sitter-zig, call arguments are DIRECT children of `call_expression` — there is no `arguments` node, only builtins have one — so the callee has to be consumed explicitly by `function:`. Without that binding the same rule also matches the callee of `foo(bar)` and mints a USES edge shadowing the call's own CALLS edge. There is a test for it. The rules are deliberately broad — `js.Bridge(Element)` and `register(count)` match too — because the callable gate in the property-dispatch pass (Function/Method/Constructor only) is the filter, the same design that keeps TypeScript's `{ port: DEFAULT_PORT }` from registering anything. Measured on the real corpus: all 3,169 emitted edges land on a Method (3,146) or Function (23). Deliberately NOT modelled: the terminal invoke. The value reaches `Accessor.init` -> a struct field -> `Factory.zig` reflection (`inline for`, `@typeInfo`) -> `Caller.zig`'s `@call(.auto, func, args)` over `func: anytype`. Resolving that needs comptime evaluation. The point is to stop dropping the reference; the shortfall is now reported as `epistemic: "lower-bound"` by the preceding commit instead of being papered over. Measured on lightpanda-io/browser (698 .zig files), wiped index both times: 30,222 nodes / 71,070 edges -> 30,222 nodes / 74,229 edges. The delta is entirely `USES` (3,169 after vs 10 before, and every one is a value-ref); CALLS, ACCESSES, IMPORTS, HAS_METHOD, DEFINES and the rest are unchanged — purely additive, no node invented. The fixture joins the existing `zig-idioms` corpus rather than adding a new one, because `bench/receiver-resolution` uses `test/fixtures/lang-resolution` as its `--check` corpus. That gate, `scope-capture` (15 languages), `zig-cross-file-resolution` and every other bench `--check` pass unchanged. * fix(review): resolve qualified value references through their owner, and stop hedging on registrations the analyzer already followed Five findings from the review bot on #3219; four valid, all addressed. 1. QUALIFIED VALUE REFERENCES RESOLVED BY TAIL NAME (the serious one). `bridge.accessor(Element.getNamespaceUri, …)` was resolved with `findCallableBindingInScope(site.inScope, site.name, …)`, which never sees the receiver and gives LOCAL bindings precedence. Reproduced: const Element = @This(); pub fn getThing(...) // main.getThing pub const JsApi = struct { fn getThing(...) // JsApi.getThing pub const thing = bridge.accessor(Element.getThing, null, .{}); }; emitted `USES JsApi → JsApi.getThing` — a WRONG edge, which is worse than the missing edge this PR set out to fix. `resolveValueRefTarget` now resolves a site carrying an explicit receiver through `findClassBindingInScope` → `findOwnedMember` (the machinery `receiver-bound-calls` already uses), gated on `CALL_TARGET_TYPES` because `findOwnedMember` also answers with fields. A bare site keeps the lexical walk, which is what an unqualified name means. When the owner cannot be resolved the site is DECLINED rather than falling back: declining costs a reference, falling back mints a confident edge to the wrong function, and the missing reference is now reported as `lower-bound` anyway. Cost on lightpanda-io/browser: value-ref edges 3,169 → 2,799 (−12%). Those 370 were tail-name coincidences, not registrations — all 94 `Element.zig` `JsApi` entries survive, the cross-container case resolves and is correctly attributed (`IntersectionObserverEntry.JsApi` → `IntersectionObserverEntry. getTarget#0`, not the enclosing observer), and both acceptance probes are unchanged: `getNamespaceUri` 7/3/LOW/lower-bound, `getTagNameLower` 31/10/HIGH/exact. 2. A FAILED PROBE READ AS "NO BOUNDARY". `.catch(() => [])` turned an unanswerable query into count 0 and no note, so `exact` could be published on the strength of a question that was never asked. It now returns `null` and emits a boundary note. This file's own `loadMeta` comment states the rule: a probe failing must never read as certainty. 3. NOT EVERY VALUE REFERENCE IS AN UNMODELLED INVOCATION. Where `emitPropertyDispatchCalls` sweep 2 synthesized the dispatch (reason `property-dispatch`), the walk did NOT provably miss the caller, so hedging was noise over an answer that was computed — and a signal that fires on every JS/TS hook table stops carrying information. A second probe excludes those targets. Zig never sets a property key, so the motivating case is untouched. The exclusion is symbol-level, not per-edge, because the graph does not record which registration produced which synthesized call; that residual is documented at the call site. 4. `LIMIT 50` SILENTLY UNDERSTATED THE COUNT. `rows.length` over a capped row set published a ceiling as the documented symbol count. Replaced with `COUNT(DISTINCT other.id)` — bounded work without a bounded answer, the shape `countByType` in the same file already uses. 5. THE TEST DID NOT PIN THE CALLER COUNT its own comment promised. Added `expect(result.impactedCount).toBe(2)`. New regression tests: qualified references bind the written owner and not the nearer lexical match; an unresolvable receiver emits nothing rather than a wrong edge; a dispatch-modelled registration stays `exact`; a probe that cannot run hedges instead of claiming certainty. Known limitation, pinned by a test rather than left implicit: when a `@This()` alias's NAME differs from its container's (`const Element = @This();` inside `main.zig`), the receiver does not resolve and the reference is declined. The provider has `rewriteZigThisAlias` for this, but it is applied to type nodes and extending it to reference receivers would change existing CALL-site behaviour. Lightpanda's `const Foo = @This();` in `Foo.zig` convention makes the names coincide, which is why the corpus is unaffected. * fix(review): bump the parse-cache schema and resolve module-owned value references Review round 2 on #3219. Four inline items, all reproduced against the worktree before deciding. P1 — the new captures could stay INERT on a warm parse cache. Adding `@reference.value-ref` rules to `ZIG_SCOPE_QUERY` changes `ParsedFile.referenceSites`, which is a PARSE-TIME fact, but `SCHEMA_BUMP` stayed at 93. A repo indexed before this branch and re-analyzed after it replays the old, empty site list for every unchanged `.zig` file — `--force` included, since shards are content-addressed — so no USES edge is emitted, the boundary probe measures a real zero, and `impact` on a registered accessor goes back to `epistemic: "exact"`. #3399 un-fixed on the incremental path most users are on, with every cold-run test still green. DECISIONS D1-1's "zero changes to … the schema" conflated the graph schema with the cache schema. Bumped 93 -> 98, not 94: #3190 claims 94 and #3179 claims 94 through 97 in one PR. `incremental-parse-cache.test.ts` re-pinned, with 93-97 added to the taken list. Re-check against origin/main and open PRs immediately before merging. P2 — a qualified value reference through a MODULE was declined with no hedge. R1-2 resolves a written receiver with `findClassBindingInScope`, which requires `isClassLike`; a namespace-only `@import` handle is not class-like, so const dom_utils = @import("dom_utils.zig"); // no `@This()` in that file pub const comparator = bridge.accessor(dom_utils.compare, null, .{}); resolved to nothing. That is not the conservative half of R1-2's trade-off: a declined site emits NO edge, so there is nothing for the probe to read and `impact` on `compare` reports `exact`. Silence, not a hedge — and the pass comment claiming otherwise was wrong on this path. `resolveValueRefTarget` now tries the second kind of owner a qualified name can have. `findNamespaceValueRefTarget` reads the file's `namespace` import edges for the handle and the target module's own `origin: 'local'` module-scope bindings for the member — the same channel `receiver-bound-calls` Case 1 already trusts for `dom_utils.compare()`, with Case 1's three guards for Case 1's reasons: `isNamespaceNameShadowed`, local-origin bindings only, and two distinct defs under one name resolve nothing. `CALL_TARGET_TYPES` gates module owners exactly as it gates container owners. Language-neutral: it reads generic namespace import edges, names no language. Still declined, deliberately: a receiver this index knows under no name at all (the `@This()`-alias case, owners outside the workspace). There the alternative is a confident edge to a lexically-nearer function the source did not name. P2 — `impact-callable-value-references.test.ts` was in neither vitest list. It opens a real engine via `withTestLbugDB(poolAdapter: true)`, so TESTING.md puts it in the `lbug-db` include list and the `default` exclude list; it was in neither, so `default` also collected it into the parallel pool. Added next to its `impact-epistemic-lower-bound` sibling in both arrays. P3 — DECISIONS.md was stale against HEAD and embedded host paths. D2-3 still described the `LIMIT 50` that R1-5 removed and D1-3 still described the receiver-blind resolution that R1-2 replaced; both now carry explicit "superseded by" pointers. The `~/code/...` and mise-node paths are replaced with placeholders. Two smaller corrections the new cause made necessary: - `formatImpactResult`'s `lower-bound` header hard-coded "callers binding via DI / dynamic dispatch", which now contradicts the value-reference bullet printed directly under it. The bullets carry the cause; the header only states that the count is a floor. - `tools.ts` said a `causes.callableValueReferences` of 0 means "nothing was missed". The dispatch exclusion is symbol-level, not edge-level, so a target with both a followed registration and an unfollowed escape also reads 0. The docs now say a 0 means "no unfollowed registration was proven". Fixture: `src/webapi/dom_utils.zig` (namespace-only) plus three cases in `Element.zig` — the module-qualified registration, a non-callable module member, and a `u8` parameter shadowing the handle. The shadow case was verified to FAIL with the guard disabled, so it is not passing for an unrelated reason. Gates: `tsc --noEmit` clean, `npm run build` clean, prettier clean. `resolvers/` 3,630 passed / 3 skipped; `unit/scope-resolution` 2,010 passed; `impact-callable-value-references` 7 passed under `lbug-db`; `incremental-parse-cache` 40 passed; eval formatters 104 passed. Bench --check all PASS with no baseline edited: receiver-resolution, zig-cross-file-resolution, scope-emission, callable-value-flow, scope-capture (15 languages), python-scope. * fix(review): guard the class receiver against a shadowing binding, and pin the dispatchability partition Review round 3 (`gitnexus-check` bot on `cf53bbaa`). Three findings, each reproduced against the code before deciding. R3-1 (Error, valid, REPRODUCED) — a CLASS receiver could resolve through a shadowing value binding. `findClassBindingInScope` is a class-only walk: it filters the scope chain by `isClassLike`, so it steps over a nearer binding that is a value and keeps climbing — and past the chain entirely, into a qualified-name fallback that answers with the unique workspace definition of the name. A `u8` parameter named `Ticker`, in a file that neither declares nor imports the `Ticker` container another file defines, therefore emitted `register(Ticker.fire)` as a confident USES edge to that container's method. That is exactly the wrong-edge failure R1-2 exists to prevent, arriving through the class channel instead of the lexical one. Fixed with `isOwnerNameShadowedBySomethingElse` — a sibling of `isNamespaceNameShadowed` with one extra clause. The plain namespace guard could NOT be reused: a container is often its own local declaration (`fn make() { const Local = struct {…}; register(Local.go); }`), and reading that binding as its own shadow suppresses precisely the resolutions this path exists to make — the #2723 mistake, one channel over. So a scope that binds the name answers immediately, and the answer is "not shadowed" only when one of that scope's own bindings IS the def just resolved. Both halves are pinned and both were verified to fail when the guard is weakened: the parameter case fails with no guard at all, the local-container case fails with the plain `isNamespaceNameShadowed`. R3-2 (Warning; mechanism correct, unreachable today; fragility fixed instead) — the dispatch exclusion could suppress an unfollowed registration. The bot is right about the code: sweep 2 synthesizes CALLS only for a registration whose site carried a `propertyKey`, while the exclusion zeroes the note on ANY inbound `property-dispatch` CALLS edge. It is not reachable in the current rule set, and the reason is measured rather than assumed: `@reference.value-ref` is emitted by exactly three languages — JavaScript (2 rules), TypeScript (2), Zig (3) — every JS/TS rule also captures `@reference.property-key` (both are object-literal shapes) and no Zig rule does. A dispatchable registration is therefore always a JS/TS one, an undispatchable one always a Zig one, and they cannot meet on one symbol. Rejected: splitting the edge `reason` into dispatchable / undispatchable. It is the precise fix, but it is a graph-content change that churns whichever side keeps the old literal — the Zig, TypeScript and probe suites all pin 'scope-resolution: value-ref' by hand as a drift canary — and it buys nothing against a case no rule can produce. What was actually wrong is that the exclusion's soundness rested on a coincidence recorded nowhere, in files nobody reading `local-backend.ts` would open. Fixed at both ends: the exclusion site now states the invariant, the three facts it rests on and the two options for when it breaks; and `value-ref-dispatchability.test.ts` fails the day it does — a JS/TS rule for a bare callback argument, a Zig rule that grows a key, or a fourth language emitting `value-ref` at all. Verified to fire (adding a property key to a Zig value-ref rule fails the Zig case), and its rule splitter has its own guard test so the suite cannot pass vacuously. `ZIG_SCOPE_QUERY` is exported for that test only. R3-3 (Nit, valid) — a test comment claimed the wrong epistemic result. The `declines a qualified reference whose receiver cannot be resolved` case said the shortfall shows up as `lower-bound`. It does not: with no edge there is no evidence and the target stays `exact`. The pass docstring was corrected in round 2 and this comment was missed. It now says the decline costs the reference AND the hedge, and why that is still the right trade. Gates: tsc --noEmit clean, npm run build clean, prettier clean. `test/integration/resolvers` 3,632 passed / 3 skipped (70 files); `test/unit/scope-resolution` 2,015 passed (120 files); `impact-callable-value-references` 7 passed under `lbug-db`. Bench --check: receiver-resolution, zig-cross-file-resolution, scope-capture (15 languages), scope-emission PASS with no baseline edited; callable-value-flow failed once on its TIMING budget (2.006 > 1.9) with a byte-identical fingerprint, then passed twice at 1.788 / 1.813 — machine load, not a regression. * fix(review): let a file's own `@import` outrank the workspace-wide class fallback Found by running the local preflight harness before pushing rather than after. One of its five findings is a real hole in R2-2; the rest are documentation that overclaimed. R4-1 (valid, REPRODUCED) — the container channel preempted the file's own `@import`. R2-2 added the module channel as a FALLBACK after `findClassBindingInScope`, and that order is wrong: `findClassBindingInScope` does not stop at the scope chain. When its `isClassLike` walk misses — and a namespace handle binds a Module, so it always misses — it falls back to `scopes.qualifiedNames`, a workspace-wide index, and answers with the unique def of that name anywhere in the repo. A container named `dom_utils` in a file `Element.zig` never imports therefore captured `bridge.accessor(dom_utils.compare, …)`, binding `Method:src/webapi/decoy.zig:dom_utils.compare#2` while the module channel that would have answered correctly was never reached. R3-1's shadow guard cannot catch it: the import binds at MODULE scope, which that guard treats as the floor. Fixed by trying the module channel FIRST. An `@import` written in this file is the strongest available statement about what the name means here and outranks a global uniqueness guess; when the handle is not an import of this file the channel answers nothing and the container path runs exactly as before. Pinned by `decoy.zig` and a strengthened assertion on the existing module-owner test, which fails on the old order. R4-2 — the cause documentation named shapes nothing captures. `tools.ts` illustrated `callableValueReferences` with "a callback argument", "a stored function pointer" and `qsort(xs, n, sz, compareItems)`. Only Zig captures a call argument or a const initialiser; JS/TS capture only object-literal property values, and C has no value-ref rule, so the `qsort` example is counted in no language. Both cause blocks now name the captured shapes and say that a bare JS/TS callback argument is not among them, so a 0 does not rule it out. R4-3 — the same block exempted itself from the re-index caveat this PR proves it needs. "Read from the graph, so it needs no index-time metadata" is true of the probe and false of the edges: an index built before a language emitted these captures has none and reports 0 — the warm-cache failure R2-1 bumped SCHEMA_BUMP for. It now says to re-analyze before reading a 0 as measured. R4-4 — the dispatchability canary was narrower than its own promise. Its header claimed it fails on "a fourth language emitting value-ref at all"; it reads `languages//query.ts`, so Vue — which owns no query and borrows `emitTsScopeCaptures` / `emitJsScopeCaptures` — emits value-refs while the assertion lists three languages, and a capture synthesized in code is invisible to it. The case now asserts on query OWNERS, which is what it checks and is sound because a delegating language inherits the rules it borrows; the header states the synthesized-capture gap instead of letting a green tick imply it away. R4-5 — the BARE docstring described a lexical walk that is not one. `findCallableBindingInScope` applies the callable predicate WHILE walking, so a nearer parameter or local is stepped over: the defect R3-1 fixed on the container channel, unguarded here, pre-existing since #2437 and reachable in JS/TS. Out of scope for #3399, so behaviour is unchanged and the sentence now says what the walk does rather than implying a guarantee it does not give. Gates: tsc --noEmit clean, npm run build clean, prettier clean. `test/integration/resolvers` 3,632 passed / 3 skipped (70 files); `test/unit/scope-resolution` 2,015 passed (120 files); `impact-callable-value-references` 7 passed under `lbug-db`. Bench --check: receiver-resolution, zig-cross-file-resolution, scope-capture (15 languages), scope-emission, callable-value-flow all PASS with no baseline edited. * fix(review): honour the hub opt-in for value references, so `hub.fn` means one thing Review round 5 (`gitnexus-check` bot on `1c7c05ff`). One finding, valid and reproduced before fixing. `findNamespaceValueRefTarget` accepted only `ref.origin === 'local'`. R2-2 recorded that as deliberate — "the `namespaceExportsIncludeImportedNames` hub opt-in is a provider decision this language-neutral pass does not make" — and that reasoning was wrong twice over. Zig sets the flag (`languages/zig/scope-resolver.ts:41`, measured on ghostty and tigerbeetle before it landed), and the pass does have the provider in scope: `runScopeResolution` takes one and already forwards several of its hooks to other passes. The result was the exact asymmetry R2-2 argued against in its own first paragraph. A Zig hub declares nothing — every name it publishes it imported — so requiring a local declaration declines every member reached through one: // hub.zig pub const scale = @import("dom_utils.zig").scale; // Element.zig const hub = @import("hub.zig"); pub fn callsThroughTheHub(v: u8) u8 { return hub.scale(v); } // resolved pub const scaled = bridge.accessor(hub.scale, null, .{}); // declined One name meaning two different things depending on whether a `(` follows it. Fixed by forwarding `provider.namespaceExportsIncludeImportedNames` into the pass and consulting the published channel when it is set — the same question `receiver-bound-calls` Case 1 asks, through the same `lookupBindingsAt` read `findExportedDefIncludingImportedNames` performs for the CALL form. The pass still names no language; it asks the provider, which is the sanctioned hook. Precedence is unchanged where it mattered: a locally declared member still wins over a republished one, two distinct defs under one name still resolve nothing, and `CALL_TARGET_TYPES` still gates the answer — pinned by `hub.DEFAULT_NS`, a re-exported CONSTANT, which stays unregistered. Languages that do not opt in are unaffected: the parameter defaults to `false`, and passing `false` was verified to fail the new hub test, so it is load-bearing. The fixture republishes a member (`scale`) that nothing else uses, so the hub assertion cannot be satisfied by an edge another case emitted. Gates: tsc --noEmit clean, npm run build clean, prettier clean. `test/integration/resolvers` 3,634 passed / 3 skipped (70 files); `test/unit/scope-resolution` 2,015 passed (120 files); `impact-callable-value-references` 7 passed under `lbug-db`. Bench --check: receiver-resolution, zig-cross-file-resolution, scope-capture (15 languages), scope-emission, callable-value-flow all PASS with no baseline edited. Also recorded in DECISIONS.md: `/autofix` returned "No successful autofix run" because the `PR Autofix` run on `1c7c05ff` failed at `actions/upload-artifact` with a 403 from GitHub's artifact storage, not because of anything in this branch. This push triggers a fresh run. * fix(review): inspect the module scope in the owner-shadow guard, and check the TSX suffix The `gitnexus-check` pass on `bd6e577e` carried three findings of its own, and I answered the later `1c7c05ff` pass without noticing them. Recording that as a process failure too: bot reviews are per-head, and a later pass does not necessarily repeat an earlier one's findings. R6-1 (Error, valid, REPRODUCED) — the shadow guard stopped one rung short. `isOwnerNameShadowedBySomethingElse` returned `false` on reaching the module scope, justified as "a container declared there IS the binding, and the caller already resolved it". That holds when the owner came from the scope chain and fails when it came from the workspace-wide qualified-name fallback: // Gauge.zig — never imported by Element.zig const Gauge = @This(); pub fn read(self: *Gauge) u8 { … } // Element.zig const Gauge = @import("dom_utils.zig").DEFAULT_NS; // NOT a container pub const level = bridge.accessor(Gauge.read, null, .{}); // → Gauge.zig's read `findClassBindingInScope` steps over the module-scope binding because it is not class-like, answers from `scopes.qualifiedNames`, and the guard waved it through. Worth recording: the first fixture attempt did NOT reproduce. A local `const Gauge: u8 = 3;` also claims the workspace qualified name `Gauge`, leaving two candidates, and the fallback refuses to guess between two — so the shape defeated itself. Binding the name by IMPORT claims no qualified name, the fallback stays unique, and it fires. A negative result on the first shape was not evidence the finding was wrong. Fixed by inspecting the module scope as the last rung instead of skipping it. The identity exemption is what makes that safe where `isNamespaceNameShadowed` cannot do it (#2723: a namespace import writes its own name into the module scope and would read as its own shadow) — the binding that IS the owner exempts itself, and only a binding to something else answers `true`. `lookupBindingsAt` is consulted at that scope and only there, because an imported alias lives in the finalized channel rather than in `scope.bindings`. R6-2 — the hub re-export finding, already fixed in `5d8fe9d8`; the same defect restated on the later head. R6-3 (valid, fixed) — the dispatchability canary omitted the TSX suffix. `getTsScopeQuery` analyzes a `.tsx` file with `TYPESCRIPT_SCOPE_QUERY + TSX_JSX_QUERY_SUFFIX`, and the test read only the base, so a `value-ref` rule added to the suffix would be emitted in TSX analysis with the canary green. The suffix is now exported and concatenated into the check; verified load-bearing by adding an unkeyed `jsx_expression` value-ref rule to it, which fails the TypeScript case. Gates: tsc --noEmit clean, npm run build clean, prettier clean. `test/integration/resolvers` 3,635 passed / 3 skipped (70 files); `test/unit/scope-resolution` 2,015 passed (120 files); `impact-callable-value-references` 7 passed under `lbug-db`. Bench --check: receiver-resolution, zig-cross-file-resolution, scope-capture (15 languages), scope-emission, callable-value-flow all PASS with no baseline edited. * perf(bench): gate callable-value reference resolution on linear scaling `resolveValueRefTarget` (#3399) replaced one lexical walk with four channels, and the last of them — a qualified receiver resolved through `findClassBindingInScope` — falls back to `scopes.qualifiedNames`, a WORKSPACE-WIDE index consulted once per site. Keyed that is O(1); scanned it is O(files) per site, and a registration table that costs O(sites) today costs O(sites x files) tomorrow. Nothing in the suite can see that: the fixtures are single-file, and the corpus it actually matters on is lightpanda-io/browser, where `bridge.{accessor,function,…}` appears 2,047 times across 257 files. `bench/value-ref-resolution/measure.mjs` builds two synthetic Zig corpora of identical shape 4x apart in file count, and times ONLY the per-site resolution loop — extraction, `reconcileOwnership` and finalize are setup. `linear_factor` is `(t_large/t_small)/(N_large/N_small)`: measured 1.00-1.09 across runs, with `us_per_site` flat at 1.4-1.5 between the arms. Correctness comes first, because a timing gate alone is satisfied by a fast wrong answer: exact site/resolved/declined counts per arm plus an order-independent sha256 over every (site -> resolved target) pair. The corpus exercises all four channels — CONTAINER, NAMESPACE, HUB and BARE — and carries two DECLINE controls per module (a non-callable namespace member, a non-callable bare argument), so a widened callable gate moves `declined` instead of hiding inside the timing. Verified load-bearing rather than assumed: making the qualified lookup scan a workspace-sized collection leaves the fingerprint IDENTICAL and takes `linear_factor` to 3.752 against a slack of 1.375 — the regression class this exists for is exactly the one no correctness gate can see. Zig is the corpus because it is the only language whose provider sets `namespaceExportsIncludeImportedNames`, so it is the only one that can exercise the hub channel at all; the pass itself names no language. `resolveValueRefTarget` is exported for the bench. Timing `emitPropertyDispatchCalls` instead would fold the signal into edge emission, and re-implementing the channel order in the bench would pin the bench's idea of the function rather than the function. * feat(zig): index every build package in the repo, not only the root one Zig already had workspace setup — `loadZigBuildConfig` has parsed `build.zig.zon` `.path` deps, the root `build.zig`'s named modules and each build module's own `addImport` table since #1432, threaded through `ScopeResolver.loadResolutionConfig` exactly as tsconfig is for TypeScript. What it did not have is the part `tsconfigFor` supplies: per-package scope. `loadZigBuildConfig` reads `/build.zig{,.zon}` and nothing else, so a repo laying its packages out as `packages//build.zig` — no root build files at all — got `null`, and EVERY bare `@import("")` in it went unresolved. Cross-file resolution silently degraded to relative imports. Measured on a two-package probe before this change: `config = null`, `@import("core")` from `packages/app/src/main.zig` → `null`. `loadZigWorkspaceIndex` discovers packages the way `findTsconfigFiles` discovers configs — one bounded breadth-first walk that skips the hardcoded ignore set — and `zigPackageFor` is the `tsconfigFor` analogue: the nearest enclosing package governs a file, deepest-first, with no fall-through to an outer package. Fall-through is what makes a vendored dependency's `@import("config")` resolve to the outer repo's `config` module, the same failure `loadTsconfigIndex` documents for a package declaring no `baseUrl`. `resolveZigImportInternal` is UNCHANGED — impact analysis puts it at HIGH risk with 6 dependents, and it does not need to move: it is handed one package's config, and which config it receives is the only difference. Its 48 existing tests pass untouched. A nested package's paths are rebased to repo-relative at load time (`.path = "../core"` → `packages/core`, which the root-relative reading rejects outright as an escape); the ROOT package keeps its raw spelling, which is what `parseZigBuildZon` promises and its tests pin, so a single-package repo is byte-identical. `loadImportConfigs` — which runs unconditionally for every repo, Zig or not — keeps calling the root-only loader, so no non-Zig repo pays for the walk. That is the split TypeScript already has between the cheap `loadTsconfigPaths` and the repo-walking `loadTsconfigIndex`, which `loadResolutionConfig` reaches only during a language pass. The `zig-monorepo` fixture carries the discriminating case rather than only the happy path: `tool` binds the alias `core` to its OWN `src/core.zig`, so a repo-wide flattened module map — the shape a workspace index invites — would point `measure` at `packages/core`, a confident edge into a package `tool` does not depend on. That is worse than the unresolved import this fixes, and only per-package scoping keeps them apart. Tests: 7 unit cases pinning the index (including `loadZigBuildConfig` answering `null` on the same fixture, side by side) and 3 integration cases pinning the EDGES — cross-package CALLS from the module root AND from a non-root file, and `tool` not crossing over. Verified load-bearing: restricting the index to the root package fails all three. Second consumer caught by the integration test and fixed with it: `populateZigWorkspaceStaticGating` reads the same `resolutionConfig`, per file because two files of that pass can belong to different packages. Gates: build clean, `tsc --noEmit` clean, prettier clean, eslint 0 errors. `test/integration/resolvers` 3,768 passed / 4 skipped, `test/unit/scope-resolution` 2,015 passed. Bench `--check` with NO baseline edited: `receiver-resolution` (whose corpus is `test/fixtures/lang-resolution`, where the fixture lands), `zig-cross-file-resolution`, `value-ref-resolution`, `scope-capture` (15 languages), `import-target`, `callable-value-flow`, `scope-emission`. * perf(zig): dequeue the package walk by head index, not `shift()` `findZigPackageDirs` pushes children while it drains the queue, which keeps the array in a mode where `Array.prototype.shift()` memmoves the whole remainder rather than taking V8's left-trimming fast path — so the walk is quadratic in the frontier, bounded only by `ZIG_SCAN_MAX_DIRS` (20,000). Measured at that bound rather than estimated from the move count: 53 ms at fan-out 4 and 81 ms at fan-out 20, against 0.8 ms with a head index — 66-106x, and paid before a single config is read. FIFO order is unchanged, and the package ordering never depended on the walk anyway: `loadZigWorkspaceIndex` sorts by (dir length desc, localeCompare). Memory is unchanged too — the entries were already retained by the pushes; `shift()` only dropped the head. `findTsconfigFiles` and `loadNodeWorkspacePackages` carry the same walk with the same bound and are equally affected. Deliberately NOT fixed here: they are pre-existing, outside this PR's diff, and reach TypeScript/Node import resolution, which nothing in this change set covers. Filed separately. Reported by gitnexus-check on aea0ab06. * chore: drop DECISIONS.md from the branch Review feedback: the working log does not belong in the repository. The reasoning it carried that is still load-bearing lives in the code comments and in the PR description. * perf(bench): gate value-ref resolution on scaling alone, not wall clock A millisecond ceiling measures the runner. This repo has been bitten by that twice already — bench/callable-value-flow's widening_overhead failed at 2.07 and 1.975 against a 1.9 budget on a shared runner while the code was correct, both times on a sub-11ms measurement — so the arm is dropped rather than loosened. `ms_budget` is gone from both arms and from `--check`; `min_ms` and `us_per_site` are still reported, and nothing compares them to anything. The remaining timing gate is the ratio, reshaped after bench/parse-dispatch-rounds/baselines.json: `_what` / `_triage` notes, a `_measured` block recording samples for context, and min-of-15 reps instead of 7 (bench/import-target measured N=5 tripping its own budget about one run in twenty, N=15 holding). Budget 1.6 — 1.40x the measured maximum over 12 runs (0.907 .. 1.146), the ~1.5x headroom its siblings use on ratios. Re-verified rather than re-quoted, and the previous note was wrong: replacing QualifiedNameIndex.get with a full scan moves linear_factor from ~1.0 to 2.07, not the ~3.7 recorded. Only 1 of the 17 value-ref sites per module reaches that workspace-wide fallback. 1.6 sits clear of both ends. The note also records the trap the first attempt fell into: patching gitnexus-shared/src changes nothing, because the bench resolves the built package. * fix(review): settle namespace precedence on the name, before the type gate findNamespaceValueRefTarget's local lookup applied CALL_TARGET_TYPES while selecting, so a target module declaring a NON-callable under the name answered nothing and fell through to the published channel — binding a re-exported callable under a name the module's own declaration owns. findExportedDef does not do that: it returns any local def and lets its caller's type gate reject it, so findExportedDefIncludingImportedNames never reaches the imported names for a name the file declares. `x.f` and `x.f()` must not disagree about which module owns f. Not reachable through valid Zig today (a container cannot declare a name twice, and Zig is the only provider setting namespaceExportsIncludeImportedNames), which is why the regression test builds the indexes directly instead of adding a fixture — there is no valid source to write. Its second case fails with the guard removed. Three smaller corrections from the same review: - ZigBuildZonConfig.pathDeps promised the raw `.path` string; a nested package stores the normalized repo-relative value. The interface now documents both spellings and why either is safe to hand to normalizeZigDepPath. Comment only — no behaviour change. - The 'single-package repo' compatibility test ran against zig-idioms, which declares libs/geo as a path dep and that directory has its own build.zig, so the walk finds two packages and the name was a claim rather than a check. It now runs against libs/geo itself and asserts the package list is exactly ['']; a second test pins the multi-package case, including that a file inside libs/geo is governed by that package and not by the root. - The lower-bound header asserted 'some callers are not traced'. callableValueReferenceBoundaries hedges when its probe could not RUN and says whether the symbol is registered is unknown, so the header claimed an omission nothing established. It now says the count may be incomplete and names no cause; the per-cause bullets underneath carry that. * feat(zig): bind a `@This()` alias to the container it names `@This()` IS the enclosing container, and `const Self = @This();` is how most Zig files say so. The container is minted under the FILE STEM and the alias bound nothing class-like: a file-level alias mints no Const at all (isZigFileThisAlias suppresses it so it cannot shadow the type for `w: *Widget`), a container-level one mints a Variable every isClassLike walk steps over. So `Self.member` resolved to nothing — not a wrong edge, no edge, and a caller list missing it is the false confidence #3399 is about. This was the PR's declared known limitation. bindZigThisAliases binds the alias name to its container definition in indexes.bindingAugmentations — the sanctioned post-finalize channel (I8), consulted only AFTER a scope's own bindings, so it can never outrank a real local declaration and can only answer where nothing answered before. Nothing is replaced or removed. No query, capture or SCHEMA_BUMP change. It runs inside populateZigRangeBindings, sharing that pass's parsed tree: a pass of its own would re-parse every Zig file on a cold tree cache. Only aliases declared directly in a container body or at file level are bound — the same set collectZigThisAliases recognizes — because a function-local alias belongs in that function's scope. Measured cold-index before/after, same command, same build: tigerbeetle (246 .zig) CALLS 17,066 -> 17,140 (+74), USES 437 -> 443, MEMBER_OF 5,131 -> 5,143; every other edge type unchanged, all 24 node-label counts identical mach (132 .zig) CALLS 7,600 -> 7,615 (+15), MEMBER_OF +1, USES flat The +74 matches a source census of 72 `Alias.member(` call sites in tigerbeetle files whose alias differs from the stem. Spot-checked end to end: src/aof.zig:636 writes `try AOF.init(io, output_path)` inside AOFType, and the edge AOFType.merge -> AOFType.init#2 is present after and absent before. Node totals move only through the derived layers (Community 524->527, Process 774->763) — no source symbol added or removed. Scale of what was dropped: 73 of ghostty's 185 `@This()` files, 93 of tigerbeetle's 94 and 8 of mach's 42 spell the alias differently from the stem, carrying 302 `Alias.member` references between them, 96 of those calls. Fixture zig-idioms/src/webapi/Widget.zig exercises both paths — a file-level `Self` and a container-level `Me` in Metrics — through a call and a registration. Five cases; three fail with the binding disabled, and the two that do not are the controls: Element.zig's stem-spelled alias must keep resolving byte-identically, and the alias name must not become resolvable from another file. * docs(review): stop claiming what the alias pass does not do Two comments asserted things the adjacent code does not support. The alias pass's call site said it ran before the payload walk "so a subject spelled through the alias resolves here too". It does not: the payload walk types subjects through findReceiverTypeBinding, which reads typeBindings plus the namespace/workspace type channels and never bindingAugmentations, where bindZigThisAliases writes. Measured on `for (Self.items) |it|` — `it` is bound neither before nor after. The pass is in that loop for the tree and nothing else, which is now what the comment says. Nor is that a gap to close by also writing a typeBinding: no container name has one, the file stem included, so a payload subject written `Type.member` resolves for no spelling at all. Giving the alias an entry would make it behave unlike the container it names. Recorded at the call site so the next reader does not re-derive it. The module-shadow test said Element.zig declares `const Gauge: u8 = 3;`. It binds `Gauge` by IMPORT, and the distinction is the point of the fixture: a local declaration would also claim the workspace qualified name `Gauge`, leaving two candidates, and the fallback refuses to guess between two — so the case the test exists for would never be reached. The comment now says which binding it is and why the other shape would be self-defeating. Comment-only: node and edge counts are byte-identical across a full re-index (52,083 / 165,261 both sides). * docs(zig): stop describing loadZigBuildConfig as root-only in the present tense It takes a `packageDir` since this branch and reads `path.join(repoRoot, packageDir, name)`; `loadZigWorkspaceIndex` is what supplies it one, per package. Two comments still described the historical root-only invocation as the function's current behaviour: - the monorepo integration-test header, flagged by review; - loadZigWorkspaceIndex's own docstring — the same sentence, in the function that calls it WITH a packageDir a few lines below, so fixing only the test copy would have left the worse of the two. Both now attribute root-only reading to the CALL (no `packageDir`), which is what the argument actually rests on: a monorepo has no root build files, so that call answers null and every bare @import goes unresolved. The trailing note about `loadImportConfigs` is reworded the same way — it calls the loader for the root package alone; the loader is not root-bound. Comment-only: node and edge counts byte-identical across a full re-index (52,083 / 165,261 both sides), and detect-changes reports the two hunks overlap no indexed symbol. * fix(zig): a written namespace handle owns its own decline `findNamespaceValueRefTarget` returning `undefined` conflated two different answers: "no namespace import named this receiver" and "the module this file named does not expose that member as a callable". Only the first should fall through to the container channel. The second did too, and `findClassBindingInScope`'s miss path answers from the WORKSPACE-wide qualified-name index — so a same-named container in a file this one never imported supplied the member the written module does not have. The owner-shadow guard does not stop it, which is the part that is not obvious: a plain `const utils = @import("utils.zig");` records a namespace IMPORT EDGE, not a module-scope binding, so the guard finds nothing bound under the name and reads the container as unshadowed. It catches `const Gauge = @import(x).MEMBER` (a real binding) and misses the handle form. Reproduced before fixing, not argued: `decoy.zig`'s `dom_utils` struct gains `onlyOnDecoy`, a callable `dom_utils.zig` does not have, and `Element.zig` registers `dom_utils.onlyOnDecoy`. That minted `JsApi -> onlyOnDecoy` — a confident USES edge into a file `Element.zig` never imports, the wrong-edge failure this PR exists to avoid, arriving through the container channel after the namespace channel said no. The channel now returns 'owned' for every outcome reached once the receiver is established as this file's unshadowed namespace handle, and the caller declines on it. A locally shadowed handle still falls through, because there the name does not mean the import at that site and the container channel's guard is the right decider. Bench fingerprint and counts unchanged; no baseline edited. * fix(zig): reject an absolute nested-package `.path` before rebasing it The nested-package branch prefixes the package directory and THEN normalizes, so an absolute `.path` stops looking absolute on the way: `packages/app/` plus `/src` is `packages/app//src`, which is relative by inspection. `normalizeZigDepPath` drops the empty segment and the dep lands on `packages/app/src` — a directory that really exists — so a dependency pointing outside the repository is fabricated into an in-repo resolution. The root package was never affected: its prefix is empty, so the value reached the check as written. `isAbsoluteZigDepPath` is now asked of the value AS WRITTEN, before any prefixing, and `normalizeZigDepPath` asks the same helper so the two cannot drift. `..` is deliberately not handled there: `../core` escapes the package but not the repo, and rebasing it is what the branch exists to do — `normalizeZigDepPath` still rejects what escapes the ROOT afterwards. Note for the reviewer: `path.posix.join(pkg, depPath)` does NOT fix this. `join('packages/app/', '/dep')` is `packages/app/dep` — it strips the leading slash too, producing the same fabricated path without rejecting anything. Measured before writing the fix. Regression test pins it through the real loader: `packages/app` declares `.escapes = .{ .path = "/src" }`, and the test fails without the guard. `impact normalizeZigDepPath` is HIGH (14 impacted, 4 direct, exact). The edit is behaviour-preserving for that function — the same two conditions moved into a named helper it calls — and its existing absolute-path suite, POSIX, Windows drive and UNC spellings included, passes unchanged. * docs(mcp): stop defining lower-bound as proof that callers were missed The CLI header was corrected in round 8; the MCP tool contract still made the assertion the CLI stopped making. `context` said lower-bound "means callers exist that this view provably does not list" and `impact` said "the walk provably missed callers" — but `callableValueReferenceBoundaries` also publishes lower-bound when its probe could not RUN, and says in its own note that whether the symbol is registered is unknown. A client following the contract would read an unanswered question as evidence of an omission. Both now define it as a FLOOR with two possible causes — the walk provably missed callers, or a probe that would have established completeness could not run — and point at `boundaries` for which. That keeps the common case exactly as strong as it was; it only stops the contract asserting the one case it cannot support. The `causes.callableValueReferences` bullet already documented the probe-failure branch, so the headline was contradicting the body. `local-backend.ts` quotes that definition to justify hedging; the quote is updated to name which half it relies on. --------- Co-authored-by: Gergő Magyar --- .github/workflows/ci-tests.yml | 12 + .../bench/value-ref-resolution/baseline.json | 37 ++ .../bench/value-ref-resolution/measure.mjs | 303 ++++++++++++++ gitnexus/src/cli/eval-server.ts | 16 +- .../src/core/ingestion/language-config.ts | 288 ++++++++++++- .../ingestion/languages/typescript/query.ts | 8 +- .../src/core/ingestion/languages/zig/query.ts | 62 ++- .../ingestion/languages/zig/range-binding.ts | 26 ++ .../ingestion/languages/zig/scope-resolver.ts | 24 +- .../languages/zig/this-alias-bindings.ts | 191 +++++++++ .../languages/zig/workspace-static-gating.ts | 12 +- .../passes/property-dispatch.ts | 316 ++++++++++++++- .../scope-resolution/pipeline/run.ts | 5 + .../scope-resolution/scope/walkers.ts | 76 ++++ .../scope-resolution/value-ref-edges.ts | 14 + gitnexus/src/mcp/local/local-backend.ts | 201 +++++++++ gitnexus/src/mcp/tools.ts | 10 +- gitnexus/src/storage/parse-cache.ts | 19 +- .../lang-resolution/zig-idioms/src/main.zig | 1 + .../zig-idioms/src/webapi/Element.zig | 224 ++++++++++ .../zig-idioms/src/webapi/Gauge.zig | 10 + .../zig-idioms/src/webapi/Ticker.zig | 13 + .../zig-idioms/src/webapi/Widget.zig | 70 ++++ .../zig-idioms/src/webapi/decoy.zig | 24 ++ .../zig-idioms/src/webapi/dom_utils.zig | 24 ++ .../zig-idioms/src/webapi/hub.zig | 10 + .../lang-resolution/zig-monorepo/README.md | 19 + .../zig-monorepo/packages/app/build.zig | 7 + .../zig-monorepo/packages/app/build.zig.zon | 19 + .../zig-monorepo/packages/app/src/main.zig | 18 + .../zig-monorepo/packages/app/src/util.zig | 8 + .../zig-monorepo/packages/core/build.zig | 5 + .../zig-monorepo/packages/core/src/root.zig | 13 + .../zig-monorepo/packages/tool/build.zig | 8 + .../zig-monorepo/packages/tool/src/core.zig | 5 + .../zig-monorepo/packages/tool/src/main.zig | 7 + .../impact-callable-value-references.test.ts | 231 +++++++++++ .../test/integration/resolvers/zig.test.ts | 383 ++++++++++++++++++ .../test/unit/incremental-parse-cache.test.ts | 19 +- .../value-ref-dispatchability.test.ts | 173 ++++++++ .../value-ref-namespace-precedence.test.ts | 170 ++++++++ .../test/unit/zig-import-resolver.test.ts | 137 +++++++ gitnexus/vitest.config.ts | 2 + 43 files changed, 3163 insertions(+), 57 deletions(-) create mode 100644 gitnexus/bench/value-ref-resolution/baseline.json create mode 100644 gitnexus/bench/value-ref-resolution/measure.mjs create mode 100644 gitnexus/src/core/ingestion/languages/zig/this-alias-bindings.ts create mode 100644 gitnexus/src/core/ingestion/scope-resolution/value-ref-edges.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Element.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Gauge.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Ticker.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Widget.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/decoy.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/dom_utils.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/hub.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-monorepo/README.md create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/app/build.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/app/build.zig.zon create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/app/src/main.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/app/src/util.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/core/build.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/core/src/root.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/tool/build.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/tool/src/core.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/tool/src/main.zig create mode 100644 gitnexus/test/integration/impact-callable-value-references.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/value-ref-dispatchability.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/value-ref-namespace-precedence.test.ts diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 01cfa24b6..b19785cfa 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -774,6 +774,18 @@ jobs: run: node --import tsx bench/objective-c-resolution/measure.mjs --check working-directory: gitnexus + - name: Callable-value reference resolution guards (#3399) + if: ${{ !cancelled() }} + # Build-free: pins the resolved-target SET of `resolveValueRefTarget` + # (exact site/resolved/declined counts plus an order-independent + # fingerprint) and asserts its per-site cost stays independent of + # workspace size across a 4x file-count step. The pass resolves a + # qualified receiver through `scopes.qualifiedNames`, a workspace-wide + # index: keyed it is O(1) per site, scanned it is O(files) — a + # regression a fixture cannot see and a 257-file binding table can. + run: node --import tsx bench/value-ref-resolution/measure.mjs --check + working-directory: gitnexus + - name: CFG construction time / disk / memory guards (#2081 M1) if: ${{ !cancelled() }} # Build-free: asserts collectFunctionCfgs output is unchanged diff --git a/gitnexus/bench/value-ref-resolution/baseline.json b/gitnexus/bench/value-ref-resolution/baseline.json new file mode 100644 index 000000000..7b23bcae5 --- /dev/null +++ b/gitnexus/bench/value-ref-resolution/baseline.json @@ -0,0 +1,37 @@ +{ + "_what": "Baselines for bench/value-ref-resolution/measure.mjs --check (#3399). Guards the resolved-target SET of `resolveValueRefTarget` and the per-site cost of its four channels across a 4x file-count step, over a synthetic Zig corpus whose shape is fixed in measure.mjs. Per module: 12 CONTAINER registrations (`ElementN.getJ`), 1 NAMESPACE (`dom_utils.compare`), 1 HUB (`hub.compare`), 1 BARE (`register(onTick)`), and 2 declines (a non-callable namespace member `dom_utils.DEFAULT_NS`, a non-callable bare argument `Bridge(ElementN)`) — 17 sites, 15 resolved.", + + "_triage": "READ THIS BEFORE RE-RUNNING. modules, files, value_ref_sites, resolved, declined and fingerprint are DETERMINISTIC: a re-run never changes them, and none may be re-baselined to make CI green — drift means the resolved target set moved, which is a behaviour change to explain. linear_scaling_budget is the only timing arm; runner contention dominates it, so re-run alone on an idle machine and read `reps` in the report before investigating. If exactly one arm fails and it is that one, suspect the machine.", + + "small": { + "modules": 80, + "files": 240, + "value_ref_sites": 1360, + "resolved": 1200, + "declined": 160, + "fingerprint": "4ca2933e25cfa9c84518fca6608c9a6f318f81a1c5bad63cb2f09b7394bfb792" + }, + "large": { + "modules": 320, + "files": 960, + "value_ref_sites": 5440, + "resolved": 4800, + "declined": 640, + "fingerprint": "249266f180388e9f1240a7e81f1141afbc51ef25d5c4b3286a681992dab69e61" + }, + + "linear_scaling_budget": 1.6, + "_linear_scaling_note": "(t_large/t_small)/(320/80) over the per-site resolution loop; ~1.0 is linear. A RATIO rather than a millisecond ceiling, and there is deliberately no ms gate at all: wall-clock measures the runner, and this repo has been bitten twice by a fixed budget (bench/callable-value-flow's widening_overhead failed at 2.07 and 1.975 against 1.9 on a shared runner while the code was correct, both on a sub-11ms measurement). Same reasoning, same shape as bench/parse-dispatch-rounds' pack_scaling_budget. Budget is 1.6 — 1.40x the measured maximum, matching the ~1.5x its siblings use on ratios. What it catches: the four channels each consult a wider index than the last, and channel 4 (CONTAINER) reaches `scopes.qualifiedNames`, a WORKSPACE-WIDE index — keyed it is O(1) per site, scanned it is O(names) per site. Verified load-bearing rather than assumed: replacing `QualifiedNameIndex.get` with a full scan (in gitnexus-shared/dist — the bench resolves the built package, so patching src changes nothing) takes the factor from ~1.0 to 2.07, well clear of this budget. Only 1 of the 17 sites per module reaches that fallback (the `dom_utils.DEFAULT_NS` decline, whose module receiver is not class-like), which is why the signal is 2.07 and not the ~4 a per-site scan on every site would give.", + + "_measured": { + "linear_factor": 1.146, + "linear_factor_samples": [ + 0.907, 0.969, 0.988, 1.03, 1.043, 1.048, 1.05, 1.051, 1.052, 1.107, 1.123, 1.146 + ], + "small_ms": 2.078, + "large_ms_4x": 9.197, + "us_per_site": "1.36-1.53 small / 1.37-1.69 large", + "reps": 15 + }, + "_measured_note": "Maxima over 12 runs on a box that was NOT idle, so the spread is an upper bound on real noise. small_ms / large_ms_4x / us_per_site are recorded for context only — NOTHING gates on them, because an absolute millisecond is exactly the gate this file avoids. `us_per_site` staying flat between the two arms is the same property linear_scaling_budget gates, read directly." +} diff --git a/gitnexus/bench/value-ref-resolution/measure.mjs b/gitnexus/bench/value-ref-resolution/measure.mjs new file mode 100644 index 000000000..a1c14f448 --- /dev/null +++ b/gitnexus/bench/value-ref-resolution/measure.mjs @@ -0,0 +1,303 @@ +#!/usr/bin/env node +/** + * Build-free scaling and correctness guard for callable-value reference + * resolution (#3399). + * + * WHAT IS GUARDED. `resolveValueRefTarget` is the per-site half of + * `emitPropertyDispatchCalls`: for every `value-ref` reference site it names the + * callable the source handed over as a value. #3399 replaced a single lexical + * walk with four channels, and each one reaches for a wider index than the last: + * + * 1. BARE `register(onTick)` — `findCallableBindingInScope` + * 2. NAMESPACE `bridge.accessor(utils.compare, …)` — the file's own + * namespace `@import` edges, then the target's module scope + * 3. HUB `bridge.accessor(hub.compare, …)` — the same, through the + * finalized/augmented channel a re-export publishes into + * 4. CONTAINER `bridge.accessor(Element.getNamespaceUri, …)` — + * `findClassBindingInScope`, whose miss path falls back to + * `scopes.qualifiedNames`, a WORKSPACE-WIDE index + * + * Channel 4 is why this bench exists. A workspace-wide index consulted per site + * is linear only while the lookup is keyed; make it a scan — or make any of the + * three guards around it (`isOwnerNameShadowedBySomethingElse`, + * `isNamespaceNameShadowed`, `findOwnedMember`) walk a collection that grows + * with the repo — and a registration table that costs O(sites) today costs + * O(sites x files) tomorrow. That regression is invisible on a fixture and + * expensive on lightpanda-io/browser, where `bridge.{accessor,function,…}` + * appears 2,047 times across 257 files. + * + * HOW. Two corpora of identical shape, 4x apart in file count, and the per-site + * resolution loop is the ONLY thing timed — extraction, ownership reconciliation + * and finalize are setup. `linear_factor` is `(t_large/t_small) / (N_large/N_small)`: + * ~1.0 linear, ~4.x quadratic on this 4x step. + * + * A RATIO IS THE ONLY TIMING GATE — no millisecond ceiling, deliberately. + * `min_ms` and `us_per_site` are printed for context and nothing compares them + * to anything: a wall-clock budget measures the runner, and this repo has been + * bitten by that twice already (`bench/callable-value-flow`'s `widening_overhead` + * failed at 2.07 and 1.975 against a 1.9 budget on a shared runner while the + * code was correct, both times on a sub-11ms measurement). Dividing the large + * arm by the small one divides the machine out, which is what + * `bench/parse-dispatch-rounds` settled on for the same reason. + * + * A timing gate alone would be satisfied by a fast wrong answer, so the + * correctness half is exact and comes first: the site/resolved/declined counts + * per arm, plus an order-independent sha256 over every (site -> resolved target) + * pair. The fingerprint is a CORRECTNESS gate — drift means the resolved target + * set moved, which is a behaviour change to be explained, never re-baselined to + * make CI green. + * + * WHY A ZIG CORPUS for a language-neutral pass. Zig is the only language whose + * provider sets `namespaceExportsIncludeImportedNames`, so it is the only one + * that can exercise channel 3 at all; and the file-as-struct idiom puts channels + * 2 and 4 in one file, which is the shape #3399 was filed over. The corpus also + * carries two DECLINE controls — a non-callable namespace member and a + * non-callable bare argument — so a change that widened the callable gate would + * move `declined` rather than hiding inside the timing. + * + * Container naming is load-bearing in the corpus: a Zig file-as-struct is minted + * under the FILE STEM, so `ElementN.zig` must write `const ElementN = @This();` + * and register `ElementN.getJ`. Spelling the alias `Element` instead is the + * documented `@This()`-alias limitation, every channel-4 site declines, and the + * bench would time a corpus that resolves nothing. + * + * Usage: + * node --import tsx bench/value-ref-resolution/measure.mjs + * node --import tsx bench/value-ref-resolution/measure.mjs --check + */ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { performance } from 'node:perf_hooks'; + +import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.ts'; +import { finalizeScopeModel } from '../../src/core/ingestion/finalize-orchestrator.ts'; +import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.ts'; +import { reconcileOwnership } from '../../src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.ts'; +import { resolveValueRefTarget } from '../../src/core/ingestion/scope-resolution/passes/property-dispatch.ts'; +import { zigScopeResolver } from '../../src/core/ingestion/languages/zig/scope-resolver.ts'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SMALL_MODULES = 80; +const LARGE_MODULES = 320; +/** Registrations per module through the CONTAINER channel. */ +const ACCESSORS_PER_MODULE = 12; +/** + * Min-of-N, and N is 15 rather than a handful: `bench/import-target` measured + * N=5 tripping its own budget about one run in twenty while N=15 held every + * language inside a 1.13-1.26x swing, and `bench/parse-dispatch-rounds` uses 15 + * on the same grounds. The whole run is ~5 s, so the reps are nearly free. + */ +const REPS = 15; + +/** + * One module = three files, mirroring `test/fixtures/lang-resolution/zig-idioms/ + * src/webapi/`: a namespace-only helper, a hub that re-exports one of its + * members and declares nothing, and a file-as-struct carrying the binding table. + */ +function moduleFiles(i) { + const accessors = Array.from( + { length: ACCESSORS_PER_MODULE }, + (_, j) => `pub fn get${j}(self: *Element${i}) u8 { return self._n; }`, + ).join('\n'); + const registrations = Array.from( + { length: ACCESSORS_PER_MODULE }, + (_, j) => ` pub const a${j} = bridge.accessor(Element${i}.get${j}, null, .{});`, + ).join('\n'); + return [ + { + path: `src/dom_utils${i}.zig`, + content: `pub const DEFAULT_NS: u8 = 7;\npub fn compare(a: u8, b: u8) u8 { return if (a > b) a else b; }\n`, + }, + { + path: `src/hub${i}.zig`, + content: `pub const compare = @import("dom_utils${i}.zig").compare;\n`, + }, + { + path: `src/Element${i}.zig`, + content: `const Element${i} = @This(); +const dom_utils = @import("dom_utils${i}.zig"); +const hub = @import("hub${i}.zig"); + +_n: u8 = 0, + +${accessors} + +fn onTick(self: *Element${i}) u8 { return self._n; } + +pub const JsApi = struct { + pub const bridge = Bridge(Element${i}); +${registrations} + pub const comparator = bridge.accessor(dom_utils.compare, null, .{}); + pub const hubbed = bridge.accessor(hub.compare, null, .{}); + pub const defaultNs = bridge.accessor(dom_utils.DEFAULT_NS, null, .{}); +}; + +pub fn boot() void { register(onTick); } + +pub fn register(comptime f: anytype) void { _ = f; } + +fn Bridge(comptime T: type) type { + _ = T; + return struct { + pub fn accessor(comptime g: anytype, comptime s: anytype, comptime o: anytype) u8 { + _ = g; + _ = s; + _ = o; + return 0; + } + }; +} +`, + }, + ]; +} + +/** + * Everything `resolveValueRefTarget` reads, built the way the pipeline builds it + * (`runScopeResolution` phases 1-2): real extraction through the Zig provider, + * `populateOwners`, `reconcileOwnership` into the SemanticModel, then finalize. + * Hand-assembling the indexes instead would pin this file's idea of their shape + * rather than the code's. + */ +function buildCorpus(modules) { + const parsedFiles = []; + for (let i = 0; i < modules; i++) { + for (const file of moduleFiles(i)) { + const parsed = extractParsedFile(zigScopeResolver.languageProvider, file.content, file.path); + if (parsed === undefined) { + throw new Error( + `scope extraction failed for ${file.path} — the vendored tree-sitter-zig ` + + `grammar is unavailable on this host, so this bench cannot run`, + ); + } + zigScopeResolver.populateOwners(parsed); + parsedFiles.push(parsed); + } + } + const model = createSemanticModel(); + reconcileOwnership(parsedFiles, model); + const allFilePaths = new Set(parsedFiles.map((p) => p.filePath)); + const scopes = finalizeScopeModel(parsedFiles, { + hooks: { + resolveImportTarget: (raw, from) => + zigScopeResolver.resolveImportTarget(raw, from, allFilePaths), + mergeBindings: (existing, incoming, scopeId) => + zigScopeResolver.mergeBindings(existing, incoming, scopeId), + expandsWildcardTo: (scope, files) => zigScopeResolver.expandsWildcardTo(scope, files), + }, + }); + return { parsedFiles, scopes, model }; +} + +/** The timed loop: every `value-ref` site, resolved exactly as the pass does. */ +function resolveAll({ parsedFiles, scopes, model }, pairs) { + let sites = 0; + let resolved = 0; + for (const parsed of parsedFiles) { + for (const site of parsed.referenceSites) { + if (site.kind !== 'value-ref') continue; + sites++; + const def = resolveValueRefTarget( + site, + parsed.filePath, + scopes, + model, + // The provider hook the pass is handed in `run.ts`; Zig sets it. + zigScopeResolver.namespaceExportsIncludeImportedNames === true, + ); + if (def === undefined) continue; + resolved++; + pairs?.push( + `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}->${def.nodeId}`, + ); + } + } + return { sites, resolved }; +} + +function measure(modules) { + const corpus = buildCorpus(modules); + const pairs = []; + const counts = resolveAll(corpus, pairs); + let bestMs = Infinity; + for (let i = 0; i < REPS; i++) { + const start = performance.now(); + resolveAll(corpus, undefined); + bestMs = Math.min(bestMs, performance.now() - start); + } + // Order-independent: the walk order is an implementation detail, the resolved + // SET is the behaviour. + pairs.sort(); + return { + modules, + files: corpus.parsedFiles.length, + value_ref_sites: counts.sites, + resolved: counts.resolved, + declined: counts.sites - counts.resolved, + fingerprint: createHash('sha256').update(pairs.join('\n')).digest('hex'), + min_ms: Number(bestMs.toFixed(3)), + us_per_site: Number(((bestMs * 1000) / Math.max(counts.sites, 1)).toFixed(3)), + }; +} + +const report = { small: measure(SMALL_MODULES), large: measure(LARGE_MODULES) }; +report.reps = REPS; +report.workload_ratio = LARGE_MODULES / SMALL_MODULES; +report.scaling_ratio = Number( + (report.large.min_ms / Math.max(report.small.min_ms, 0.001)).toFixed(3), +); +report.linear_factor = Number((report.scaling_ratio / report.workload_ratio).toFixed(3)); + +if (!process.argv.includes('--check')) { + console.log(JSON.stringify(report, null, 2)); + process.exit(0); +} + +const baseline = JSON.parse(readFileSync(join(HERE, 'baseline.json'), 'utf8')); +const failures = []; +const requirePositiveNumber = (path, value) => { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + failures.push(`${path}: expected a finite positive number, got ${JSON.stringify(value)}`); + return false; + } + return true; +}; +for (const arm of ['small', 'large']) { + // Correctness first: counts AND the resolved-target set. + for (const key of [ + 'modules', + 'files', + 'value_ref_sites', + 'resolved', + 'declined', + 'fingerprint', + ]) { + if (report[arm][key] !== baseline[arm][key]) { + failures.push( + `${arm}.${key}: expected ${JSON.stringify(baseline[arm][key])}, got ${JSON.stringify(report[arm][key])}`, + ); + } + } + // `min_ms` / `us_per_site` are reported, never gated — see the header. +} +if ( + requirePositiveNumber('linear_scaling_budget', baseline.linear_scaling_budget) && + report.linear_factor > baseline.linear_scaling_budget +) { + failures.push( + `linear_factor ${report.linear_factor} exceeds budget ${baseline.linear_scaling_budget} ` + + `(runtime ${report.scaling_ratio}x for ${report.workload_ratio}x work; ` + + `~1.0 is linear). Re-run alone on an idle machine before investigating — ` + + `this is the only arm a busy runner can move.`, + ); +} + +console.log(JSON.stringify(report, null, 2)); +if (failures.length > 0) { + console.error('[value-ref-resolution --check] FAIL'); + for (const failure of failures) console.error(` - ${failure}`); + process.exit(1); +} +console.log('[value-ref-resolution --check] PASS'); diff --git a/gitnexus/src/cli/eval-server.ts b/gitnexus/src/cli/eval-server.ts index b2f656c39..fc6af8e63 100644 --- a/gitnexus/src/cli/eval-server.ts +++ b/gitnexus/src/cli/eval-server.ts @@ -609,10 +609,20 @@ export function formatImpactResult(result: any): string { } // #1858 — an interface / indirection boundary on the path makes this a lower // bound; surface it so the count is not read as exhaustive. + // + // The header names no cause AND asserts no omitted caller, because it cannot + // know either. DI / dynamic dispatch was the only producer of `lower-bound` + // when this was written; #3399 added callables named in VALUE position (a + // registration table, a callback argument), and one of its producers is a + // probe that could not RUN — `callableValueReferenceBoundaries` hedges on a + // failed query and says in so many words that whether the symbol is + // registered is unknown. A header claiming "some callers are not traced" + // would there assert an omission nothing established, and would contradict + // the bullet printed directly under it. The bullets carry the cause — they + // are generated per-cause by `computeEpistemicBoundary` — so the header only + // has to say the count is a floor. if (result.epistemic === 'lower-bound') { - lines.push( - '⚠️ Lower bound — unresolved indirection on the path (callers binding via DI / dynamic dispatch are not traced; actual impact may be higher):', - ); + lines.push('⚠️ Lower bound — impact may be incomplete and actual impact may be higher:'); for (const b of result.boundaries || []) lines.push(` • ${b}`); } pushCallgraphRiskLines(lines, result); diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 5d8fc9061..390e6a32f 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -5,6 +5,7 @@ import path from 'path'; import type { CsharpStructureLineScanner } from './languages/csharp/namespace-siblings.js'; import { isDev } from './utils/env.js'; +import { isHardcodedIgnoredDirectoryAtPath } from '../../config/ignore-service.js'; import { mapConcurrent } from '../../lib/utils.js'; import { logger } from '../logger.js'; @@ -182,13 +183,24 @@ export interface SwiftPackageConfig { /** Zig package config parsed from build.zig.zon and the root build.zig */ export interface ZigBuildZonConfig { /** - * Map of dependency name -> the raw `.path = "..."` value, exactly as - * written in build.zig.zon (relative to the repo root, and possibly - * escaping it: `../local_dep`). Consumers normalize — see - * `normalizeZigDepPath` below, which rejects absolute - * and repo-escaping values. `.url`-based deps cannot be resolved to a - * repo-local file (they unpack into a build cache outside the repo) and so - * are not included here. + * Map of dependency name -> the dep's directory, in one of two spellings + * depending on which package this config describes: + * + * - ROOT package (`pkg === ''`): the raw `.path = "..."` value, exactly as + * written in build.zig.zon (relative to the repo root, and possibly + * escaping it: `../local_dep`). This is what `parseZigBuildZon` promises + * and what its tests pin. + * - NESTED package: repo-relative and already normalized, because a nested + * package's `.path` is written relative to ITS directory and means + * nothing against the repo-relative keys consumers match on + * (`packages/app`'s `../core` is stored as `packages/core`). A dep + * escaping the REPO root is dropped rather than stored. + * + * Either spelling is safe to hand to `normalizeZigDepPath` below — it rejects + * absolute and repo-escaping values and is idempotent on an already + * normalized one, which is what `resolveZigImportInternal` relies on. + * `.url`-based deps cannot be resolved to a repo-local file (they unpack into + * a build cache outside the repo) and so are not included here. */ pathDeps: Map; /** @@ -228,6 +240,29 @@ export interface ZigBuildZonConfig { buildModules?: readonly ZigBuildModule[]; } +/** + * One Zig build package: the directory whose `build.zig` / `build.zig.zon` + * declare the config, and that config with every path REPO-relative. + * + * A Zig module's import table is declared by the `build.zig` of the package it + * belongs to, so a repo holding several packages holds several import tables — + * the same shape a TypeScript monorepo has with a `tsconfig.json` per package. + */ +export interface ZigPackageScope { + /** Repo-relative directory the package governs (`''` for the repo root). */ + readonly dir: string; + readonly config: ZigBuildZonConfig; +} + +/** + * Every Zig build package in the repo, indexed so the nearest one to a file + * wins — the `TsconfigIndex` analogue, and for the same reason. + */ +export interface ZigWorkspaceIndex { + /** Deepest-first, so the first `dir` that prefixes a file path governs it. */ + readonly packages: readonly ZigPackageScope[]; +} + /** One build module of the root `build.zig` — see `ZigBuildZonConfig.buildModules`. */ export interface ZigBuildModule { /** The `addModule("", …)` name; absent for `createModule` bindings @@ -646,10 +681,21 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise { +export async function loadZigBuildConfig( + repoRoot: string, + packageDir = '', +): Promise { + // Every path this function returns is REPO-relative, because that is the + // keyspace `allFilePaths` uses. The parsers below answer package-relative, so + // a nested package rebases them through `inPackage`. For the root package + // (`packageDir === ''`) the prefix is empty and every value is byte-identical + // to what this function returned before nested packages existed. + const pkg = packageDir === '' ? '' : `${packageDir}/`; + const inPackage = (relToPackage: string): string => `${pkg}${relToPackage}`; + const packageFile = (name: string): string => path.join(repoRoot, packageDir, name); let config: ZigBuildZonConfig | null = null; try { - const raw = await fs.readFile(path.join(repoRoot, 'build.zig.zon'), 'utf-8'); + const raw = await fs.readFile(packageFile('build.zig.zon'), 'utf-8'); config = parseZigBuildZon(raw); } catch { // No zon (or unreadable): the root build.zig may still declare modules. @@ -661,9 +707,11 @@ export async function loadZigBuildConfig(repoRoot: string): Promise | undefined; let rootBuildZig: string | null = null; try { - rootBuildZig = await fs.readFile(path.join(repoRoot, 'build.zig'), 'utf-8'); + rootBuildZig = await fs.readFile(packageFile('build.zig'), 'utf-8'); const parsed = parseZigRootModules(rootBuildZig); - if (parsed.size > 0) rootModules = parsed; + if (parsed.size > 0) { + rootModules = new Map(Array.from(parsed, ([name, root]) => [name, inPackage(root)])); + } } catch { // No root build.zig — nothing to declare. } @@ -671,7 +719,7 @@ export async function loadZigBuildConfig(repoRoot: string): Promise>(); + // A nested package's `.path` values are written relative to ITS directory, so + // they are rebased here and stored repo-relative; `resolveZigImportInternal` + // then reads them through the same `normalizeZigDepPath`, which is idempotent + // on an already-normalized value. A dep that escapes the REPO root (not merely + // the package) resolves to nothing and is dropped. The root package keeps its + // raw spelling, which is what `parseZigBuildZon` promises and its tests pin. + const pathDeps = pkg === '' ? config.pathDeps : new Map(); for (const [depName, depPath] of config.pathDeps) { - const rel = normalizeZigDepPath(depPath); + // Asked of the value AS WRITTEN, before the package prefix goes on: an + // absolute `.path` points outside the repository whichever package declared + // it, and prefixing hides that from `normalizeZigDepPath`. See + // `isAbsoluteZigDepPath`. + if (isAbsoluteZigDepPath(depPath)) continue; + const rel = normalizeZigDepPath(`${pkg}${depPath}`); if (rel === null) continue; + if (pkg !== '') pathDeps.set(depName, rel); let buildZig: string; try { buildZig = await fs.readFile(path.join(repoRoot, rel, 'build.zig'), 'utf-8'); @@ -706,15 +767,177 @@ export async function loadZigBuildConfig(repoRoot: string): Promise 0) depModules.set(depName, named); } - const buildModules = rootBuildZig === null ? [] : parseZigBuildModules(rootBuildZig, depModules); + const buildModules = + rootBuildZig === null + ? [] + : rebaseZigBuildModules( + parseZigBuildModules(rootBuildZig, depModules), + inPackage, + depModules, + ); return { ...config, + pathDeps, ...(moduleRoots.size > 0 ? { moduleRoots } : {}), ...(rootModules ? { rootModules } : {}), ...(buildModules.length > 0 ? { buildModules } : {}), }; } +/** + * Rebase a package's own build modules to repo-relative paths. + * + * `parseZigBuildModules` answers package-relative for everything it read out of + * the `build.zig` it was handed, with one exception: an alias resolved through + * `depModules` (`addImport("api", dep.module("core"))`) is already repo-relative, + * because `depModules` was built that way. Prefixing that a second time would + * point the alias at a path no file has. The already-repo-relative values are + * therefore identified by membership in `depModules`, not guessed at from their + * shape. + */ +function rebaseZigBuildModules( + modules: readonly ZigBuildModule[], + inPackage: (relToPackage: string) => string, + depModules?: ReadonlyMap>, +): ZigBuildModule[] { + if (inPackage('') === '') return [...modules]; + const fromDep = new Set(); + for (const named of depModules?.values() ?? []) + for (const root of named.values()) fromDep.add(root); + return modules.map((mod) => ({ + ...(mod.name !== undefined ? { name: mod.name } : {}), + root: inPackage(mod.root), + imports: new Map( + Array.from(mod.imports, ([alias, root]) => [ + alias, + fromDep.has(root) ? root : inPackage(root), + ]), + ), + })); +} + +/** Bounds for the package walk, mirroring the tsconfig scan. */ +const ZIG_SCAN_MAX_DIRS = 20_000; +const ZIG_SCAN_MAX_DEPTH = 24; + +/** + * The Zig build package governing `filePath` — the nearest one at or above it. + * + * A Zig module's import table is declared by the `build.zig` of the package the + * file belongs to, so the nearest enclosing package is the faithful reading of + * `@import("name")` at that site, exactly as `tsconfigFor` reads a non-relative + * specifier against the nearest enclosing project. + * + * There is deliberately NO fall-through to an enclosing package when the nearest + * one does not bind the name. Falling through is how a vendored dependency's + * `@import("config")` silently resolved to the outer repo's `config` module — + * the same failure `loadTsconfigIndex` documents for a package whose own + * tsconfig declares no `baseUrl`, and the same failure the per-module import + * tables in `resolveZigImportInternal` already exist to prevent one level down. + */ +export function zigPackageFor( + index: ZigWorkspaceIndex | null | undefined, + filePath: string, +): ZigBuildZonConfig | null { + if (index === null || index === undefined) return null; + for (const scope of index.packages) { + if (scope.dir === '') return scope.config; + if (filePath.startsWith(`${scope.dir}/`)) return scope.config; + } + return null; +} + +/** + * Load every Zig build package in the repo, nearest-first. + * + * Called with no `packageDir` — which is how every call site read it before + * this function existed — `loadZigBuildConfig` reads the ROOT `build.zig` / + * `build.zig.zon` and nothing else. That is the whole configuration of a + * single-package repo and none of the configuration of a monorepo: a repo + * laying its packages out as `packages//build.zig` has no root build + * files at all, so the loader answers `null` and EVERY bare + * `@import("")` in it goes unresolved — cross-file resolution silently + * degrades to relative imports only. Measured on a two-package fixture: + * `config = null`, `@import("core")` → `null`. + * + * The loader itself is not root-bound any more: this function is what supplies + * it a `packageDir`, one per package below. + * + * So the packages are discovered the way tsconfigs are (`findTsconfigFiles`): + * one bounded breadth-first walk that skips the hardcoded ignore set, then + * deepest-first ordering so `zigPackageFor` can take the first match. + * + * Called from `ScopeResolver.loadResolutionConfig`, which the orchestrator runs + * once per LANGUAGE workspace pass — so the walk happens only for repos that + * actually contain Zig. `loadImportConfigs`, which runs unconditionally for + * every repo, keeps calling `loadZigBuildConfig` for the root package alone; + * that is the same split TypeScript already has between the cheap + * `loadTsconfigPaths` and the repo-walking `loadTsconfigIndex`. + */ +export async function loadZigWorkspaceIndex(repoRoot: string): Promise { + const dirs = await findZigPackageDirs(repoRoot); + if (dirs.length === 0) return null; + const packages: ZigPackageScope[] = []; + for (const dir of dirs) { + const config = await loadZigBuildConfig(repoRoot, dir); + // A `build.zig` that declares no module and no path dep contributes nothing + // a lookup could answer with. Keeping it as an empty scope would be worse + // than dropping it: it would shadow an enclosing package that DOES declare + // the name, and answer nothing in its place. + if (config !== null) packages.push({ dir, config }); + } + if (packages.length === 0) return null; + // Deepest first, so `zigPackageFor` takes the most specific package rather + // than whichever the walk reached first. + packages.sort((a, b) => b.dir.length - a.dir.length || a.dir.localeCompare(b.dir)); + return { packages }; +} + +/** Repo-relative directories holding a `build.zig` and/or a `build.zig.zon`. */ +async function findZigPackageDirs(repoRoot: string): Promise { + const found: string[] = []; + const queue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }]; + // A HEAD INDEX rather than `queue.shift()`. The queue is pushed to while it is + // drained, which keeps the array in a mode where `shift()` memmoves the whole + // remainder instead of taking V8's left-trimming fast path — so the walk is + // quadratic in the frontier, and `ZIG_SCAN_MAX_DIRS` is the bound on how bad + // that gets. Measured at that bound (20,000 dequeues): 53 ms at fan-out 4 and + // 81 ms at fan-out 20, against 0.8 ms here — 66-106x, paid before any config + // is read. Memory is unchanged: entries were already retained by the pushes, + // `shift()` only dropped the head. + let queueHead = 0; + let dirsScanned = 0; + + while (queueHead < queue.length && dirsScanned < ZIG_SCAN_MAX_DIRS) { + const { dir, depth } = queue[queueHead++]!; + dirsScanned++; + let entries: import('fs').Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + continue; + } + let isPackage = false; + for (const entry of entries) { + if (entry.isDirectory()) { + const childDir = path.join(dir, entry.name); + if (isHardcodedIgnoredDirectoryAtPath(repoRoot, childDir)) continue; + if (depth < ZIG_SCAN_MAX_DEPTH) queue.push({ dir: childDir, depth: depth + 1 }); + continue; + } + if (!entry.isFile()) continue; + // Either marker declares a package: a `build.zig` with no zon still names + // modules, and a zon with no build.zig still names path deps. + if (entry.name === 'build.zig' || entry.name === 'build.zig.zon') isPackage = true; + } + if (isPackage) { + const rel = path.relative(repoRoot, dir).split(path.sep).join('/'); + found.push(rel === '.' || rel === '' ? '' : rel); + } + } + return found; +} + /** * Normalize a `.path` value from build.zig.zon into a repo-relative form. * Returns null for paths that escape the repo root (start with `..`) or @@ -722,13 +945,38 @@ export async function loadZigBuildConfig(repoRoot: string): Promise...\`) emit; the closing tag is intentionally NOT captured — * each JSX element should emit exactly one CALLS edge per use site. */ -const TSX_JSX_QUERY_SUFFIX = ` +/** + * Exported alongside `TYPESCRIPT_SCOPE_QUERY` so + * `value-ref-dispatchability.test.ts` checks the whole query a `.tsx` file is + * analyzed with. Checking the base alone would miss a `value-ref` rule added + * here. Not part of the provider surface — use `getTsScopeQuery`. + */ +export const TSX_JSX_QUERY_SUFFIX = ` ;; ((jsx_self_closing_element name: (identifier) @reference.name) @reference.call.free diff --git a/gitnexus/src/core/ingestion/languages/zig/query.ts b/gitnexus/src/core/ingestion/languages/zig/query.ts index 9aa8e106a..0b4540417 100644 --- a/gitnexus/src/core/ingestion/languages/zig/query.ts +++ b/gitnexus/src/core/ingestion/languages/zig/query.ts @@ -20,7 +20,13 @@ import { requireVendoredGrammar } from '../../../tree-sitter/vendored-grammars.j * container and import bindings — `emitZigScopeCaptures` filters those * groups out so a name binds exactly once. */ -const ZIG_SCOPE_QUERY = ` +/** + * Exported for `value-ref-dispatchability.test.ts`, which reads every language's + * scope query to enforce the keyed/unkeyed partition that + * `callableValueReferenceBoundaries`' dispatch exclusion depends on. Not part of + * the provider surface — nothing else should import it. + */ +export const ZIG_SCOPE_QUERY = ` ;; Scopes (source_file) @scope.module (struct_declaration) @scope.class @@ -359,6 +365,60 @@ const ZIG_SCOPE_QUERY = ` "const" . (identifier) @type-binding.name (call_expression) @type-binding.type .) @type-binding.alias +;; References — VALUE positions (#3399): a callable named where a value is +;; expected rather than where a callee is. Zig's JS bridge is built entirely +;; out of this shape — +;; +;; pub const namespaceURI = bridge.accessor(Element.getNamespaceUri, null, .{}); +;; +;; — 2,047 such declarations across 257 files in lightpanda-io/browser, the +;; project's whole JS↔Zig surface, and NONE of them reached the graph: Zig +;; emitted no \`value-ref\` capture at all, so a public DOM accessor's only +;; recorded callers were the two internal ones and \`impact\` called that \`exact\`. +;; +;; These become reference-class USES edges through the existing +;; \`mapReferenceKindToEdgeType\` mapping — a registration is not an invocation +;; (Kythe \`ref\` vs \`ref/call\`; Joern \`METHOD_REF\`) — resolved by the +;; property-dispatch pass, which keeps ONLY callable targets. That callable gate +;; is what makes these deliberately broad rules safe: \`js.Bridge(Element)\` and +;; \`register(count)\` match too, and emit nothing, exactly as TypeScript's +;; \`{ port: DEFAULT_PORT }\` does. +;; +;; No \`@reference.property-key\` is attached: Zig has no object-literal key to +;; dispatch through, so these register a reference and never synthesize CALLS. +;; The terminal invoke — \`Accessor.init\` → a struct field → \`Factory.zig\`'s +;; \`inline for\`/\`@typeInfo\` → \`@call(.auto, func, args)\` — needs comptime +;; evaluation and is deliberately NOT modelled; \`impact\` reports the shortfall +;; as \`epistemic: "lower-bound"\` instead of pretending to certainty. + +;; Call ARGUMENTS. In tree-sitter-zig arguments are direct children of +;; \`call_expression\`, NOT wrapped in an \`arguments\` node (only builtins have +;; one), so the callee has to be consumed explicitly by \`function:\` — without +;; that binding the same rule also matches the callee of \`foo(bar)\` and mints a +;; USES edge duplicating the call. +(call_expression + function: (_) + (identifier) @reference.name @reference.value-ref) + +;; Qualified argument — \`bridge.accessor(Element.getNamespaceUri, …)\`. The +;; RECEIVER is captured alongside the member so the site carries the owner it +;; was written with; \`@reference.name\` stays the member, which is the name the +;; scope walk resolves. +(call_expression + function: (_) + (field_expression + object: (_) @reference.receiver + member: (identifier) @reference.name) @reference.value-ref) + +;; Const binding initialiser — \`pub const defaultHandler = onReset;\`. Both +;; anchors are load-bearing: the leading \`.\` pins the bound name to the first +;; named child (see the declaration rules above), and the trailing \`.\` keeps the +;; initializer as the LAST child, so a \`const x: T = y\` annotation shape cannot +;; put the TYPE in value position. +(variable_declaration + "const" . (identifier) + (identifier) @reference.name @reference.value-ref .) + ;; References — free calls: foo(...) (call_expression function: (identifier) @reference.name) @reference.call.free diff --git a/gitnexus/src/core/ingestion/languages/zig/range-binding.ts b/gitnexus/src/core/ingestion/languages/zig/range-binding.ts index a0186242f..408386bdf 100644 --- a/gitnexus/src/core/ingestion/languages/zig/range-binding.ts +++ b/gitnexus/src/core/ingestion/languages/zig/range-binding.ts @@ -17,6 +17,14 @@ * * Runs after `propagateImportedReturnTypes`, so return bindings hoisted from * other files are visible when a subject is a call. + * + * This hook also carries `bindZigThisAliases` (`this-alias-bindings.ts`), which + * is a different job — binding `const Self = @This();` to its container — but + * needs the same thing this pass is already paying for: the file's parsed tree, + * post-finalize. Giving it a pass of its own would re-parse every Zig file in + * the repo whenever the tree cache is cold. That is the whole reason it is + * here; the two do not otherwise interact, for the reason recorded at the call + * site. */ import type { ParsedFile, Scope, ScopeId, TypeRef } from 'gitnexus-shared'; @@ -32,6 +40,7 @@ import { isClassLike, } from '../../scope-resolution/scope/walkers.js'; import { isZigKeywordDeclaration, zigUnwrapValue } from './captures.js'; +import { bindZigThisAliases } from './this-alias-bindings.js'; import { normalizeZigTypeName } from './interpret.js'; type ZigTree = ReturnType['parse']>; @@ -89,6 +98,23 @@ export function populateZigRangeBindings( } const scopes = parsed.scopes; if (scopes.length === 0) continue; + + // `const Self = @This();` — bind the alias to its container. It sits in this + // loop for ONE reason: the tree. A pass of its own would re-parse every Zig + // file in the repo whenever the tree cache is cold. + // + // Its position relative to the payload walk below is NOT load-bearing, and + // saying otherwise would be wrong in a checkable way: the payload walk types + // a subject through `findReceiverTypeBinding`, which reads `typeBindings` + // and the namespace/workspace type channels — never `bindingAugmentations`, + // where this writes. Measured on `for (Self.items) |it|`: `it` is bound + // neither before nor after. Nor is that a gap this should close by also + // writing a typeBinding — NO container name has one, the file stem included, + // so a payload subject written `Type.member` resolves for no spelling at + // all, and giving the alias an entry would make it behave unlike the very + // container it names. + bindZigThisAliases(parsed, tree.rootNode, indexes); + const resolver = new ZigSubjectTypeResolver(scopes, indexes, classScopeByDefId); // Pre-order: an outer payload is bound before an inner construct reads it diff --git a/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts index 86afcfbf2..675f6e1a8 100644 --- a/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts @@ -5,8 +5,9 @@ * Thin wiring: Zig has no inheritance (default MRO linearization over an * empty heritage set), no `super`, and is statically typed (field-fallback * heuristic off per the contract guidance). Import resolution reuses the - * same `resolveZigImportInternal` the legacy import-resolver config wraps, - * with `build.zig.zon` `.path` deps threaded through `loadResolutionConfig`. + * same `resolveZigImportInternal` the legacy import-resolver config wraps, with + * the repo's Zig build packages threaded through `loadResolutionConfig` and the + * one governing each file selected per-file by `zigPackageFor`. */ import type { ParsedFile } from 'gitnexus-shared'; @@ -14,7 +15,11 @@ 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 { loadZigBuildConfig, type ZigBuildZonConfig } from '../../language-config.js'; +import { + loadZigWorkspaceIndex, + zigPackageFor, + type ZigWorkspaceIndex, +} from '../../language-config.js'; import { resolveZigImportInternal } from '../../import-resolvers/zig.js'; import { zigProvider } from '../zig.js'; import { expandZigWildcardNames, zigArityCompatibility, zigMergeBindings } from './index.js'; @@ -46,14 +51,23 @@ export const zigScopeResolver: ScopeResolver = { // import; a one-hop split at the last dot resolved none of them. resolveNamespaceChains: true, - loadResolutionConfig: (repoPath: string) => loadZigBuildConfig(repoPath), + // The whole workspace, not just the root package. A Zig module's import table + // is declared by the `build.zig` of the package the file belongs to, so a repo + // laying its packages out as `packages//build.zig` has as many import + // tables as packages — and reading only the root one leaves every bare + // `@import("")` in such a repo unresolved. Same shape, same reason, as + // `loadTsconfigIndex` for a TypeScript monorepo. + loadResolutionConfig: (repoPath: string) => loadZigWorkspaceIndex(repoPath), resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => resolveZigImportInternal( fromFile, targetRaw, allFilePaths, - (resolutionConfig as ZigBuildZonConfig | null | undefined) ?? null, + // The package governing THIS file — `zigPackageFor` is the `tsconfigFor` + // analogue. `resolveZigImportInternal` is handed one package's config and + // is unchanged by this: which config it receives is the only difference. + zigPackageFor(resolutionConfig as ZigWorkspaceIndex | null | undefined, fromFile), ), // `pub usingnamespace @import("x.zig");` — target decls become local decls. diff --git a/gitnexus/src/core/ingestion/languages/zig/this-alias-bindings.ts b/gitnexus/src/core/ingestion/languages/zig/this-alias-bindings.ts new file mode 100644 index 000000000..8a124bf3e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/zig/this-alias-bindings.ts @@ -0,0 +1,191 @@ +/** + * Bind a Zig `const X = @This();` alias to the container it names (#3219 + * review round 8). + * + * `@This()` IS the enclosing container, and Zig code says so constantly: the + * single most common spelling is `const Self = @This();`, followed by + * `Alias.member` wherever the explicit form is wanted (`Self.width(self)`, a + * registration `bridge.accessor(Self.width, …)`, a nested type `Self.Node`). + * A compiler resolves `Self` and the container's own name to the same type. + * This index did not, for one specific reason: + * + * - A container-level alias (`const Self = @This();` inside `struct {…}`) + * mints a `Variable` beside the `Struct`, so `Self` binds to a VALUE. Every + * class-like lookup filters on `isClassLike` and walks straight past it. + * - A FILE-level alias in a file-as-struct mints nothing at all — + * `isZigFileThisAlias` suppresses the Const deliberately, so it cannot + * shadow the type for `x: *Page`. The name is then bound to nothing, while + * the container itself is bound under the FILE STEM. + * + * So `Self.width` resolved only when the alias happened to be spelled like its + * container. Measured on the three Zig corpora on hand: 73 of ghostty's 185 + * `@This()` files, 93 of tigerbeetle's 94 and 8 of mach's 42 spell it + * differently, carrying 302 `Alias.member` references between them, 96 of those + * calls. Those were not wrong edges — they were no edges, and a caller list + * missing them is exactly the false confidence #3399 is about. + * + * WHAT THIS ADDS, precisely: one binding of the alias NAME to the container's + * own definition, appended to `indexes.bindingAugmentations` (the sanctioned + * post-finalize channel, invariant I8 — `indexes.bindings` is frozen). The + * augmentation channel is consulted by `lookupBindingsAt` only AFTER a scope's + * own `Scope.bindings`, so this can never outrank a real local declaration: the + * `Variable` the container-level alias already mints still answers first for + * anything that wants a value, and the container answers for anything that + * wants a type. Nothing is replaced and nothing is removed. + * + * WHAT IT DELIBERATELY DOES NOT DO. Only aliases declared DIRECTLY in a + * container body or at file level are bound, which is the same set + * `collectZigThisAliases` recognizes for the type-rewrite path. A function-local + * `const Self = @This();` also names the enclosing container, but it belongs in + * that function's scope, not the container's, and binding it here would make it + * visible to sibling functions that never declared it. + */ + +import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { BindingRef } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { isClassLike } from '../../scope-resolution/scope/walkers.js'; +import { isZigKeywordDeclaration, ZIG_CONTAINER_TYPES } from './captures.js'; + +/** `const X = @This();` — the declaration shape, wherever it sits. */ +function thisAliasName(node: SyntaxNode): string | undefined { + if (node.type !== 'variable_declaration' || !isZigKeywordDeclaration(node)) return undefined; + const named = node.namedChildren.filter((c): c is SyntaxNode => c !== null); + if (named.length !== 2 || named[0]!.type !== 'identifier') return undefined; + const value = named[1]!; + if (value.type !== 'builtin_function' || value.namedChild(0)?.text !== '@This') return undefined; + return named[0]!.text; +} + +/** The class-like definition a Class scope is the body of. */ +function containerDefOf(scope: Scope): SymbolDefinition | undefined { + return scope.ownedDefs.find((d) => isClassLike(d.type)); +} + +/** + * The Class scope of the FILE-AS-STRUCT, if this file is one. + * + * Identified by range rather than by name: it is the only Class scope that both + * hangs directly off the module scope AND spans the same lines as it. A + * namespace-only file with a top-level `pub const Counter = struct {…}` also + * produces a Class scope under the module scope, and it also carries the file + * stem when the file is `Counter.zig` — matching on the name would bind that + * file's `@This()` alias to `Counter`, which is not what `@This()` means there. + */ +function fileStructScope(scopes: readonly Scope[], moduleScope: Scope): Scope | undefined { + return scopes.find( + (s) => + s.kind === 'Class' && + s.parent === moduleScope.id && + s.range.startLine === moduleScope.range.startLine && + s.range.endLine === moduleScope.range.endLine, + ); +} + +/** + * Innermost Class scope containing `node`. + * + * Compared on (line, column) rather than line alone, because a Zig container + * can nest inside another on ONE line — `const A = struct { const S = @This(); + * const B = struct { const T = @This(); }; };` gives both scopes the same start + * line, and picking between them by line would be a coin toss that binds an + * alias to the wrong container. A wrong edge is the one outcome this whole + * change set treats as worse than no edge. + */ +function enclosingClassScope(scopes: readonly Scope[], node: SyntaxNode): Scope | undefined { + const line = node.startPosition.row + 1; + const column = node.startPosition.column; + const startsAtOrBefore = (l: number, c: number): boolean => + l < line || (l === line && c <= column); + const endsAtOrAfter = (l: number, c: number): boolean => l > line || (l === line && c >= column); + let best: Scope | undefined; + for (const s of scopes) { + if (s.kind !== 'Class') continue; + if (!startsAtOrBefore(s.range.startLine, s.range.startCol)) continue; + if (!endsAtOrAfter(s.range.endLine, s.range.endCol)) continue; + if ( + best === undefined || + s.range.startLine > best.range.startLine || + (s.range.startLine === best.range.startLine && s.range.startCol > best.range.startCol) + ) { + best = s; + } + } + return best; +} + +/** + * Append `alias -> def` at `scopeId`, skipping the append when THIS def is + * already bound there. Idempotence, not precedence: a duplicate would be + * harmless for lookup (`lookupBindingsAt` dedupes by `def.nodeId`) but would + * make the channel's contents depend on how many times the hook ran. Other + * bindings under the same name are left alone — the channel is append-only per + * I8, and the whole point of the augmentation tier is that a scope's own + * `Scope.bindings` are consulted first and still win. + */ +function appendBinding( + indexes: ScopeResolutionIndexes, + scopeId: ScopeId, + alias: string, + def: SymbolDefinition, +): void { + const channel = indexes.bindingAugmentations as Map>; + let byName = channel.get(scopeId); + if (byName === undefined) { + byName = new Map(); + channel.set(scopeId, byName); + } + const bucket = byName.get(alias); + if (bucket === undefined) { + byName.set(alias, [{ def, origin: 'local' }]); + return; + } + if (bucket.some((ref) => ref.def.nodeId === def.nodeId)) return; + bucket.push({ def, origin: 'local' }); +} + +/** + * Bind every `@This()` alias declared in `parsed` at container or file level. + * + * Called per file from `populateZigRangeBindings`, which already holds the + * parsed tree — a second pass over `parsedFiles` would re-parse every file when + * the tree cache is cold. That is the only thing the two share. Ordering + * against the payload bindings that follow does not matter, twice over: the + * payload walk types its subjects through `findReceiverTypeBinding`, which + * never reads the channel written here, and a `@This()` alias is a + * container-private name no other file can import, so nothing outside this file + * reads it either. + */ +export function bindZigThisAliases( + parsed: ParsedFile, + root: SyntaxNode, + indexes: ScopeResolutionIndexes, +): void { + const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); + if (moduleScope === undefined) return; + const fileStruct = fileStructScope(parsed.scopes, moduleScope); + + const visit = (node: SyntaxNode): void => { + const alias = thisAliasName(node); + if (alias !== undefined) { + const parent = node.parent; + if (parent?.type === 'source_file') { + // The file-as-struct's own name. Bound at the MODULE scope, which is + // where the container is already bound under the file stem, so the + // alias and the stem are visible to exactly the same sites. + const def = fileStruct === undefined ? undefined : containerDefOf(fileStruct); + if (def !== undefined) appendBinding(indexes, moduleScope.id, alias, def); + } else if (parent !== null && ZIG_CONTAINER_TYPES.has(parent.type)) { + const scope = enclosingClassScope(parsed.scopes, node); + const def = scope === undefined ? undefined : containerDefOf(scope); + if (scope !== undefined && def !== undefined) appendBinding(indexes, scope.id, alias, def); + } + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null) visit(child); + } + }; + visit(root); +} diff --git a/gitnexus/src/core/ingestion/languages/zig/workspace-static-gating.ts b/gitnexus/src/core/ingestion/languages/zig/workspace-static-gating.ts index 040c058be..11c13b259 100644 --- a/gitnexus/src/core/ingestion/languages/zig/workspace-static-gating.ts +++ b/gitnexus/src/core/ingestion/languages/zig/workspace-static-gating.ts @@ -1,6 +1,6 @@ import type { ParsedFile, ReferenceSite } from 'gitnexus-shared'; import { getTreeSitterBufferSize } from '../../constants.js'; -import type { ZigBuildZonConfig } from '../../language-config.js'; +import { zigPackageFor, type ZigWorkspaceIndex } from '../../language-config.js'; import { resolveZigImportInternal } from '../../import-resolvers/zig.js'; import { buildZigBoolConstMap, @@ -51,7 +51,11 @@ export function populateZigWorkspaceStaticGating( tree, parsed.filePath, knownPaths, - ctx.resolutionConfig as ZigBuildZonConfig | null | undefined, + // `resolutionConfig` is the whole Zig workspace; the package governing + // THIS file is what `resolveZigImportInternal` takes. Selected here rather + // than hoisted out of the loop because the answer is per-file: two files + // of this pass can belong to different packages. + zigPackageFor(ctx.resolutionConfig as ZigWorkspaceIndex | null | undefined, parsed.filePath), ); if (aliases.size === 0) continue; const ranges = collectZigStaticGatedRanges( @@ -76,7 +80,7 @@ function collectImportAliases( tree: ZigTree, fromFile: string, knownPaths: ReadonlySet, - resolutionConfig?: ZigBuildZonConfig | null, + packageConfig: ReturnType, ): ZigImportAliasMap { const candidates = new Map(); const declarationCounts = new Map(); @@ -91,7 +95,7 @@ function collectImportAliases( const raw = builtin?.descendantsOfType('string').at(0)?.text; if (raw === undefined) continue; const specifier = raw.replace(/^['"]|['"]$/g, ''); - const target = resolveZigImportInternal(fromFile, specifier, knownPaths, resolutionConfig); + const target = resolveZigImportInternal(fromFile, specifier, knownPaths, packageConfig); if (target !== null) candidates.set(binding, target); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/property-dispatch.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/property-dispatch.ts index fef9ed29d..6a23ca94d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/property-dispatch.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/property-dispatch.ts @@ -17,9 +17,11 @@ * registries only consult pre-finalize local bindings — imported names live * in finalized bindings (the same reason free calls need * `emitFreeCallFallback`) — so `resolveReferenceSites` skips `value-ref` - * sites and this pass resolves them post-finalize via - * `findCallableBindingInScope` (Function/Method/Constructor only — the - * callable gate that keeps `{ port: DEFAULT_PORT }` from emitting anything). + * sites and this pass resolves them post-finalize (see + * `resolveValueRefTarget` — Function/Method/Constructor only, the callable + * gate that keeps `{ port: DEFAULT_PORT }` from emitting anything, and + * receiver-aware so a qualified reference binds the owner it was written + * with). * * Precision posture (mirrors `emitInterfaceDispatchFor`): * - reason `'property-dispatch'` keeps synthesized CALLS auditable; @@ -36,13 +38,29 @@ * emits the capture participates) and generic member-call sites. */ -import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import type { + BindingRef, + ParsedFile, + ReferenceSite, + ScopeId, + SymbolDefinition, +} from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { tryEmitEdge, type CalleeIdCaptureCtx } from '../graph-bridge/edges.js'; import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js'; -import { findCallableBindingInScope } from '../scope/walkers.js'; +import { + findCallableBindingInScope, + findClassBindingInScope, + findOwnedMember, + isNamespaceNameShadowed, + isOwnerNameShadowedBySomethingElse, + lookupBindingsAt, +} from '../scope/walkers.js'; +import { VALUE_REF_EDGE_REASON } from '../value-ref-edges.js'; +import type { SemanticModel } from '../../model/semantic-model.js'; +import { CALL_TARGET_TYPES } from '../../model/symbol-table.js'; /** * Keys registered by more than this many distinct functions are skipped — @@ -65,12 +83,282 @@ export const MAX_PROPERTY_DISPATCH_FANOUT = (() => { /** Below the 0.85 resolved baseline; same discount idea as interface-dispatch. */ export const PROPERTY_DISPATCH_CONFIDENCE = 0.7; +/** + * Resolve a `value-ref` site to the callable it names. + * + * Two shapes, and the difference matters: + * + * - BARE (`{ handler: onClick }`, `register(onTick)`) — the name is resolved + * up the lexical chain, which is what an unqualified name means. Be exact + * about what that walk does, because it is not a plain lexical lookup: + * `findCallableBindingInScope` applies the callable predicate WHILE walking, + * so a scope binding the name to a parameter or a local contributes nothing + * and the walk continues outward. A nearer non-callable binding is stepped + * over — the same shape the qualified path guards against below, unguarded + * here. Pre-existing (#2437) and out of scope for #3399; reachable in JS/TS + * (`function outer(handler) { return { h: handler }; }` beside a top-level + * `function handler`), and narrow in Zig, which rejects a local shadowing a + * container declaration. + * + * - QUALIFIED (`bridge.accessor(Element.getNamespaceUri, …)`) — the source + * WROTE the owner, so the lexical chain is the wrong instrument. It gives + * local bindings precedence (`walkScopeChain`), so a nested container + * holding its own `getNamespaceUri` answers first and the registration is + * attached to a DIFFERENT function than the one written. That is a wrong + * edge, not a missing one — strictly worse for a tool whose value is that + * its edges can be trusted — and it is reachable in any language that + * allows a same-named callable in a nested container, Zig included. + * + * So a qualified site resolves through its receiver: name the owner, then take + * the member off that owner. An owner is either a MODULE or a CLASS-like + * container — `utils.compare` and `Element.getNamespaceUri` are the same shape + * written against the two kinds of namespace a language has, and the member-call + * path already resolves both (receiver-bound-calls Case 1 / Case 2). Both are + * tried here, MODULE FIRST: see `findNamespaceValueRefTarget` for why a module + * receiver cannot simply be declined, and the comment on the call below for why + * the container channel must not go first. + * + * If neither channel names the owner, or the owner is named but owns no such + * callable, this DECLINES rather than falling back to the lexical walk. + * + * Be precise about what declining costs, because it is more than one reference: + * a site that emits NO edge leaves no evidence for `impact`'s value-reference + * probe to read, so the target keeps `epistemic: "exact"` — silence, not a + * hedge. That is why the module channel above exists rather than being waved + * through as "just a decline". What remains declined is the case where the + * written receiver names nothing this index knows at all (a Zig `@This()` alias + * whose name differs from its container's, an owner from outside the workspace): + * there the alternative is not a hedge either, it is a confident edge to a + * lexically-nearer function that the source did not name, and a wrong edge is + * strictly worse than a missing one for a tool whose value is that its edges can + * be trusted. + * + * `CALL_TARGET_TYPES`, not a hand-rolled label set: `findOwnedMember` also + * answers with FIELDS, and a field named like the member would otherwise + * register as if it were the callable. + * + * Exported for `bench/value-ref-resolution/measure.mjs`, which gates both the + * resolved-target SET and the per-site cost of the four channels above across a + * 4x file-count step. It is the per-site half of the pass, so timing it in + * isolation is what makes a workspace-size dependency visible; timing + * `emitPropertyDispatchCalls` would fold that signal into edge emission, and + * re-implementing the channel order in the bench would pin the bench's idea of + * this function rather than this function. + */ +export function resolveValueRefTarget( + site: ReferenceSite, + filePath: string, + scopes: ScopeResolutionIndexes, + model: SemanticModel, + publishesImportedNames: boolean, +): SymbolDefinition | undefined { + const receiverName = site.explicitReceiver?.name; + if (receiverName === undefined) { + return findCallableBindingInScope(site.inScope, site.name, scopes); + } + // NAMESPACE FIRST, and the order is load-bearing. `findClassBindingInScope` + // does not stop at the scope chain: when its `isClassLike` walk misses — and a + // namespace handle binds a Module, so it always misses — it falls back to + // `scopes.qualifiedNames`, a WORKSPACE-wide index, and answers with the unique + // def of that name anywhere in the repo. Trying it first therefore lets a + // same-named container in a file this one never imported preempt the `@import` + // this file actually wrote: + // + // const dom_utils = @import("dom_utils.zig"); // namespace-only module + // … bridge.accessor(dom_utils.compare, …) // → decoy.zig's compare + // + // The shadow guard below cannot catch it: the import binds at MODULE scope, + // which the guard treats as the floor. An import written in this file is the + // strongest statement about what the name means here, so it outranks a global + // guess — and when the handle is not an import of this file, this answers + // nothing and the container channel runs exactly as before. + const viaNamespace = findNamespaceValueRefTarget( + site, + filePath, + receiverName, + scopes, + publishesImportedNames, + ); + if (viaNamespace !== undefined) { + // `'owned'` is NOT "no answer" — it is "this receiver is a namespace handle + // this file wrote, and it names no callable member". The two must not be + // conflated, because falling through from the second one reaches + // `findClassBindingInScope`, whose miss path answers from the WORKSPACE-wide + // qualified-name index: a same-named container in a file this one never + // imported then supplies the member the written module does not have. That + // is a confident edge into an unrelated file, and the owner-shadow guard + // below does not stop it — a plain `const utils = @import("utils.zig");` + // records a namespace IMPORT EDGE, not a module-scope binding, so the guard + // sees nothing bound under the name and reads the container as unshadowed. + // Verified with a fixture rather than argued: `dom_utils.onlyOnDecoy`, where + // `dom_utils.zig` has no such member and `decoy.zig` declares a same-named + // struct that does, minted `JsApi → onlyOnDecoy` before this line existed. + // + // The file said which module it meant. If that module does not expose the + // name as a callable, the honest answer is no edge. + return viaNamespace === 'owned' ? undefined : viaNamespace; + } + + const owner = findClassBindingInScope(site.inScope, receiverName, scopes); + if (owner !== undefined) { + // The container lookup is a CLASS-ONLY walk: `walkScopeChain` filters by + // `isClassLike`, so it steps over a nearer binding that is a value and keeps + // climbing — and past the scope chain entirely, into a qualified-name + // fallback that answers with the unique workspace definition of the name. + // `fn f(Ticker: u8) { register(Ticker.fire) }` in a file that neither + // declares nor imports `Ticker` therefore resolves to some other file's + // `Ticker` container. That is the wrong-edge failure R1-2 exists to prevent, + // arriving through the class channel instead of the lexical one, and a + // registration pointing at a function the source never named is worse than + // no registration at all. + // + // So the name has to still MEAN that container at this site. The namespace + // channel below asks the same question through `isNamespaceNameShadowed`; + // a container needs the variant that exempts the container ITSELF, because + // `fn make() { const Local = struct {…}; register(Local.go); }` binds the + // name locally to the very def we resolved, and reading that as its own + // shadow would suppress the resolutions this path exists to make. + if (isOwnerNameShadowedBySomethingElse(receiverName, owner, site.inScope, scopes)) + return undefined; + const member = findOwnedMember(owner.nodeId, site.name, model); + if (member === undefined || !CALL_TARGET_TYPES.has(member.type)) return undefined; + return member; + } + return undefined; +} + +/** + * The second kind of owner: a namespace handle. + * + * `const utils = @import("utils.zig"); register(utils.compare);` — `utils` is a + * MODULE, not a class, so `findClassBindingInScope` answers nothing and the + * class path above declines. Declining here would be a silent hole rather than + * a conservative one: no USES edge is emitted, so + * `callableValueReferenceBoundaries` measures a real zero and `impact` on + * `compare` republishes `exact` — the very claim this feature exists to stop + * making. Nothing downstream can hedge on evidence that was never recorded. + * + * So resolve it, through the SAME channel the member-CALL path already trusts + * for `utils.compare()` (receiver-bound-calls Case 1): the file's namespace + * import edges name the target module, and the target module's own local + * module-scope bindings name its members. `utils.compare` and `utils.compare()` + * disagreeing about what `utils` is would be the anomaly. + * + * The same three guards Case 1 applies, for the same reasons: + * - a LOCAL declaration shadowing the handle suppresses the resolution + * (`isNamespaceNameShadowed`) — `fn f(utils: Decoy) { register(utils.compare) }` + * names the parameter's member, and resolving through the import would be a + * wrong edge rather than a missing one; + * - a locally declared member wins, and a name the target file merely IMPORTED + * counts only when the provider says its imports ARE its exports + * (`ScopeResolver.namespaceExportsIncludeImportedNames`). That opt-in is not + * a detail to skip: a Zig HUB — a file made only of re-exports, ghostty's + * `src/terminal/`, tigerbeetle's `stdx` — declares nothing, so requiring a + * local declaration declines every member reached through one. `hub.fn()` + * resolves and `register(hub.fn)` would not, and one name would mean two + * things depending on whether a `(` followed it. In languages that do not + * opt in, a module's imports are not its exports and this stays closed; + * - two distinct defs under one name resolve NOTHING. Never guess a namespace + * member — the whole point of reading the written receiver is precision. + * + * `CALL_TARGET_TYPES` gates the answer for the same reason the class path needs + * it: `utils.DEFAULT_PORT` is a module-scope binding too, and a registration + * table full of constants must keep emitting nothing. + */ +function findNamespaceValueRefTarget( + site: ReferenceSite, + filePath: string, + receiverName: string, + scopes: ScopeResolutionIndexes, + publishesImportedNames: boolean, +): SymbolDefinition | 'owned' | undefined { + const moduleScopeId = scopes.moduleScopes.get(filePath); + if (moduleScopeId === undefined) return undefined; + const targetFiles: string[] = []; + for (const edge of scopes.imports.get(moduleScopeId) ?? []) { + if (edge.kind !== 'namespace' || edge.localName !== receiverName) continue; + if (edge.targetFile === null) continue; + if (!targetFiles.includes(edge.targetFile)) targetFiles.push(edge.targetFile); + } + if (targetFiles.length === 0) return undefined; + if (isNamespaceNameShadowed(receiverName, site.inScope, scopes)) return undefined; + + /** The unique callable `select` finds across every target file, or nothing. */ + const uniqueMember = ( + select: (moduleScope: ScopeId) => readonly BindingRef[], + ): SymbolDefinition | undefined | 'ambiguous' => { + let picked: SymbolDefinition | undefined; + for (const targetFile of targetFiles) { + const targetScopeId = scopes.moduleScopes.get(targetFile); + if (targetScopeId === undefined) continue; + for (const ref of select(targetScopeId)) { + if (!CALL_TARGET_TYPES.has(ref.def.type)) continue; + if (picked !== undefined && picked.nodeId !== ref.def.nodeId) return 'ambiguous'; + picked = ref.def; + } + } + return picked; + }; + + // A locally declared member first — same precedence `findExportedDef` states + // and `walkScopeChain` applies: what the target file DECLARED beats what it + // merely re-published. + const localRefs = (scope: ScopeId): readonly BindingRef[] => + (scopes.bindings.get(scope)?.get(site.name) ?? []).filter((ref) => ref.origin === 'local'); + const local = uniqueMember(localRefs); + if (local === 'ambiguous') return 'owned'; + if (local !== undefined) return local; + if (!publishesImportedNames) return 'owned'; + + // PRECEDENCE IS DECIDED BEFORE THE TYPE GATE, not by it. `uniqueMember` + // applies `CALL_TARGET_TYPES` while it selects, so a target file declaring a + // NON-callable under this name answers `undefined` above and would otherwise + // fall through to the published channel — publishing a re-exported callable + // under a name the module's own declaration owns. `findExportedDef` does not + // do that: it returns any local def it finds and lets its caller's type gate + // reject it, so `findExportedDefIncludingImportedNames` never reaches the + // imported names for a name the file declares. Same rule here, so `x.f` and + // `x.f()` cannot disagree about which module owns the name. + // + // Not reachable through valid Zig today — a container cannot declare a name + // twice, so one target file cannot hold both spellings, and Zig is the only + // provider that sets `namespaceExportsIncludeImportedNames`. It becomes + // reachable the moment a second provider opts in, or a receiver binds more + // than one target file; the guard is one `some` and the alternative failure + // is a confident edge into the wrong module. + const declaredLocally = targetFiles.some((targetFile) => { + const targetScopeId = scopes.moduleScopes.get(targetFile); + return targetScopeId !== undefined && localRefs(targetScopeId).length > 0; + }); + if (declaredLocally) return 'owned'; + + // Then a name the target file publishes but did not declare — the hub case. + // `lookupBindingsAt`, not `scopes.bindings`, because a hub's module scope owns + // no local binding for these names and the finalized/augmented channel is the + // only place they exist; the same read `findExportedDefIncludingImportedNames` + // does for the CALL form. + const published = uniqueMember((scope) => + lookupBindingsAt(scope, site.name, scopes).filter( + (ref) => ref.origin === 'import' || ref.origin === 'namespace' || ref.origin === 'reexport', + ), + ); + return published === 'ambiguous' || published === undefined ? 'owned' : published; +} + export function emitPropertyDispatchCalls( graph: KnowledgeGraph, scopes: ScopeResolutionIndexes, parsedFiles: readonly ParsedFile[], nodeLookup: GraphNodeLookup, + model: SemanticModel, calleeIdSink?: CalleeIdSink, + /** + * `ScopeResolver.namespaceExportsIncludeImportedNames`, forwarded rather than + * re-derived. The pass names no language; it asks the provider the same + * question `receiver-bound-calls` asks before resolving a namespace member, + * so the CALL and the REGISTRATION forms of `hub.fn` cannot disagree. + */ + publishesImportedNames = false, ): { usesEmitted: number; callsEmitted: number; @@ -86,18 +374,16 @@ export function emitPropertyDispatchCalls( for (const parsed of parsedFiles) { for (const site of parsed.referenceSites) { if (site.kind !== 'value-ref') continue; - const def = findCallableBindingInScope(site.inScope, site.name, scopes); + const def = resolveValueRefTarget( + site, + parsed.filePath, + scopes, + model, + publishesImportedNames, + ); if (def === undefined) continue; - const ok = tryEmitEdge( - graph, - scopes, - nodeLookup, - site, - def, - 'scope-resolution: value-ref', - seen, - ); + const ok = tryEmitEdge(graph, scopes, nodeLookup, site, def, VALUE_REF_EDGE_REASON, seen); if (ok) usesEmitted++; if (site.propertyKey === undefined) continue; diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index aa30acf91..15976130f 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -1210,7 +1210,12 @@ export function runScopeResolution( indexes, emitParsedFiles, postHeritageNodeLookup, + readonlyModel, calleeIdAccumulator, + // Same provider hook the receiver-bound pass consults for a namespace + // member (Case 1). Without it a hub module's re-exported callable + // resolves when CALLED and declines when REGISTERED. + provider.namespaceExportsIncludeImportedNames === true, ); if (propertyDispatch.skippedKeys > 0) { // Never drop dispatch coverage silently: a hook table larger than the diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 38413f013..835f9a7a8 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -353,6 +353,82 @@ export function isNamespaceNameShadowed( return true; } +/** + * Does something between `inScope` and its module scope bind `name` to + * ANYTHING other than `def`? + * + * `isNamespaceNameShadowed` asks the same question for a namespace handle, + * where any local binding of the name is by definition not the import. A + * CONTAINER receiver needs the extra clause: the container may itself be the + * local declaration (`fn make() { const Local = struct {…}; … Local.go … }`), + * and reading that as its own shadow would suppress exactly the resolutions it + * is meant to permit — the #2723 mistake, one channel over. + * + * So a scope that binds the name answers immediately, and the answer is "not + * shadowed" only when one of that scope's bindings IS `def`. A name bound in a + * nearer scope to something else — a parameter, a local, a type binding — wins + * the lexical race, which is the whole point: `findClassBindingInScope` filters + * the chain by `isClassLike` and therefore cannot see that it lost it. + * + * The MODULE scope is inspected too, unlike `isNamespaceNameShadowed`, and the + * exemption is what makes that safe. That guard stops one rung short because a + * namespace import writes its own name into the module scope and would read as + * its own shadow (#2723); here the owner is compared by identity, so the binding + * that IS the owner exempts itself and only a binding to something ELSE answers + * `true`. Stopping short would leave the exact hole this walk exists to close: + * `findClassBindingInScope` steps over a module-scope binding that is not + * class-like and then answers from a WORKSPACE-wide qualified-name index, so + * `const Gauge = @import("other.zig").SOME_CONST;` in a file that never imports + * `Gauge.zig` would still resolve `Gauge.read` to that file's container. + * `lookupBindingsAt` is used at that scope and only there, because an imported + * alias lives in the finalized channel rather than in `scope.bindings`. + * + * Fail-closed like its sibling: a missing scope or a parent cycle answers + * `true`, because suppressing a resolution costs a missing edge while trusting a + * corrupt chain costs a wrong one. + */ +export function isOwnerNameShadowedBySomethingElse( + name: string, + def: SymbolDefinition, + inScope: ScopeId, + scopes: ScopeResolutionIndexes, +): boolean { + let currentId: ScopeId | null = inScope; + const visited = new Set(); + while (currentId !== null) { + if (visited.has(currentId)) return true; + visited.add(currentId); + const scope = scopes.scopeTree.getScope(currentId); + if (scope === undefined) return true; + if (scope.kind !== 'Object') { + const isModule = scope.kind === 'Module'; + const imported = isModule ? lookupBindingsAt(currentId, name, scopes) : []; + const bindsHere = + scope.bindings.has(name) || + scope.typeBindings.has(name) || + scope.lexicalNames?.has(name) === true || + imported.length > 0 || + scope.ownedDefs.some((d) => { + const qualifiedName = d.qualifiedName; + if (qualifiedName === undefined) return false; + const dot = qualifiedName.lastIndexOf('.'); + return (dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1)) === name; + }); + if (bindsHere) { + if ((scope.bindings.get(name) ?? []).some((b) => b.def.nodeId === def.nodeId)) return false; + if (scope.ownedDefs.some((d) => d.nodeId === def.nodeId)) return false; + if (imported.some((b) => b.def.nodeId === def.nodeId)) return false; + return true; + } + } + // The module scope is the last rung, not a rung to skip: nothing above it + // can shadow a name for this file. + if (scope.kind === 'Module') return false; + currentId = scope.parent; + } + return true; +} + export function findReceiverTypeBinding( startScope: ScopeId, receiverName: string, diff --git a/gitnexus/src/core/ingestion/scope-resolution/value-ref-edges.ts b/gitnexus/src/core/ingestion/scope-resolution/value-ref-edges.ts new file mode 100644 index 000000000..5507f9d0e --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/value-ref-edges.ts @@ -0,0 +1,14 @@ +/** + * The `reason` text stamped on the USES edge a `value-ref` site emits. + * + * A leaf module on purpose. Two modules need this string and they sit on + * opposite sides of the product: `passes/property-dispatch.ts` writes it at + * analysis time, and `mcp/local/local-backend.ts` reads it back at query time + * to decide whether an answer is `exact`. Re-typing the literal in the reader + * would make the epistemic signal fail SILENTLY the day the writer's text is + * reworded — the query would simply match nothing and every answer would go + * back to claiming certainty, which is the exact defect (#3399) this constant + * exists to close. Importing `property-dispatch.ts` for it instead would drag + * the whole scope-resolution emit graph into the MCP backend for one string. + */ +export const VALUE_REF_EDGE_REASON = 'scope-resolution: value-ref'; diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index d288d14f4..8b34a3716 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -129,6 +129,7 @@ import type { UnresolvedReceiverSummary } from '../../core/ingestion/scope-resol import type { UndecidedSatisfactionSummary } from '../../core/ingestion/scope-resolution/undecided-satisfaction.js'; import { scopeExtractionFailureTotal } from '../../core/ingestion/scope-resolution/scope-extraction-failures.js'; import { lookupCount } from '../../core/ingestion/scope-resolution/summary-maps.js'; +import { VALUE_REF_EDGE_REASON } from '../../core/ingestion/scope-resolution/value-ref-edges.js'; import { DEFERRED_IMPORT_REASON_SUFFIX, TYPE_ONLY_IMPORT_REASON_SUFFIX, @@ -711,6 +712,39 @@ export interface EpistemicCauses { * "nothing was undecided", and a re-index is what tells the two apart. */ readonly undecidedSatisfaction: number; + /** + * Symbols that name this callable in VALUE position rather than calling it + * (#3399) — a registration table (`bridge.accessor(Element.getNamespaceUri, + * …)`), a callback argument, a function pointer stored in a field. + * + * Unit: SYMBOLS — distinct referrers, the same unit and the same reason as + * `dispatchBoundary`: the reference edge is per-site but the walk's question + * is "who else might reach this", and a referrer that names the callable + * twice is still one place the value escapes from. + * + * Kept separate from `dispatchBoundary` even though both describe dispatch + * the walk cannot follow. That slot counts implementations and + * interface-level consumers found by the heritage probe; these are neither, + * and folding them in would tell a consumer branching on the numbers that an + * interface boundary exists where there is none. The distinction is also the + * actionable one: a dispatch boundary is irreducible, whereas a callable + * value CAN often be followed once the language models the store/load that + * carries it. + * + * The reference itself IS modelled — that is what makes it countable. What is + * missing is the invocation through the value: it happens later, through a + * struct field, a registry lookup, or comptime reflection, and no CALLS edge + * connects the eventual call site back to this symbol. + * + * Zero when the property-dispatch pass DID synthesize that invocation + * (`x.()` through a registered object-literal key): the walk followed + * the registration, so nothing was missed and the result stays `exact`. + * + * Also zero — WITH a boundary note — when the probe itself could not run. + * The note is the signal there; the count is not, which is why a reader must + * branch on `epistemic` first and read the causes as explanation. + */ + readonly callableValueReferences: number; } function epistemicFrom(dropped: { @@ -720,6 +754,7 @@ function epistemicFrom(dropped: { undecided: number; dispatch: number; scopeExtraction: number; + callableValueReferences: number; }): { epistemic: 'exact' | 'lower-bound'; boundaries?: string[]; @@ -738,6 +773,7 @@ function epistemicFrom(dropped: { dispatchBoundary: dropped.dispatch, externalBoundary: dropped.external, undecidedSatisfaction: 0, + callableValueReferences: dropped.callableValueReferences, }, } : { epistemic: 'exact' } @@ -754,6 +790,7 @@ function epistemicFrom(dropped: { dispatchBoundary: dropped.dispatch, externalBoundary: dropped.external, undecidedSatisfaction: dropped.undecided, + callableValueReferences: dropped.callableValueReferences, }, }; } @@ -853,6 +890,153 @@ function undecidedSatisfactionBoundaries( return { notes, undecided }; } +/** + * Boundary evidence for callables named in VALUE position (#3399). + * + * `bridge.accessor(Element.getNamespaceUri, null, .{})`, `{ onClick: handler }`, + * `qsort(xs, n, sz, compareItems)` — each REGISTERS a function somewhere + * instead of calling it. The registration is modelled (`value-ref` → a USES + * edge, Kythe `ref` / Joern `METHOD_REF`); the invocation through the stored + * value is not, because it happens later through a struct field, a registry + * lookup or comptime reflection. + * + * That gap is precisely the first half of `tools.ts`'s definition of + * `lower-bound` — the walk provably missed callers — and it was previously + * reported as `exact`. (The second half, a probe that could not run, is what + * the failure branch below publishes; the contract states both because this + * function can produce either.) A + * public DOM accessor bound into a JS bridge table came back LOW/exact with two + * internal callers, which is worse than no answer: `lower-bound` invites the + * reader to look further, `exact` tells them not to bother. + * + * Counted as DISTINCT REFERRERS rather than sites: the question the count + * serves is "how many places does this value escape from", and a table that + * registers the same callable twice is still one table. + * + * NOT every value reference is a gap. Where the property-dispatch pass + * synthesized the invocation side, the walk followed it and the answer stays + * `exact` — see the second probe below. + * + * Three failure modes, three different answers, none of them silence: + * - the query cannot run → hedge, count 0 (a probe that did not answer + * is not evidence of completeness); + * - the query returns nothing → no hedge (a real, measured zero); + * - the reference was followed → no hedge (nothing was missed). + * + * The probe reads the edge's `reason`, which is why writer and reader share + * {@link VALUE_REF_EDGE_REASON}. Language-neutral by construction — every + * provider that emits a `value-ref` capture participates, and one that emits + * none simply gets no rows. + */ +async function callableValueReferenceBoundaries( + lbugPath: string, + symId: string, +): Promise<{ notes: string[]; referrers: number }> { + // `COUNT(DISTINCT …)`, not a capped row list. A `LIMIT n` here would make the + // published cause silently understate a target with more than n + // registrations — and this number is documented as "how many symbols", so a + // reader comparing its magnitude against `receiverTyping` would be comparing + // a truth to a ceiling. Aggregating in the database keeps the work bounded + // without capping the answer; scalar `sym.id` equality plus an implicit + // group-by is the shape `countByType` below already relies on. + // + // `null`, not `[]`, on failure: see below — an empty result set and an + // unanswerable query must not be the same value. + const rows = await executeParameterized( + lbugPath, + `MATCH (other)-[r:CodeRelation]->(sym) + WHERE sym.id = $symId AND r.type = 'USES' AND r.reason = $reason + RETURN COUNT(DISTINCT other.id) AS cnt`, + { symId, reason: VALUE_REF_EDGE_REASON }, + ).catch(() => null); + + // A probe that could not run must never read as certainty — the same rule the + // `loadMeta` read above states, and the whole reason this function exists. + // Returning zero here would publish `exact` on the strength of a query that + // never answered. + if (rows === null) { + return { + referrers: 0, + notes: [ + 'The callable-value-reference probe could not be run against this index, so whether ' + + 'this symbol is registered somewhere as a value is unknown. Treat the caller list as ' + + 'incomplete until it can be re-checked.', + ], + }; + } + const referrers = rows.length > 0 ? Number((rows[0] as any).cnt ?? (rows[0] as any)[0] ?? 0) : 0; + if (!Number.isFinite(referrers) || referrers <= 0) return { notes: [], referrers: 0 }; + + // Registrations whose invocation side the analyzer ALREADY synthesized are + // not a gap. `emitPropertyDispatchCalls` sweep 2 connects `x.()` member + // calls to every function registered under `` and stamps those edges + // `property-dispatch`; where that happened, the walk did not "provably miss" + // the caller and `lower-bound` would be noise sprayed over an answer the + // analyzer actually computed. Zig — the case this was built for — never sets + // a property key (no object-literal key to dispatch through), so it is never + // excluded here; the exclusion exists to keep TypeScript/JavaScript hook + // tables that ARE followed from being downgraded. + // + // SYMBOL-LEVEL, NOT PER-EDGE, and that is only sound because of an invariant + // that lives nowhere near this line. The graph does not record which + // registration produced which synthesized call, so if one symbol could carry + // both a followed and an unfollowed registration, this would zero the note + // over a gap the analyzer provably did not close — #3399 returning through a + // side door. Today no symbol can: + // + // - sweep 2 synthesizes CALLS only for a registration whose site carried a + // `propertyKey` (sweep 1 skips the index when it is undefined); + // - every JS/TS `@reference.value-ref` rule also captures + // `@reference.property-key` — both are object-literal shapes; + // - no Zig `@reference.value-ref` rule captures one. + // + // So a dispatchable registration is always a JS/TS one, an undispatchable + // registration is always a Zig one, and the two never meet on one symbol. + // `test/unit/scope-resolution/value-ref-dispatchability.test.ts` FAILS the day + // that stops holding — a JS/TS rule for a bare callback argument + // (`register(handler)`), a Zig rule that grows a key. When it does, the + // choice to make here is between (a) splitting the edge `reason` into + // dispatchable / undispatchable so this probe can count them apart, and + // (b) hedging any symbol with an undispatchable registration regardless of + // dispatch. (a) is precise and costs a graph-content change; (b) is cheap and + // over-hedges. What is NOT acceptable is leaving this as-is, because a signal + // that quietly stops firing is the defect this whole feature removes. + // + // Given the invariant, the residual today is only the coarseness of the + // exclusion within JS/TS, and hedging every property-value registration in + // every JS/TS codebase is worse: a signal that fires on everything stops + // carrying information, and the fan-out cap warning still sits behind it. + const dispatched = await executeParameterized( + lbugPath, + `MATCH (other)-[r:CodeRelation]->(sym) + WHERE sym.id = $symId AND r.type = 'CALLS' AND r.reason = 'property-dispatch' + RETURN COUNT(r) AS cnt`, + { symId }, + ).catch(() => null); + // Failure here is NOT a reason to skip the hedge: we already know a value + // reference exists, and being unable to prove it was followed leaves the + // conservative answer standing. + const dispatchedCount = + dispatched === null || dispatched.length === 0 + ? 0 + : Number((dispatched[0] as any).cnt ?? (dispatched[0] as any)[0] ?? 0); + if (Number.isFinite(dispatchedCount) && dispatchedCount > 0) { + return { notes: [], referrers: 0 }; + } + + const one = referrers === 1; + return { + referrers, + notes: [ + `${referrers} ${one ? 'symbol references' : 'symbols reference'} this callable as a VALUE ` + + `rather than calling it (a registration table, a callback argument, a stored function ` + + `pointer). The reference is recorded, but the call made THROUGH that value is not: it is ` + + `dispatched later from wherever the value is stored. Callers reached that way are absent ` + + `from this result — actual impact may be higher.`, + ], + }; +} + interface RepoHandle { id: string; // unique key = repo name (basename) name: string; @@ -6998,6 +7182,19 @@ export class LocalBackend { direction === 'downstream' ? Promise.resolve(undefined) : queryConvexDispatchMetadata(repo.lbugPath, symId, symName, symType); + // #3399 — callables named in value position. Upstream only: the question + // "who can reach this symbol" is the one a registration makes unanswerable. + // A downstream walk asks what THIS symbol reaches, which a reference INTO + // it does not affect. + // + // Issued alongside the heritage probe rather than after it, and read into + // `droppedBoundaries` below, so it hedges even when that probe finds + // nothing AND when it throws — a value reference is an independent reason + // a count is short, exactly as the receiver drops above are. + const valueRefPromise = + direction === 'downstream' + ? Promise.resolve({ notes: [] as string[], referrers: 0 }) + : callableValueReferenceBoundaries(repo.lbugPath, symId); const interfaceRowsPromise = executeParameterized( repo.lbugPath, `MATCH (x)-[r:CodeRelation]->(iface) @@ -7018,12 +7215,14 @@ export class LocalBackend { : []), ]); const convexDispatch = await convexDispatchPromise; + const valueRefDrops = await valueRefPromise; const droppedBoundaries = { ...receiverDrops, notes: [ ...receiverDrops.notes, ...scopeExtractionDrops.notes, ...undecidedDrops.notes, + ...valueRefDrops.notes, ...(convexDispatch === undefined ? [] : [convexDispatch.boundary]), ], undecided: undecidedDrops.undecided, @@ -7032,6 +7231,7 @@ export class LocalBackend { // inventing one from the presence of a note. dispatch: 0, scopeExtraction: scopeExtractionDrops.files, + callableValueReferences: valueRefDrops.referrers, }; try { // Discover the interface / abstract supertypes on the target's boundary. @@ -7123,6 +7323,7 @@ export class LocalBackend { dispatchBoundary: droppedBoundaries.dispatch + dispatchBoundarySymbols, externalBoundary: droppedBoundaries.external, undecidedSatisfaction: droppedBoundaries.undecided, + callableValueReferences: droppedBoundaries.callableValueReferences, }, }; } catch { diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index d68b5b70b..01f2a6a31 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -291,14 +291,15 @@ Handles disambiguation: if multiple symbols share the same name, returns ranked NOTE: ACCESSES edges (field read/write tracking) are included in context results with reason 'read' or 'write'. CALLS edges resolve through field access chains and method-call chains (e.g., user.address.getCity().save() produces CALLS edges at each step). COMPLETENESS OF incoming: alongside symbol/incoming/outgoing the result carries the same epistemic envelope impact() returns: -- epistemic: 'exact' | 'lower-bound' — 'lower-bound' means callers exist that this view provably does not list. +- epistemic: 'exact' | 'lower-bound' — 'lower-bound' means incoming is a FLOOR: either the walk provably missed callers, or a probe that would have established completeness could not run. Do not read it as proof that an omitted caller exists — read boundaries for which of the two it is. - boundaries: string[] — one plain-language sentence per reason. Prose for humans; branch on causes instead. -- causes: { scopeExtractionFiles, receiverTyping, dispatchBoundary, externalBoundary, undecidedSatisfaction } — machine-readable WHY. Every field counts MISSING THINGS, never sentences: +- causes: { scopeExtractionFiles, receiverTyping, dispatchBoundary, externalBoundary, undecidedSatisfaction, callableValueReferences } — machine-readable WHY. Every field counts MISSING THINGS, never sentences: - causes.scopeExtractionFiles (unit: files) > 0 — scope extraction still failed after the fallback pass, so scope-resolution edges from those files are absent. A value of 0 does not prove completeness when epistemic is 'lower-bound' because an older or unverified index has no measured file count. Re-run \`gitnexus analyze --force\`; if the reason persists, inspect the extraction warnings. - causes.receiverTyping (unit: call sites) > 0 — RESOLVER GAP: the analyzer dropped that many call sites on this name because it could not type the receiver, so they are missing from incoming. Do not read an absent caller as proof none exists. - causes.externalBoundary (unit: call sites) > 0 — the calls left the indexed program (System.out.println, fetch(...)). NOT a defect: no in-graph node could have been reached. An epistemic:'exact' result can carry this. - causes.dispatchBoundary (unit: symbols) > 0 — DI or interface dispatch: that many symbols sit on or beyond a boundary static analysis cannot cross. Irreducible. A symbol count, not a site count — per-site multiplicity is not retained for these edges — so compare its magnitude with receiverTyping, not its exact value. A framework runtime-proxy boundary can make epistemic lower-bound while this value remains 0 because endpoint metadata proves the gap but cannot count omitted symbols. - causes.undecidedSatisfaction (unit: unjudged interface/type pairs) > 0 — the analyzer could not decide whether a type satisfies an interface, so no IMPLEMENTS edge exists and no dispatch boundary was left for the walk to notice. Usually fixable by making the missing dependency available to analysis. + - causes.callableValueReferences (unit: symbols) > 0 — that many symbols name this callable as a VALUE instead of calling it (a Zig registration table or const initialiser, a JS/TS object-literal property value). A bare callback argument in JS/TS is not captured today and is not counted, so a 0 does not rule that shape out; nor does it, on an index built before the language emitted these captures — re-analyze first. The reference is in the graph as a USES edge; the call made THROUGH the value is not, because it is dispatched later from wherever the value was stored. incoming.calls is therefore a floor. Follow the USES edges to find the registration, then the code that reads it. It is 0 when the analyzer DID synthesize the dispatch through a registered property key. That exclusion is per SYMBOL, not per registration: a target with BOTH a followed registration and an unfollowed escape reads 0 here, so a 0 means 'no unfollowed registration was proven', not 'this symbol escapes nowhere'. A 0 alongside epistemic 'lower-bound' can also mean the probe itself could not run — read boundaries for which. REQUIRES RE-INDEX: causes.scopeExtractionFiles, causes.receiverTyping, causes.externalBoundary, causes.undecidedSatisfaction, and framework runtime-proxy boundary detection depend on index-time metadata that only a current analyzer writes. Against an older index the metadata can be absent, which is indistinguishable from "nothing was dropped" unless the schema probe detects the stale index — re-run \`gitnexus analyze\` before trusting a zero or an apparently exact result. @@ -488,15 +489,16 @@ Output includes: - affected_processes: which execution flows break and at which step - affected_modules: which functional areas are hit (direct vs indirect; classification-unavailable when that secondary query fails) - byDepth: affected symbols grouped by traversal depth (paginated by limit/offset; omitted when summaryOnly:true — use byDepthCounts for totals per depth, pagination object when truncated). Each item includes a processes:[{id,label,processType,step}] field listing the execution flows that symbol participates in. Empty when the symbol has no process membership. Can ALSO be empty when partial:true is set — either the process-aggregation pass hit its cap before detecting affected processes, or per-symbol enrichment was capped on a very large page. When partial:true, do NOT treat processes:[] as proof of no participation; cross-check the top-level affected_processes list. An item carries staticGated:true only when the edge that reached it is provably unreachable at compile time from the indexed source (today: Zig calls inside an 'if (CONST_FALSE)' body or the else of 'if (CONST_TRUE)'); the field is absent when the edge is live or the language does not model it. Traversal and risk do NOT filter or rank on it: it is metadata for the caller to weigh. -- epistemic: 'exact' | 'lower-bound' — whether impactedCount is the whole story. 'lower-bound' means the walk provably missed callers, so the count is a floor. Absent only on skipped probes (ambiguous-candidate lists, group fan-out). +- epistemic: 'exact' | 'lower-bound' — whether impactedCount is the whole story. 'lower-bound' means the count is a FLOOR: either the walk provably missed callers, or a probe that would have established completeness could not run (a failed callable-value-reference query says so in boundaries). It is not itself proof that an omitted caller exists — branch on causes and read boundaries. Absent only on skipped probes (ambiguous-candidate lists, group fan-out). - boundaries: string[] — one plain-language sentence per reason the count is short. Prose for humans; branch on causes instead. -- causes: { scopeExtractionFiles, receiverTyping, dispatchBoundary, externalBoundary, undecidedSatisfaction } — the machine-readable split of WHY, so an agent gating its own edits can tell a fixable analyzer gap from an irreducible one. Every field counts MISSING THINGS, never sentences: +- causes: { scopeExtractionFiles, receiverTyping, dispatchBoundary, externalBoundary, undecidedSatisfaction, callableValueReferences } — the machine-readable split of WHY, so an agent gating its own edits can tell a fixable analyzer gap from an irreducible one. Every field counts MISSING THINGS, never sentences: - causes.scopeExtractionFiles (unit: files) > 0 — scope extraction still failed after the fallback pass, so scope-resolution edges from those files are absent. A value of 0 does not prove completeness when epistemic is 'lower-bound' because an older or unverified index has no measured file count. Re-run \`gitnexus analyze --force\`; if the reason persists, inspect the extraction warnings. - causes.receiverTyping (unit: call sites) > 0 — the RESOLVER GAP signal: the analyzer dropped that many call sites because it could not establish the receiver's type (unresolved constructor, factory, chained expression). Those callers are absent from byDepth. Treat the result as incomplete: grep the symbol name before deleting or renaming. - causes.externalBoundary (unit: call sites) > 0 — those calls left the indexed program (System.out.println, fetch(...), os.environ.*). NOT a defect and NOT a reason the count is short: there is no in-graph node any edge could have reached. An epistemic:'exact' result can carry this. - causes.dispatchBoundary (unit: symbols) > 0 — DI or interface dispatch: that many symbols sit on or beyond a boundary a static walk cannot cross. Irreducible. A symbol count, not a site count — per-site multiplicity is not retained for these edges — so compare its magnitude with receiverTyping, not its exact value. A framework runtime-proxy boundary can make epistemic lower-bound while this value remains 0 because endpoint metadata proves the gap but cannot count omitted symbols. - causes.undecidedSatisfaction (unit: unjudged interface/type pairs) > 0 — the analyzer could not DECIDE whether a type satisfies an interface (a type in a required signature named a package it could not resolve), so no IMPLEMENTS edge exists and no dispatch boundary was left for the walk to notice. Distinct from every cause above, which count decided facts that could not be attributed; this one counts questions never answered. It is the only cause that shortens a result WITHOUT leaving a trace in the graph, so an unhedged zero on a symbol reached only through such an interface would otherwise read as 'nobody calls this'. Usually fixable: it most often means a dependency is missing from the analyzed tree. + - causes.callableValueReferences (unit: symbols) > 0 — that many symbols name this callable as a VALUE rather than calling it: 'bridge.accessor(Element.getNamespaceUri, ...)' and 'pub const h = onReset;' in Zig, '{ onClick: handler }' in JS/TS. Those are the shapes actually captured today — a bare callback argument in JS/TS ('qsort'-style, 'setTimeout(tick)') is NOT one of them and is not counted, so a 0 here does not rule that shape out. The registration IS modelled (a USES edge); the invocation through the stored value is NOT, because it happens later via a struct field, a registry lookup or comptime reflection. So impactedCount is a floor and a LOW risk verdict on such a symbol is a floor too. Unlike dispatchBoundary this is often reducible — it usually means the language provider does not yet follow that store/load — but until it is, do NOT read an empty or small caller set as 'safe to change'. It is an exact count, not a capped sample. It is 0 when the analyzer DID synthesize the dispatch through a registered property key, and epistemic stays 'exact' on that account. That exclusion is symbol-level, not edge-level — the graph does not record which registration produced which synthesized call — so a symbol with a mix of followed and unfollowed registrations also reads 0: treat a 0 as 'no unfollowed registration was proven', not as proof the value escapes nowhere. A 0 alongside epistemic 'lower-bound' can instead mean the probe could not run at all, so read boundaries to tell those apart. Read from the graph, so it needs no index-time metadata BEYOND the edges being there: an index built by an analyzer that did not yet emit this language's value-ref captures has none, and reports 0. Re-analyze before reading a 0 as measured. REQUIRES RE-INDEX: causes.scopeExtractionFiles, causes.receiverTyping, causes.externalBoundary, causes.undecidedSatisfaction, and framework runtime-proxy boundary detection depend on index-time metadata that only a current analyzer writes. Against an older index the metadata can be absent, which is indistinguishable from "nothing was dropped" unless the schema probe detects the stale index — re-run \`gitnexus analyze\` before trusting a zero or an apparently exact result. diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 8a22b5038..a06f646df 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -749,7 +749,24 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // v97: Objective-C macro-marker preprocessing recognizes comment-prefixed // directives and rejects invalid numeric marker prefixes. A warm v96 cache can // replay error-recovered facts from the previous normalization behavior. -const SCHEMA_BUMP = 97; +// v98 (#3219): `ZIG_SCOPE_QUERY` gained three `@reference.value-ref` rules — +// bare call argument, qualified call argument (with `@reference.receiver`), and +// const-binding initialiser — so a Zig callable named in VALUE position now +// produces a `value-ref` entry in `ParsedFile.referenceSites` where it produced +// none before. These captures are PARSE-TIME facts, so a warm pre-v98 cache +// replays unchanged `.zig` files with zero value-ref sites, `--force` included +// (shards are content-addressed): `emitPropertyDispatchCalls` then emits no +// USES edge, `callableValueReferenceBoundaries` measures a real zero, and +// `impact` on a registered accessor republishes `epistemic: "exact"` — the exact +// #3399 defect this change exists to close, silently un-fixed. +// +// 94-97 went to #3179 (Objective-C), which has since merged; 98 is the next +// value above it. Re-checked against origin/main and every open PR touching +// this file at merge time, which is the rule the paragraphs above were written +// by PRs that each checked only once: main is at 97, and the remaining open +// claims (#3190 at 94, #2840 at 71, #1616 at 2) all sit below it and must +// re-bump themselves. +const SCHEMA_BUMP = 98; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/main.zig index 1cb77e2ee..15c44f34a 100644 --- a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/main.zig +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/main.zig @@ -21,6 +21,7 @@ pub usingnamespace @import("mixin.zig"); pub const Interfaces = .{ @import("webapi/AbortController.zig"), @import("webapi/AbortSignal.zig"), + @import("webapi/Element.zig"), }; pub fn main() void { diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Element.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Element.zig new file mode 100644 index 000000000..02bdeed1e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Element.zig @@ -0,0 +1,224 @@ +// The JS-API binding-table idiom, as Lightpanda writes it (#3399). +// +// Every accessor below hands a Zig function to `bridge.accessor` AS A VALUE: +// the function is REGISTERED here, never called here. The eventual invocation +// runs through comptime reflection (`@call(.auto, func, args)` over a +// `func: anytype` field), which no static walk can follow — that terminal hop +// is out of scope. What was NOT acceptable is dropping the reference entirely: +// `impact` then reported the accessor as having only its two in-file callers +// and called that answer `exact`. +// +// A file-as-struct, like every webapi module in the real tree. +const Element = @This(); + +// A NAMESPACE-only module (no `@This()`), imported under a handle. The bridge +// table below registers one of its functions the same way it registers this +// file's own methods. +const dom_utils = @import("dom_utils.zig"); + +// A hub module that re-exports `dom_utils`' members without declaring any. +const hub = @import("hub.zig"); + +// A MODULE-LEVEL binding whose name collides with the container `Gauge.zig` +// declares, bound to something that is NOT a container. This file never imports +// `Gauge.zig`. `findClassBindingInScope` filters the scope chain by +// `isClassLike`, so it walks past this binding, and its workspace-wide +// qualified-name fallback answers with the other file's struct — while the +// shadow guard used to permit exactly this, treating the module scope as a floor +// it need not inspect. +// +// The name is bound by IMPORT rather than by a local `const Gauge: u8 = 3`, +// and that detail is the difference between a live case and a self-defeating +// one: a local declaration would also claim the workspace qualified name +// `Gauge`, leaving two candidates, and the fallback refuses to guess between +// two. An imported alias claims nothing, so the fallback stays unique and fires. +const Gauge = @import("dom_utils.zig").DEFAULT_NS; + +_namespace: u8 = 0, + +// ── Registered accessors ──────────────────────────────────────────────────── + +pub fn getNamespaceUri(self: *Element) u8 { + return self._namespace; +} + +// An ordinary in-file caller. The point of the defect is that the REGISTRATION +// was missing, not that the symbol looked like a leaf: a plausible-but-short +// caller list is exactly what makes `epistemic: "exact"` dangerous. +pub fn lookupNamespaceUri(self: *Element) u8 { + return self.getNamespaceUri(); +} + +// ── The control ───────────────────────────────────────────────────────────── + +// Called normally and registered NOWHERE. Its edges must not move: a change +// that hedges or re-links every method would be indistinguishable from one +// that models value references, and only the second is correct. +pub fn getTagNameLower(self: *Element) u8 { + return self._namespace; +} + +pub fn describe(self: *Element) u8 { + return self.getTagNameLower(); +} + +// ── Owner discrimination ──────────────────────────────────────────────────── + +// Shadowed below by a same-named sibling inside `JsApi`. The registration +// writes `Element.getLocalName`, so THIS is the one it must bind. +pub fn getLocalName(self: *Element) u8 { + return self._namespace; +} + +fn tick(self: *Element) u8 { + return self._namespace; +} + +// ── The binding table ─────────────────────────────────────────────────────── + +pub const JsApi = struct { + pub const bridge = Bridge(Element); + + // QUALIFIED value reference — the accessor names its container explicitly. + // This is the exact line from Element.zig:2296 that #3399 was filed over. + pub const namespaceURI = bridge.accessor(Element.getNamespaceUri, null, .{}); + + // BARE value reference to a sibling declared in this same container. + pub const tagName = bridge.accessor(_tagName, null, .{}); + + fn _tagName(self: *Element) u8 { + return self.getTagNameLower(); + } + + // A sibling with the SAME simple name as the file-struct method above. + // `walkScopeChain` gives a local binding precedence over the enclosing + // scope, so a registration resolved by TAIL NAME alone binds here — the + // wrong function, silently. Resolving `Element.getLocalName` through its + // written owner is what keeps them apart. + fn getLocalName(self: *Element) u8 { + return 0; + } + + pub const localName = bridge.accessor(Element.getLocalName, null, .{}); + + // A receiver this index cannot resolve. There is a file-level `tick`, and + // tail-name resolution would happily bind it even though the source says + // the function belongs to something else entirely. Declining is the only + // safe answer: a missing reference is recoverable, a confident wrong edge + // is not. + pub const ticker = bridge.accessor(unresolvable_ns.tick, null, .{}); + + // QUALIFIED value reference through a MODULE handle rather than a container. + // `dom_utils` is a namespace, not a class, so the class-owner lookup answers + // nothing here — and declining would be silent rather than safe: with no + // USES edge, `impact` on `compare` measures a real zero and reports `exact`, + // which is the claim this whole change exists to stop making. + pub const comparator = bridge.accessor(dom_utils.compare, null, .{}); + + // A namespace member that is NOT callable. Module receivers get the same + // callable gate as container receivers — a registration table full of + // constants must keep emitting nothing. + pub const defaultNs = bridge.accessor(dom_utils.DEFAULT_NS, null, .{}); + + // The receiver IS a known namespace import, but `dom_utils.zig` declares no + // `onlyOnDecoy` — so the namespace channel declines and the container + // channel runs, reaching `decoy.zig`'s same-named struct through the + // workspace-wide qualified-name index. A registration must NOT be minted + // there: the file wrote which module it meant. + pub const decoyed = bridge.accessor(dom_utils.onlyOnDecoy, null, .{}); + + // Through a HUB, whose published names are all imported ones. The CALL form + // resolves — `namespaceExportsIncludeImportedNames` is what makes a Zig hub + // work at all — so the REGISTRATION form has to resolve to the same def, or + // one name means two things depending on whether it is followed by `(`. + pub const scaled = bridge.accessor(hub.scale, null, .{}); + + // …and the callable gate still applies through the hub. + pub const hubNs = bridge.accessor(hub.DEFAULT_NS, null, .{}); + + // `Gauge` names this file's `const Gauge: u8`, not `Gauge.zig`'s container. + pub const level = bridge.accessor(Gauge.read, null, .{}); +}; + +// The CALL form of the same hub member, so the two are pinned side by side. +pub fn callsThroughTheHub(v: u8) u8 { + return hub.scale(v); +} + +// A LOCAL declaration shadowing the module handle. `dom_utils` here is a `u8` +// parameter with no member of its own; resolving `dom_utils.normalize` through +// the file-level import would attach the registration to a module the source +// did not name at this site — a wrong edge, the failure the same guard prevents +// on the member-CALL path. +pub fn shadowsTheModuleHandle(dom_utils: u8) u8 { + register(dom_utils.normalize); + return dom_utils; +} + +// A LOCAL declaration shadowing a CONTAINER name — the class-owner half of the +// same failure. `Ticker` here is a `u8` parameter, and this file neither +// declares nor imports the `Ticker` container that `Ticker.zig` defines. +// `findClassBindingInScope` filters the scope chain by `isClassLike`, so it +// walks straight past the parameter and its qualified-name fallback answers +// with a struct from a file this one never named. Resolving `Ticker.fire` +// through that is a confident edge to a function the source did not write. +pub fn shadowsAContainerName(Ticker: u8) u8 { + register(Ticker.fire); + return Ticker; +} + +// The positive half of the same guard: here the LOCAL declaration IS the +// container the registration names. A shadow test that only asked "is this name +// bound nearer than the module scope" would answer yes and decline — reading the +// declaration as its own shadow — and this whole class of local container would +// stop registering anything. +pub fn registersALocalContainer() u8 { + const Local = struct { + pub fn go() u8 { + return 3; + } + }; + return bridge.accessor(Local.go, null, .{}); +} + +// ── Const binding initialiser ─────────────────────────────────────────────── + +fn onReset(self: *Element) u8 { + return self._namespace; +} + +// A `const` whose initialiser IS a function value. Second value position, same +// class of drop. +pub const defaultHandler = onReset; + +// ── comptime anytype sink ─────────────────────────────────────────────────── + +var registered: ?*const fn (*Element) u8 = null; + +// Takes a callable by value into a `comptime … anytype` parameter and STORES +// it. Nothing in this file calls `f`. +pub fn register(comptime f: anytype) void { + registered = f; +} + +fn onTick(self: *Element) u8 { + return self._namespace; +} + +pub fn boot() void { + // `onTick` is never called in this file — it is handed over as a value and + // invoked later through `registered`. + register(onTick); +} + +fn Bridge(comptime T: type) type { + _ = T; + return struct { + pub fn accessor(comptime getter: anytype, comptime setter: anytype, comptime opts: anytype) u8 { + _ = getter; + _ = setter; + _ = opts; + return 0; + } + }; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Gauge.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Gauge.zig new file mode 100644 index 000000000..5b6071db4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Gauge.zig @@ -0,0 +1,10 @@ +// The unique workspace definition of the name `Gauge`. `Element.zig` never +// imports it, and declares a module-local `const Gauge` of its own — see +// `readsThroughAShadowedContainerName` there. +const Gauge = @This(); + +_level: u8 = 0, + +pub fn read(self: *Gauge) u8 { + return self._level; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Ticker.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Ticker.zig new file mode 100644 index 000000000..58fb48aa5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Ticker.zig @@ -0,0 +1,13 @@ +// A file-struct in a module `Element.zig` never imports. +// +// Its only job is to be the unique workspace definition of the name `Ticker`, +// so that `findClassBindingInScope`'s qualified-name fallback can reach it from +// a file that has no binding for that name at all. See the +// `shadowsAContainerName` case in `Element.zig`. +const Ticker = @This(); + +_ticks: u8 = 0, + +pub fn fire(self: *Ticker) u8 { + return self._ticks; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Widget.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Widget.zig new file mode 100644 index 000000000..595c3aab9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Widget.zig @@ -0,0 +1,70 @@ +// A file-as-struct whose `@This()` alias is spelled `Self`, NOT `Widget`. +// +// This is the ordinary Zig idiom, and the case #3219 originally declined. The +// container this file mints is named after the FILE STEM (`Widget`), so before +// `bindZigThisAliases` the name `Self` meant nothing class-like here: a +// file-level `@This()` alias mints no Const at all (`isZigFileThisAlias` +// suppresses it so it cannot shadow the type for `w: *Widget`), and a +// container-level one mints a Variable that every `isClassLike` walk steps +// over. Every qualified reference through the alias — a CALL as much as a +// REGISTRATION — resolved to nothing. +// +// Counted on the corpora on hand when this was fixed: 73 of ghostty's 185 +// `@This()` files, 93 of tigerbeetle's 94 and 8 of mach's 42 spell the alias +// differently from the file stem, carrying 302 `Alias.member` references, 96 +// of them calls. +// +// `Element.zig` next door is the OTHER half of the control: it writes +// `const Element = @This();` in `Element.zig`, so its qualified references +// resolved through the stem binding and must keep resolving exactly as before. +const Self = @This(); + +_w: u8 = 0, + +pub fn width(self: *Self) u8 { + return self._w; +} + +// A qualified CALL through the alias — the explicit spelling Zig allows beside +// `self.width()`, and the shape `Alias.member` takes 96 times in the corpora +// above. +pub fn describeWidth(self: *Self) u8 { + return Self.width(self); +} + +pub const WidgetApi = struct { + pub const binder = Binder(Self); + // A qualified REGISTRATION through the alias: the #3399 shape, written the + // way most Zig files spell their own type. + pub const w = binder.accessor(Self.width, null, .{}); +}; + +// A NESTED container with its own differently-spelled alias. The file-level and +// container-level aliases take different code paths — one binds at the module +// scope against the file-struct, the other at the container's own scope — so +// both are exercised. +pub const Metrics = struct { + const Me = @This(); + + _n: u8 = 0, + + pub fn read(self: *Me) u8 { + return self._n; + } + + pub fn readTwice(self: *Me) u8 { + return Me.read(self) +% Me.read(self); + } +}; + +fn Binder(comptime T: type) type { + _ = T; + return struct { + pub fn accessor(comptime g: anytype, comptime s: anytype, comptime o: anytype) u8 { + _ = g; + _ = s; + _ = o; + return 0; + } + }; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/decoy.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/decoy.zig new file mode 100644 index 000000000..1197a40c8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/decoy.zig @@ -0,0 +1,24 @@ +// A container whose NAME collides with `Element.zig`'s `dom_utils` import +// handle, in a file `Element.zig` never imports. +// +// `findClassBindingInScope` does not stop at the scope chain: when its +// `isClassLike` walk misses — and a namespace import binds a Module, not a +// class — it falls back to `scopes.qualifiedNames`, a WORKSPACE-wide index, and +// answers with the unique def of that name. This struct is that unique def. A +// registration written `dom_utils.compare` in `Element.zig` must still bind +// `dom_utils.zig`'s function, not this one: the file said which module it meant. +pub const dom_utils = struct { + pub fn compare(a: u8, b: u8) u8 { + return if (a < b) a else b; + } + + // A callable that exists ONLY here. `dom_utils.zig` has no member of this + // name, so a registration written `dom_utils.onlyOnDecoy` declines in the + // namespace channel and falls through to the container channel — where the + // workspace-wide qualified-name fallback answers with this struct. The + // owner-shadow guard is the only thing standing between that and a + // confident edge into a file `Element.zig` never imported. + pub fn onlyOnDecoy(v: u8) u8 { + return v; + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/dom_utils.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/dom_utils.zig new file mode 100644 index 000000000..e8972ee41 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/dom_utils.zig @@ -0,0 +1,24 @@ +// A NAMESPACE-only module: no `const X = @This()`, so this file declares no +// container symbol of its own. Its members are reachable only through an +// `@import` handle — the second kind of owner a qualified name can have, and +// the one `findClassBindingInScope` cannot answer for. +// +// Lightpanda's `libdom.zig` / `parser.zig` helpers are written exactly this +// way, and they are registered into the same bridge tables as the file-struct +// methods next door. + +pub const DEFAULT_NS: u8 = 7; + +pub fn compare(a: u8, b: u8) u8 { + return if (a > b) a else b; +} + +pub fn normalize(v: u8) u8 { + return v; +} + +// Republished by `hub.zig`, and by nothing else — so an assertion about the +// hub cannot be satisfied by an edge some other case emitted. +pub fn scale(v: u8) u8 { + return v +% 1; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/hub.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/hub.zig new file mode 100644 index 000000000..b99f60d9e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/hub.zig @@ -0,0 +1,10 @@ +// A HUB module: a file made only of re-exports, the shape +// `ScopeResolver.namespaceExportsIncludeImportedNames` exists for (ghostty's +// `src/terminal/`, tigerbeetle's `stdx`). It declares nothing of its own — +// every name it publishes is a name it imported. +// +// A consumer writes `hub.scale(x)` to CALL through it, and +// `bridge.accessor(hub.scale, …)` to REGISTER through it. Those are the same +// name resolved by the same rule, so they must not disagree. +pub const scale = @import("dom_utils.zig").scale; +pub const DEFAULT_NS = @import("dom_utils.zig").DEFAULT_NS; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-monorepo/README.md b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/README.md new file mode 100644 index 000000000..4ec85bc38 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/README.md @@ -0,0 +1,19 @@ +A Zig MONOREPO: several build packages, and no `build.zig` at the repo root. + +This is the layout the root-only config loader could not see at all. With no +root `build.zig` / `build.zig.zon`, `loadZigBuildConfig` answered `null`, and +every bare `@import("")` below went unresolved — so cross-file symbol +resolution in a repo like this degraded to relative imports only. + +The three packages are not interchangeable: + +- `core` declares module `core` and is imported by `app`. +- `app` declares module `app` and depends on `core` through a + `build.zig.zon` `.path = "../core"` — a spelling relative to the PACKAGE, + which has to be rebased to repo-relative `packages/core` before it can be + matched against indexed files. +- `tool` is the discriminating control. It binds the SAME alias `core` to its + OWN `src/core.zig`. A workspace that flattened every package's modules into + one repo-wide map would resolve `tool`'s `@import("core")` to `core`'s root + — a confident edge into a package `tool` never depends on. Only per-package + scoping (`zigPackageFor`, the `tsconfigFor` analogue) keeps them apart. diff --git a/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/app/build.zig b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/app/build.zig new file mode 100644 index 000000000..e237138a8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/app/build.zig @@ -0,0 +1,7 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const core_dep = b.dependency("core", .{}); + const app = b.addModule("app", .{ .root_source_file = b.path("src/main.zig") }); + app.addImport("core", core_dep.module("core")); +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/app/build.zig.zon b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/app/build.zig.zon new file mode 100644 index 000000000..2b666b3d1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/app/build.zig.zon @@ -0,0 +1,19 @@ +.{ + .name = "app", + .version = "0.1.0", + .dependencies = .{ + .core = .{ + .path = "../core", + }, + // ABSOLUTE, so it points outside the repository whichever package + // declares it. It sits in a NESTED package on purpose: that is the + // branch where the package prefix used to hide it. `packages/app/` + + // `/src` reads as the relative, in-repo `packages/app/src` — a real + // directory in this fixture — so the absolute dep silently became the + // package's own source tree instead of being rejected. + .escapes = .{ + .path = "/src", + }, + }, + .paths = .{""}, +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/app/src/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/app/src/main.zig new file mode 100644 index 000000000..26798d708 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/app/src/main.zig @@ -0,0 +1,18 @@ +// `core` is a PACKAGE dependency, declared by this package's build.zig.zon and +// wired in by its build.zig — not a relative path. Resolving it needs the +// package's own config, which lives two directories below the repo root. +const core = @import("core"); +const util = @import("util.zig"); + +pub fn run(attempt: u8) u8 { + return core.retryBudget(attempt); +} + +pub fn configured() u8 { + var cfg = core.Config{}; + return cfg.load(); +} + +pub fn delegated(attempt: u8) u8 { + return util.clamp(attempt); +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/app/src/util.zig b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/app/src/util.zig new file mode 100644 index 000000000..5387442cd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/app/src/util.zig @@ -0,0 +1,8 @@ +// A second file of the same package reaching the same dependency: membership in +// a build module is the root plus what it reaches, so a file that is not itself +// a module root must resolve the alias too. +const core = @import("core"); + +pub fn clamp(attempt: u8) u8 { + return core.retryBudget(attempt); +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/core/build.zig b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/core/build.zig new file mode 100644 index 000000000..53ac256a7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/core/build.zig @@ -0,0 +1,5 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + _ = b.addModule("core", .{ .root_source_file = b.path("src/root.zig") }); +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/core/src/root.zig b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/core/src/root.zig new file mode 100644 index 000000000..b048ced4c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/core/src/root.zig @@ -0,0 +1,13 @@ +pub const DEFAULT_RETRIES: u8 = 3; + +pub fn retryBudget(attempt: u8) u8 { + return DEFAULT_RETRIES - attempt; +} + +pub const Config = struct { + retries: u8 = DEFAULT_RETRIES, + + pub fn load(self: *Config) u8 { + return self.retries; + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/tool/build.zig b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/tool/build.zig new file mode 100644 index 000000000..8a3964bee --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/tool/build.zig @@ -0,0 +1,8 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const tool = b.addModule("tool", .{ .root_source_file = b.path("src/main.zig") }); + // The SAME alias as `app` binds, pointing somewhere else entirely. + const own_core = b.createModule(.{ .root_source_file = b.path("src/core.zig") }); + tool.addImport("core", own_core); +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/tool/src/core.zig b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/tool/src/core.zig new file mode 100644 index 000000000..1616db72b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/tool/src/core.zig @@ -0,0 +1,5 @@ +// `tool`'s own `core`, unrelated to `packages/core`. Same module name, and that +// is the point: the name is only meaningful inside the package that binds it. +pub fn retryBudget(attempt: u8) u8 { + return attempt; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/tool/src/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/tool/src/main.zig new file mode 100644 index 000000000..979d76d7d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-monorepo/packages/tool/src/main.zig @@ -0,0 +1,7 @@ +// Resolving this through a repo-wide module map would reach +// `packages/core/src/root.zig` — a package `tool` does not depend on. +const core = @import("core"); + +pub fn measure(attempt: u8) u8 { + return core.retryBudget(attempt); +} diff --git a/gitnexus/test/integration/impact-callable-value-references.test.ts b/gitnexus/test/integration/impact-callable-value-references.test.ts new file mode 100644 index 000000000..6124d76d3 --- /dev/null +++ b/gitnexus/test/integration/impact-callable-value-references.test.ts @@ -0,0 +1,231 @@ +/** + * Integration test: a callable named in VALUE position makes `impact` a lower + * bound (#3399). + * + * The rejected behaviour, in one sentence: `Element.getNamespaceUri` — the DOM + * `Element.namespaceURI` accessor — is bound into a JS bridge table as + * `bridge.accessor(Element.getNamespaceUri, null, .{})`, and `impact` answered + * "2 callers, LOW risk, epistemic: exact". Everything about that answer except + * the number 2 was wrong, and `exact` is the part that made it unusable: it + * tells the reader not to look further. + * + * The seed below is the shape that matters, not the language. A registration + * edge (`USES`, reason `scope-resolution: value-ref`) says a function was + * handed somewhere as a value. Where the value goes next — a struct field, a + * registry lookup, comptime reflection — is not modelled, so no CALLS edge + * connects the eventual invocation back to the target. `tools.ts` defines + * `lower-bound` as "the walk provably missed callers", and that is exactly this + * situation. + * + * WHY the assertions are what they are: + * - `impactedCount` must NOT move. Hedging is not inventing callers; a fix + * that made the number go up would be a different (and unearned) claim. + * - a plain CALLS-only target in the SAME index must stay `exact`, or the + * hedge is noise sprayed over every query and carries no information. + * - the cause has its own slot rather than being folded into + * `dispatchBoundary`: an agent branching on the numbers would otherwise be + * told an interface boundary exists where there is none, and would draw the + * opposite conclusion about whether the gap is reducible. + */ +import { it, expect, beforeAll, vi } from 'vitest'; +import path from 'node:path'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { withTestLbugDB } from '../helpers/test-indexed-db.js'; +import { VALUE_REF_EDGE_REASON } from '../../src/core/ingestion/scope-resolution/value-ref-edges.js'; + +/** + * Fail ONLY the value-reference probe, leaving every other query on the real + * database. Keyed on the bound `$reason` param rather than the query text, so + * it cannot accidentally match a different query that happens to mention USES. + */ +let failValueRefProbe = false; + +vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + executeParameterized: (...args: any[]) => { + const params = args[2] as { reason?: string } | undefined; + if (failValueRefProbe && params?.reason === 'scope-resolution: value-ref') { + return Promise.reject(new Error('simulated: index unreadable')); + } + return (actual as any).executeParameterized(...args); + }, + }; +}); + +vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), + }; +}); +const { listRegisteredRepos, saveMeta } = await import('../../src/storage/repo-manager.js'); + +const SEED = [ + // The registered accessor, with one ordinary in-file caller so the walk has + // something real to report. This mirrors Element.zig: `getNamespaceUri` genuinely + // has internal callers, and it is the REGISTRATION that the answer omits. + `CREATE (:Method {id: 'Method:webapi/Element.zig:Element.getNamespaceUri', name: 'getNamespaceUri', filePath: 'webapi/Element.zig', startLine: 439, endLine: 442, isExported: true, content: '', description: ''})`, + `CREATE (:Method {id: 'Method:webapi/Element.zig:Element.lookupPrefixForElement', name: 'lookupPrefixForElement', filePath: 'webapi/Element.zig', startLine: 490, endLine: 520, isExported: true, content: '', description: ''})`, + `MATCH (a:Method {id:'Method:webapi/Element.zig:Element.lookupPrefixForElement'}), (b:Method {id:'Method:webapi/Element.zig:Element.getNamespaceUri'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.85, reason:'scope-resolution: local-call', step:0}]->(b)`, + + // The binding table entry: `pub const namespaceURI = bridge.accessor(Element.getNamespaceUri, null, .{});` + // A registration, NOT an invocation — hence USES, not CALLS (Kythe `ref` vs + // `ref/call`; Joern METHOD_REF). + `CREATE (:Struct {id: 'Struct:webapi/Element.zig:Element.JsApi', name: 'JsApi', filePath: 'webapi/Element.zig', startLine: 2281, endLine: 2400, content: '', description: ''})`, + `MATCH (a:Struct {id:'Struct:webapi/Element.zig:Element.JsApi'}), (b:Method {id:'Method:webapi/Element.zig:Element.getNamespaceUri'}) CREATE (a)-[:CodeRelation {type:'USES', confidence:0.85, reason:'${VALUE_REF_EDGE_REASON}', step:0}]->(b)`, + + // Control: same file, same shape of caller, but nothing registers it as a + // value. This is `getTagNameLower` — it must come back `exact`. + `CREATE (:Method {id: 'Method:webapi/Element.zig:Element.getTagNameLower', name: 'getTagNameLower', filePath: 'webapi/Element.zig', startLine: 400, endLine: 410, isExported: true, content: '', description: ''})`, + `MATCH (a:Method {id:'Method:webapi/Element.zig:Element.lookupPrefixForElement'}), (b:Method {id:'Method:webapi/Element.zig:Element.getTagNameLower'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.85, reason:'scope-resolution: local-call', step:0}]->(b)`, + + // Second control: an ordinary USES edge that is NOT a value registration (a + // type reference). The probe keys on the reason, so this must not hedge — + // otherwise every type mention in the repo would downgrade its target. + `CREATE (:Method {id: 'Method:webapi/Element.zig:Element.getInnerText', name: 'getInnerText', filePath: 'webapi/Element.zig', startLine: 600, endLine: 610, isExported: true, content: '', description: ''})`, + `MATCH (a:Struct {id:'Struct:webapi/Element.zig:Element.JsApi'}), (b:Method {id:'Method:webapi/Element.zig:Element.getInnerText'}) CREATE (a)-[:CodeRelation {type:'USES', confidence:0.85, reason:'scope-resolution: type-reference', step:0}]->(b)`, + + // Third control: a registration whose invocation the analyzer DID synthesize. + // `emitPropertyDispatchCalls` sweep 2 connects `x.()` member calls to + // every function registered under `` and stamps those edges + // `property-dispatch`. Where that happened the walk followed the + // registration, so hedging would be noise over an answer that was actually + // computed. This is the JS/TS hook-table shape, not the Zig one — Zig has no + // object-literal key to dispatch through and so is never excluded. + `CREATE (:Function {id: 'Function:hooks/registry.js:onClick', name: 'onClick', filePath: 'hooks/registry.js', startLine: 5, endLine: 8, isExported: true, content: '', description: ''})`, + `CREATE (:Function {id: 'Function:hooks/registry.js:registerAll', name: 'registerAll', filePath: 'hooks/registry.js', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`, + `CREATE (:Function {id: 'Function:hooks/consumer.js:runHandlers', name: 'runHandlers', filePath: 'hooks/consumer.js', startLine: 1, endLine: 6, isExported: true, content: '', description: ''})`, + `MATCH (a:Function {id:'Function:hooks/registry.js:registerAll'}), (b:Function {id:'Function:hooks/registry.js:onClick'}) CREATE (a)-[:CodeRelation {type:'USES', confidence:0.85, reason:'${VALUE_REF_EDGE_REASON}', step:0}]->(b)`, + `MATCH (a:Function {id:'Function:hooks/consumer.js:runHandlers'}), (b:Function {id:'Function:hooks/registry.js:onClick'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.7, reason:'property-dispatch', step:0}]->(b)`, +]; + +withTestLbugDB( + 'impact-callable-value-references', + (handle) => { + let backend: LocalBackend; + beforeAll(() => { + backend = (handle as any)._backend; + }); + + it('downgrades a registered accessor to lower-bound without inventing callers', async () => { + const result: any = await backend.callTool('impact', { + target: 'getNamespaceUri', + direction: 'upstream', + }); + expect(result).not.toHaveProperty('error'); + // Pinned, not implied: hedging must not INVENT callers. The seed gives + // this symbol exactly two inbound edges — one CALLS, one value-ref USES — + // and both are traversed. If a later change made the hedge also widen the + // walk, every other assertion here would still pass. + expect(result.impactedCount).toBe(2); + // The registration is a real inbound edge, so it IS traversed and counted + // — but the call THROUGH the registered value is not, which is why the + // count still cannot be the whole story. + expect(result.epistemic).toBe('lower-bound'); + expect(result.causes.callableValueReferences).toBe(1); + // Its own slot: an agent must not read this as an interface boundary. + expect(result.causes.dispatchBoundary).toBe(0); + expect(result.boundaries.join(' ')).toContain('as a VALUE'); + }); + + it('leaves a symbol with only ordinary calls exact', async () => { + const result: any = await backend.callTool('impact', { + target: 'getTagNameLower', + direction: 'upstream', + }); + expect(result).not.toHaveProperty('error'); + expect(result.epistemic).toBe('exact'); + }); + + it('does not hedge on a USES edge that is not a value registration', async () => { + const result: any = await backend.callTool('impact', { + target: 'getInnerText', + direction: 'upstream', + }); + expect(result).not.toHaveProperty('error'); + // A type reference is a use, not an escape: nothing can be invoked + // through it, so the answer stays complete. + expect(result.epistemic).toBe('exact'); + }); + + it('hedges context() for the same reason it hedges impact()', async () => { + const result: any = await backend.callTool('context', { name: 'getNamespaceUri' }); + expect(result).not.toHaveProperty('error'); + expect(result.epistemic).toBe('lower-bound'); + expect(result.causes.callableValueReferences).toBe(1); + }); + + it('stays exact when the analyzer already synthesized the dispatch', async () => { + // `onClick` is registered as a value AND reached by a synthesized + // property-dispatch CALLS edge. The walk did not "provably miss" that + // caller, so `lower-bound` would be wrong — and a signal that fires on + // every hook table in every JS codebase carries no information. + const result: any = await backend.callTool('impact', { + target: 'onClick', + direction: 'upstream', + }); + expect(result).not.toHaveProperty('error'); + expect(result.epistemic).toBe('exact'); + }); + + it('hedges — never reports exact — when the probe itself cannot run', async () => { + // The failure mode this whole feature exists to remove is silence reading + // as certainty. A probe that threw has not established that the symbol is + // unregistered; swallowing the error into a zero would publish `exact` on + // the strength of a question that was never answered. + failValueRefProbe = true; + try { + const result: any = await backend.callTool('impact', { + target: 'getTagNameLower', // the control: exact when the probe works + direction: 'upstream', + }); + expect(result).not.toHaveProperty('error'); + expect(result.epistemic).toBe('lower-bound'); + expect(result.boundaries.join(' ')).toContain('could not be run'); + // No count is claimed — the note is the signal, and inventing a + // magnitude from a failed query would be the same error inverted. + expect(result.causes.callableValueReferences).toBe(0); + } finally { + failValueRefProbe = false; + } + }); + + it('stays exact downstream — a reference INTO a symbol says nothing about what it reaches', async () => { + const result: any = await backend.callTool('impact', { + target: 'getNamespaceUri', + direction: 'downstream', + }); + expect(result).not.toHaveProperty('error'); + expect(result.epistemic).toBe('exact'); + }); + }, + { + seed: SEED, + poolAdapter: true, + afterSetup: async (h) => { + // Without a completeness receipt EVERY answer is hedged ("scope-extraction + // completeness was not recorded"), which would make the controls below + // pass for the wrong reason and prove nothing about this probe. + // `saveMeta` is the only writer production uses. + await saveMeta(path.dirname(h.dbPath), { scopeExtractionReceipt: 1 } as any); + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'test-repo', + path: '/test/repo', + storagePath: h.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + stats: { files: 1, nodes: 5, communities: 0, processes: 0 }, + }, + ] as any); + const backend = new LocalBackend(); + await backend.init(); + (h as any)._backend = backend; + }, + }, +); diff --git a/gitnexus/test/integration/resolvers/zig.test.ts b/gitnexus/test/integration/resolvers/zig.test.ts index 724bd5368..ffdd22750 100644 --- a/gitnexus/test/integration/resolvers/zig.test.ts +++ b/gitnexus/test/integration/resolvers/zig.test.ts @@ -229,6 +229,328 @@ describe.skipIf(!zigAvailable)('Zig idioms (zig-idioms fixture)', () => { expect(calls).toContain('main → incr'); }); + /** + * #3399 — a callable named in VALUE position. + * + * `src/webapi/Element.zig` is the JS-API binding-table idiom verbatim: + * `pub const namespaceURI = bridge.accessor(Element.getNamespaceUri, null, .{});` + * registers a Zig function with the JS bridge instead of calling it. Zig + * emitted NO `value-ref` capture at all, so every one of those references was + * dropped — 2,047 of them across 257 files in lightpanda-io/browser, the whole + * JS<->Zig surface — and `impact` on a public DOM accessor answered with its + * two in-file callers and the verdict `epistemic: "exact"`. + * + * These assert USES and not CALLS on purpose. A registration is not an + * invocation (Kythe `ref` vs `ref/call`; Joern METHOD_REF), and the call that + * eventually happens goes through comptime reflection this analyzer cannot + * follow. The claim being pinned is the reference, not the dispatch. + */ + describe('callable values (#3399)', () => { + let uses: string[]; + let valueRefs: string[]; + let valueRefTargetIds: string[]; + beforeAll(() => { + const edges = getRelationships(result, 'USES'); + uses = edgeSet(edges); + valueRefTargetIds = edges + .filter((e) => e.rel.reason === 'scope-resolution: value-ref') + .map((e) => e.rel.targetId) + .sort(); + // The reason text is spelled out rather than imported from + // `VALUE_REF_EDGE_REASON`, deliberately and as `typescript-value-refs.test.ts` + // already does: `impact`'s epistemic probe matches this exact string in + // the stored graph, so a change to the constant's VALUE (as opposed to + // its name) must fail a test rather than quietly agree with itself on + // both sides. + valueRefs = edgeSet(edges.filter((e) => e.rel.reason === 'scope-resolution: value-ref')); + }); + + it('records a QUALIFIED function value handed to a call (`bridge.accessor(Element.getNamespaceUri, …)`)', () => { + expect(valueRefs).toContain('JsApi → getNamespaceUri'); + }); + + it('records a BARE function value handed to a call (`bridge.accessor(_tagName, …)`)', () => { + expect(valueRefs).toContain('JsApi → _tagName'); + }); + + it('records a function value in a const initialiser (`pub const defaultHandler = onReset;`)', () => { + expect(valueRefs).toContain('Element → onReset'); + }); + + it('records a function passed into a `comptime f: anytype` parameter and stored', () => { + // The shape the non-goal is about: `register(onTick)` stores the value in + // a module-level field and nothing in the file ever calls `onTick`. The + // terminal invoke needs comptime evaluation and is NOT modelled — but the + // reference must survive, or `onTick` reads as dead code. + expect(valueRefs).toContain('boot → onTick'); + expect(calls).not.toContain('boot → onTick'); + }); + + it('emits USES, never CALLS, for a registration', () => { + // The whole distinction: if these became CALLS, `impact` would claim the + // accessor is invoked from the binding table, which is not a fact the + // analyzer has. + expect(calls).not.toContain('JsApi → getNamespaceUri'); + expect(calls).not.toContain('JsApi → _tagName'); + }); + + it('does not mint a value reference for a non-callable argument', () => { + // `Bridge(Element)` passes a TYPE. The property-dispatch pass keeps only + // Function/Method/Constructor targets, which is what makes the broad + // capture rules safe — the same gate that stops TypeScript's + // `{ port: DEFAULT_PORT }` from registering anything. + expect(valueRefs).not.toContain('JsApi → Element'); + expect(uses.filter((u) => u === 'JsApi → Element')).toHaveLength(0); + }); + + it('leaves a method that is only ever CALLED untouched', () => { + // The control. `getTagNameLower` is called twice and registered nowhere; + // a change that sprayed USES edges over every method would satisfy every + // assertion above and still be wrong. + expect(calls).toContain('describe → getTagNameLower'); + expect(calls).toContain('_tagName → getTagNameLower'); + expect(valueRefs.filter((v) => v.endsWith(' → getTagNameLower'))).toEqual([]); + }); + + it('binds a QUALIFIED reference to the owner that was written, not the nearest lexical match', () => { + // `JsApi` declares its own `getLocalName` next to + // `bridge.accessor(Element.getLocalName, …)`. `walkScopeChain` gives that + // local binding precedence, so resolving the registration by tail name + // alone attaches it to the SIBLING — a confidently wrong edge, which is a + // worse failure than the missing edge this whole change is about. The + // written receiver is the only thing that tells them apart. + const local = valueRefTargetIds.filter((id) => id.endsWith('.getLocalName#0')); + expect(local).toEqual(['Method:src/webapi/Element.zig:Element.getLocalName#0']); + expect(local).not.toContain('Method:src/webapi/Element.zig:JsApi.getLocalName#0'); + }); + + it('declines a qualified reference whose receiver cannot be resolved', () => { + // `bridge.accessor(unresolvable_ns.tick, …)` names an owner this index + // does not have, while a file-level `tick` sits in the lexical chain + // waiting to be mis-bound. Emitting nothing is the safe direction, but be + // exact about what it buys: no edge means no evidence, and `impact` on + // `tick` therefore stays `epistemic: "exact"` — this decline costs the + // reference AND the hedge. It is still the right trade, because the + // alternative is a confident edge to a function the source did not name, + // and a wrong edge is worse than a missing one. See + // `resolveValueRefTarget`'s docstring for the same distinction, and the + // module-owner case below for the half of it that IS recoverable. + expect(valueRefTargetIds.filter((id) => id.includes('.tick#'))).toEqual([]); + expect(valueRefs).not.toContain('JsApi → tick'); + }); + + it('records a QUALIFIED function value owned by a MODULE, not a container (`bridge.accessor(dom_utils.compare, …)`)', () => { + // `dom_utils` is a namespace-only file — no `@This()`, so no container + // symbol to look the member up on. Resolving only through class-like + // owners declines here, and a decline is SILENT: with no USES edge the + // boundary probe measures a real zero and `impact` on `compare` goes back + // to `epistemic: "exact"`, which is the defect, not a conservative answer. + // The member-CALL path already resolves `dom_utils.compare()` through the + // file's namespace import; the registration reads the same channel. + expect(valueRefs).toContain('JsApi → compare'); + // And it must be dom_utils.zig's `compare`, not `decoy.zig`'s. That file + // declares a CONTAINER also called `dom_utils`, with its own `compare`, + // and `Element.zig` never imports it. `findClassBindingInScope` does not + // stop at the scope chain: a namespace handle binds a Module, so the + // `isClassLike` walk misses and its workspace-wide `qualifiedNames` + // fallback answers with that unique container — preempting the `@import` + // this very file wrote. The written import has to outrank a global guess. + expect(valueRefTargetIds.filter((id) => id.includes('compare'))).toEqual([ + 'Function:src/webapi/dom_utils.zig:compare', + ]); + }); + + it('resolves a value reference through a HUB the same way a call through it resolves', () => { + // `hub.zig` declares nothing: every name it publishes it imported + // (`pub const normalize = @import("dom_utils.zig").normalize;`). That is + // the shape `ScopeResolver.namespaceExportsIncludeImportedNames` exists + // for, and `receiver-bound-calls` honours it — so `hub.scale(v)` + // resolves. Accepting only locally-declared members here would make + // `bridge.accessor(hub.scale, …)` decline, and one name would mean + // two different things depending on whether a `(` followed it. + expect(calls).toContain('callsThroughTheHub → scale'); + expect(valueRefs).toContain('JsApi → scale'); + expect(valueRefTargetIds.filter((id) => id.includes('scale'))).toEqual([ + 'Function:src/webapi/dom_utils.zig:scale', + ]); + }); + + it('applies the callable gate through a HUB too', () => { + expect(valueRefs).not.toContain('JsApi → DEFAULT_NS'); + }); + + it('applies the callable gate to a MODULE owner too', () => { + // `dom_utils.DEFAULT_NS` is a module-scope constant. Widening the owner + // channel must not widen what counts as a registration, or every + // `bridge.accessor(mod.SOME_CONST, …)` in a binding table starts claiming + // a callable was registered. + expect(valueRefs).not.toContain('JsApi → DEFAULT_NS'); + expect(valueRefTargetIds.filter((id) => id.includes('DEFAULT_NS'))).toEqual([]); + }); + + it('declines a module-qualified reference whose handle is locally shadowed', () => { + // `shadowsTheModuleHandle(dom_utils: u8)` names a PARAMETER, not the + // file-level `@import`. Reading through the import here would attach the + // registration to a module this site never named — the same wrong-edge + // failure `isNamespaceNameShadowed` prevents on the member-call path, and + // the reason the module channel is guarded rather than merely added. + expect(valueRefs).not.toContain('shadowsTheModuleHandle → normalize'); + expect(valueRefTargetIds.filter((id) => id.includes('normalize'))).toEqual([]); + }); + + it('declines a container-qualified reference whose owner name is locally shadowed', () => { + // `shadowsAContainerName(Ticker: u8)` names a PARAMETER. This file neither + // declares nor imports `Ticker.zig`'s container, so + // `findClassBindingInScope` walks past the parameter (it filters by + // `isClassLike`) and its qualified-name fallback answers with the unique + // workspace `Ticker` — a struct the source never named at this site. + // Verified to emit `shadowsAContainerName → fire` without the guard. + expect(valueRefs).not.toContain('shadowsAContainerName → fire'); + expect(valueRefTargetIds.filter((id) => id.includes('Ticker'))).toEqual([]); + }); + + it('still binds a reference whose container IS the local declaration', () => { + // `registersALocalContainer` declares `Local` in its own body and registers + // `Local.go`. The shadow guard above must exempt the container it just + // resolved, or the nearer binding — which is that container — reads as its + // own shadow and every function-local registry stops registering. + expect(valueRefs).toContain('registersALocalContainer → go'); + }); + + it('declines a container-qualified reference shadowed at MODULE scope', () => { + // `Element.zig` binds `Gauge` at module scope to something that is NOT a + // container, and never imports `Gauge.zig`, which declares one. The class + // walk filters by `isClassLike`, steps over that binding, and its + // workspace-wide qualified-name fallback answers with the other file's + // struct. The shadow guard has to inspect the MODULE scope to catch it — + // stopping one rung short, as it did, permitted precisely this case. + // + // The binding is an IMPORT (`const Gauge = @import("dom_utils.zig").DEFAULT_NS;`), + // not a local `const Gauge: u8 = 3;`, and that is the difference between a + // live case and a self-defeating one: a local declaration would ALSO claim + // the workspace qualified name `Gauge`, leaving two candidates, and the + // fallback refuses to guess between two — so the case this test exists for + // would never be reached. See the fixture's own note at `Element.zig:30-35`. + expect(valueRefs).not.toContain('JsApi → read'); + expect(valueRefTargetIds.filter((id) => id.includes('Gauge'))).toEqual([]); + }); + + it('declines a namespace member the written module does not have, rather than reaching a same-named container', () => { + // The fall-through the channel order creates, and the guard that closes + // it. `dom_utils` IS a namespace import here, but `dom_utils.zig` has no + // `onlyOnDecoy`, so the namespace channel declines — and declining is not + // the end: `findClassBindingInScope` runs next, its `isClassLike` walk + // misses (an import binds a Module), and its WORKSPACE-WIDE + // `qualifiedNames` fallback answers with `decoy.zig`'s same-named struct, + // which does declare `onlyOnDecoy`. Only `isOwnerNameShadowedBySomethingElse` + // stands between that and a confident edge into a file this one never + // imported — the wrong-edge failure, arriving through the container + // channel after the namespace channel said no. + expect(valueRefs).not.toContain('JsApi → onlyOnDecoy'); + expect(valueRefTargetIds.filter((id) => id.includes('onlyOnDecoy'))).toEqual([]); + }); + + it('does not mint a value reference for the CALLEE of an ordinary call', () => { + // `register(onTick)` must produce ONE value reference (the argument), not + // two: without binding the callee to the `function:` field the same rule + // also matches `register` itself and every call in the repo would emit a + // USES edge shadowing its own CALLS edge. + expect(valueRefs).not.toContain('boot → register'); + expect(calls).toContain('boot → register'); + }); + }); + + /** + * `@This()` aliases (#3219 review round 8). + * + * `@This()` IS the enclosing container, and `const Self = @This();` is how + * most Zig files say so. The container itself is minted under the FILE STEM, + * and the alias bound nothing class-like — a file-level alias mints no Const + * at all, a container-level one mints a Variable that every `isClassLike` + * walk steps over — so `Self.member` resolved to nothing at all: not a wrong + * edge, no edge. On the corpora at hand that is 302 `Alias.member` + * references (ghostty 73 files, tigerbeetle 93, mach 8), 96 of them calls. + * + * `bindZigThisAliases` binds the alias name to its container in the + * post-finalize augmentation channel, so a compiler's answer and this + * index's answer agree. Both spellings of the alias are exercised: + * `Widget.zig`'s file-level `Self` and `Metrics`' container-level `Me`. + */ + describe('@This() aliases (#3219)', () => { + let valueRefs: string[]; + let valueRefTargetIds: string[]; + beforeAll(() => { + // Recomputed here rather than shared with the block above: these are + // sibling describes, and a shared `beforeAll` would make the order of + // the two blocks load-bearing. + const edges = getRelationships(result, 'USES').filter( + (e) => e.rel.reason === 'scope-resolution: value-ref', + ); + valueRefs = edgeSet(edges); + valueRefTargetIds = edges.map((e) => e.rel.targetId).sort(); + }); + + it('resolves a qualified CALL written through a file-level alias', () => { + // `Widget.zig` writes `const Self = @This();` and calls `Self.width(self)`. + expect(calls).toContain('describeWidth → width'); + expect( + getRelationships(result, 'CALLS') + .filter((e) => e.source === 'describeWidth') + .map((e) => e.rel.targetId), + ).toEqual(['Method:src/webapi/Widget.zig:Widget.width#0']); + }); + + it('resolves a qualified REGISTRATION written through a file-level alias', () => { + // The #3399 shape spelled the ordinary way: `binder.accessor(Self.width, …)`. + // Declining it cost the reference AND the hedge — no edge means no + // evidence, so `impact` on `width` went back to claiming `exact`. + expect(valueRefs).toContain('WidgetApi → width'); + expect(valueRefTargetIds.filter((id) => id.includes('Widget.width'))).toEqual([ + 'Method:src/webapi/Widget.zig:Widget.width#0', + ]); + }); + + it('resolves a qualified call through a CONTAINER-level alias', () => { + // `Metrics` declares `const Me = @This();`, which mints a Variable beside + // the Struct — the binding is there, it is just not class-like, so the + // walk stepped over it and kept climbing. + expect(calls).toContain('readTwice → read'); + expect( + getRelationships(result, 'CALLS') + .filter((e) => e.source === 'readTwice') + .map((e) => e.rel.targetId), + ).toEqual([ + 'Method:src/webapi/Widget.zig:Metrics.read#0', + 'Method:src/webapi/Widget.zig:Metrics.read#0', + ]); + }); + + it('leaves a stem-spelled alias resolving exactly as it did', () => { + // The control. `Element.zig` writes `const Element = @This();`, so its + // qualified references already resolved through the stem binding. The + // alias binding is an ADDITION to the augmentation channel, consulted + // only after a scope's own bindings, so it must move nothing here. + expect(valueRefTargetIds.filter((id) => id.endsWith('.getNamespaceUri#0'))).toEqual([ + 'Method:src/webapi/Element.zig:Element.getNamespaceUri#0', + ]); + expect(valueRefs).toContain('JsApi → getNamespaceUri'); + }); + + it('does not make the alias name resolvable from another file', () => { + // `Self` and `Me` are container-private: Zig has no way to import them, + // and the binding is appended at the declaring scope only. If it leaked + // to the workspace channels, every file in a repo would see one + // arbitrary `Self` — 66 files in ghostty declare that exact name. + const targets = valueRefTargetIds.concat( + getRelationships(result, 'CALLS').map((e) => e.rel.targetId), + ); + // The alias's OWN def (`Metrics.Me`, a Variable) must never be an edge + // target — matched on the last segment so `Metrics.read` is not read as + // a hit on `Me`. + expect(targets.filter((id) => /[:.](Self|Me)(#\d+)?$/.test(id))).toEqual([]); + }); + }); + it('types a receiver from its ANNOTATION (`var b: Counter = undefined; b.twice()`, `const c: Counter = .init(); c.get()`)', () => { // The declared type is the ONLY type source for `= undefined` and for // 0.14+ decl literals (`.init`, `.empty`), which current std uses for @@ -1207,3 +1529,64 @@ describe.skipIf(!zigAvailable)( }); }, ); + +// ── Monorepo: several build packages, no build.zig at the repo root ────────── +// +// The layout the root-only config loader could not see. `loadZigBuildConfig` +// read `/build.zig{,.zon}` and nothing else, so a repo whose packages +// live under `packages//` had no config at all and every bare +// `@import("")` in it went unresolved — cross-file resolution silently +// degraded to relative imports. It now takes a `packageDir` and +// `loadZigWorkspaceIndex` walks the repo for the packages to hand it, which is +// the change these assert. +// +// They assert the EDGES, not the config: the unit tests in +// `test/unit/zig-import-resolver.test.ts` pin the index, and this pins that the +// index actually reaches symbol resolution. +describe.skipIf(!zigAvailable)('Zig monorepo package resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-monorepo'), () => {}); + }, 60000); + + it('resolves a cross-package @import declared by the package’s own build files', () => { + // `packages/app` depends on `packages/core` through `.path = "../core"` — + // package-relative, and rejected outright when read from the repo root. + const imports = getRelationships(result, 'IMPORTS').filter((e) => + e.sourceFilePath.includes('packages/app/src/main.zig'), + ); + expect(imports.map((e) => e.targetFilePath).join('\n')).toContain('packages/core/src/root.zig'); + }); + + it('emits CALLS across the package boundary, from the module root and from a non-root file', () => { + const calls = getRelationships(result, 'CALLS'); + const crossPackage = calls.filter( + (e) => + e.sourceFilePath.includes('packages/app/') && + e.targetFilePath.includes('packages/core/src/root.zig'), + ); + // `run` is in the module ROOT, `clamp` in a sibling file of the same + // package: membership is the root plus what it reaches, so both must + // resolve the alias — a fix that only worked for module roots would pass an + // assertion on `run` alone. + expect(edgeSet(crossPackage)).toContain('run → retryBudget'); + expect(edgeSet(crossPackage)).toContain('clamp → retryBudget'); + }); + + it('binds one alias to two different roots in two packages without crossing them', () => { + // The discriminating case, and the reason the index is scoped per package + // rather than flattened repo-wide: `tool` binds `core` to its OWN + // src/core.zig. Flattened, `measure` would call into `packages/core` — a + // confident edge into a package `tool` does not depend on, which is worse + // than the unresolved import this change set out to fix. + const fromTool = getRelationships(result, 'CALLS').filter((e) => + e.sourceFilePath.includes('packages/tool/src/main.zig'), + ); + expect(edgeSet(fromTool)).toContain('measure → retryBudget'); + expect(fromTool.map((e) => e.targetFilePath).join('\n')).toContain( + 'packages/tool/src/core.zig', + ); + expect(fromTool.map((e) => e.targetFilePath).join('\n')).not.toContain('packages/core/'); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index d0657607f..7ae7f152d 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -266,12 +266,25 @@ describe('PARSE_CACHE_VERSION', () => { // recognizes form feed and vertical tab as C preprocessing whitespace. // Moved 96 -> 97 for #3179: comment-prefixed directives and invalid numeric // marker prefixes change the parse-time normalization result. - it('pins SCHEMA_BUMP to 97 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(97); + // Moved 97 -> 98 for #3219 (Zig callable-value references): `ZIG_SCOPE_QUERY` + // gained three `@reference.value-ref` rules, so a `.zig` file now yields + // `value-ref` entries in `ParsedFile.referenceSites` where it yielded none. + // A warm pre-v98 cache replays the old, empty site list for every unchanged + // file — `--force` included, since shards are content-addressed — so no USES + // edge is emitted, the boundary probe measures a real zero, and `impact` on a + // registered accessor goes back to `epistemic: "exact"`: the #3399 defect, + // silently un-fixed on exactly the incremental path most users are on. + // 94-97 belong to #3179, which merged first; 98 is the next value above it. + // Re-checked against origin/main and every open PR touching parse-cache.ts at + // merge time — the rule the paragraphs above were written by PRs that each + // checked only once — and the remaining open claims (#3190 at 94, #2840 at + // 71, #1616 at 2) all sit below main and must re-bump themselves. + it('pins SCHEMA_BUMP to 98 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(98); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); for (const taken of [ 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, - 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, + 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } diff --git a/gitnexus/test/unit/scope-resolution/value-ref-dispatchability.test.ts b/gitnexus/test/unit/scope-resolution/value-ref-dispatchability.test.ts new file mode 100644 index 000000000..3527587af --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/value-ref-dispatchability.test.ts @@ -0,0 +1,173 @@ +/** + * Canary for the invariant that `callableValueReferenceBoundaries`' dispatch + * exclusion silently depends on (#3219 review round 3). + * + * The exclusion, in `mcp/local/local-backend.ts`: a target with an inbound + * `property-dispatch` CALLS edge is NOT hedged, because the analyzer followed + * the registration and nothing was missed. It is symbol-level, not edge-level — + * the graph does not record which registration produced which synthesized call. + * + * That is only sound while no single symbol can carry BOTH kinds of + * registration, and today none can, for a reason that lives nowhere near the + * exclusion: + * + * - `emitPropertyDispatchCalls` synthesizes a CALLS edge only for a + * registration whose site carries a `propertyKey` (sweep 1 skips the + * registration index when it is undefined; sweep 2 reads only that index). + * - Every JS/TS `@reference.value-ref` rule also captures + * `@reference.property-key` — both are object-literal shapes. + * - No Zig `@reference.value-ref` rule captures one: Zig has no + * object-literal key to dispatch through. + * + * So a dispatchable registration is always a JS/TS one and an undispatchable + * registration is always a Zig one, and the two cannot meet on one symbol. + * + * The day that stops being true — a JS/TS rule for a bare callback argument + * (`register(handler)`), a Zig rule that grows a key — a symbol CAN have both, + * and the exclusion starts publishing `exact` over a registration the analyzer + * provably did not follow. That is the #3399 defect returning through a side + * door, and it would not fail a single existing test. + * + * This test fails instead. If it fails, do not relax it: go and decide what + * `callableValueReferenceBoundaries` should do about a mixed symbol (the + * options are recorded at the exclusion site), then update this file. + * + * WHAT IT DOES NOT COVER, stated so the green tick is not read as more than it + * is. It reads query SOURCES, so a capture synthesized in code rather than + * matched by a rule — the mechanism `@reference.static-gated` uses — can break + * the partition with this test green. A provider adding one has to come here by + * hand. Languages that own no query and delegate to another's captures (Vue → + * `emitTsScopeCaptures` / `emitJsScopeCaptures`) are covered transitively, by + * the rules they borrow, which is why the last case asserts on query OWNERS + * rather than on the set of languages that can emit a value-ref. + */ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + TSX_JSX_QUERY_SUFFIX, + TYPESCRIPT_SCOPE_QUERY, +} from '../../../src/core/ingestion/languages/typescript/query.js'; +import { JAVASCRIPT_SCOPE_QUERY } from '../../../src/core/ingestion/languages/javascript/query.js'; +import { ZIG_SCOPE_QUERY } from '../../../src/core/ingestion/languages/zig/query.js'; + +const VALUE_REF = '@reference.value-ref'; +const PROPERTY_KEY = '@reference.property-key'; + +/** + * Split a tree-sitter scope query into its top-level s-expression rules. + * + * `;;` comments are dropped first — they discuss the very tags this test + * matches on (Zig's rules carry a paragraph explaining why they attach no + * property key), so leaving them in would make every Zig rule look keyed. + * Double-quoted anonymous nodes (`"const"`, `"("`) are skipped while counting + * depth: a query that matches a literal paren would otherwise unbalance it. + */ +function topLevelRules(query: string): string[] { + const src = query + .split('\n') + .map((line) => { + const comment = line.indexOf(';;'); + return comment === -1 ? line : line.slice(0, comment); + }) + .join('\n'); + + const rules: string[] = []; + let depth = 0; + let start = -1; + let inString = false; + for (let i = 0; i < src.length; i++) { + const ch = src[i]; + if (inString) { + if (ch === '\\') i++; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === '(') { + if (depth === 0) start = i; + depth++; + } else if (ch === ')') { + depth--; + if (depth === 0 && start !== -1) { + rules.push(src.slice(start, i + 1)); + start = -1; + } + if (depth < 0) depth = 0; + } + } + return rules; +} + +function valueRefRules(query: string): string[] { + return topLevelRules(query).filter((rule) => rule.includes(VALUE_REF)); +} + +describe('value-ref dispatchability partition', () => { + it('splits a query into rules without being confused by comments or literal parens', () => { + // Guards the guard: a splitter that silently returned [] would make every + // assertion below vacuously true. + const rules = topLevelRules(` +;; a comment mentioning (parens) and ${PROPERTY_KEY} +(call_expression + function: (_) + (identifier) @reference.name) + +(variable_declaration + "const" . (identifier) @a .) +`); + expect(rules).toHaveLength(2); + expect(rules[0]).toContain('call_expression'); + expect(rules[1]).toContain('variable_declaration'); + expect(rules.join('\n')).not.toContain(PROPERTY_KEY); + }); + + it('every TypeScript value-ref rule is DISPATCHABLE (carries a property key)', () => { + // The BASE query plus the TSX suffix, because `getTsScopeQuery` concatenates + // them for a `.tsx` file: a value-ref rule added to the suffix alone would + // be emitted in TSX analysis while a base-only check stayed green. + const rules = valueRefRules(TYPESCRIPT_SCOPE_QUERY + TSX_JSX_QUERY_SUFFIX); + expect(rules.length).toBeGreaterThan(0); + expect(rules.filter((r) => !r.includes(PROPERTY_KEY))).toEqual([]); + }); + + it('every JavaScript value-ref rule is DISPATCHABLE (carries a property key)', () => { + const rules = valueRefRules(JAVASCRIPT_SCOPE_QUERY); + expect(rules.length).toBeGreaterThan(0); + expect(rules.filter((r) => !r.includes(PROPERTY_KEY))).toEqual([]); + }); + + it('every Zig value-ref rule is UNDISPATCHABLE (carries no property key)', () => { + const rules = valueRefRules(ZIG_SCOPE_QUERY); + expect(rules.length).toBeGreaterThan(0); + expect(rules.filter((r) => r.includes(PROPERTY_KEY))).toEqual([]); + }); + + it('no OTHER language OWNS a value-ref rule', () => { + // The three above are hand-classified. A fourth query declaring + // `value-ref` has not been classified by anyone, so the exclusion's premise + // is unverified for it — classify it here and in the exclusion's comment. + // "Owns", not "emits": Vue has no query of its own and borrows TypeScript's + // and JavaScript's captures, so it inherits their classification rather than + // needing one. A capture synthesized in code owns no rule either and is + // invisible here — see the header. + const languagesDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../../../src/core/ingestion/languages', + ); + const emitting = fs + .readdirSync(languagesDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .filter((e) => { + const query = path.join(languagesDir, e.name, 'query.ts'); + return fs.existsSync(query) && fs.readFileSync(query, 'utf8').includes(VALUE_REF); + }) + .map((e) => e.name) + .sort(); + expect(emitting).toEqual(['javascript', 'typescript', 'zig']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/value-ref-namespace-precedence.test.ts b/gitnexus/test/unit/scope-resolution/value-ref-namespace-precedence.test.ts new file mode 100644 index 000000000..67c3019a9 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/value-ref-namespace-precedence.test.ts @@ -0,0 +1,170 @@ +/** + * `findNamespaceValueRefTarget` precedence: a name the target module DECLARES + * beats a name it merely re-publishes, and it beats it whether or not the + * declaration happens to be callable (#3219 review round 8). + * + * The channel resolves `bridge.accessor(hub.scale, …)` by reading the target + * module's own module-scope bindings first and its re-published ones second. + * The order is only meaningful if the FIRST lookup decides on the NAME: it + * applies `CALL_TARGET_TYPES` while it selects, so a target module declaring a + * non-callable `scale` answers nothing there and — before this guard — fell + * through to the published channel and bound an imported callable under a name + * the module's own declaration owns. `findExportedDef` does not behave that + * way: it returns any local def and lets the caller's type gate reject it, so + * `findExportedDefIncludingImportedNames` never reaches the imported names for + * a name the file declares. `hub.scale` and `hub.scale()` must not disagree + * about which module owns `scale`. + * + * WHY THE INDEXES ARE HAND-BUILT, stated so this is not read as a fixture that + * "just happens" to be synthetic. Zig forbids declaring a name twice in one + * container, and Zig is today the only provider that sets + * `namespaceExportsIncludeImportedNames`, so no valid Zig source can put a + * local non-callable and a published callable under one name in one module — + * there is no source-level fixture to write. The shape becomes reachable the + * moment a second provider opts in, or a receiver name binds more than one + * target file. Building the indexes directly is what lets the guard be pinned + * before that happens; the middle case below fails without it. + */ + +import { describe, it, expect } from 'vitest'; +import { resolveValueRefTarget } from '../../../src/core/ingestion/scope-resolution/passes/property-dispatch.js'; +import type { BindingRef, ReferenceSite, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js'; +import type { SemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; + +const CONSUMER_FILE = 'src/Element.zig'; +const HUB_FILE = 'src/hub.zig'; +const CONSUMER = 'scope:consumer:module' as ScopeId; +const HUB = 'scope:hub:module' as ScopeId; + +const def = (nodeId: string, type: string, filePath: string): SymbolDefinition => + ({ nodeId, name: 'scale', type, filePath }) as unknown as SymbolDefinition; + +const ref = (d: SymbolDefinition, origin: string): BindingRef => + ({ def: d, origin }) as unknown as BindingRef; + +/** The callable `hub.zig` re-publishes from `dom_utils.zig`. */ +const PUBLISHED = def('Function:src/dom_utils.zig:scale', 'Function', 'src/dom_utils.zig'); +/** A `scale` the hub declares ITSELF, in the two kinds that matter. */ +const LOCAL_CONST = def('Const:src/hub.zig:scale', 'Const', HUB_FILE); +const LOCAL_FN = def('Function:src/hub.zig:scale', 'Function', HUB_FILE); + +function moduleScope(id: ScopeId, filePath: string): Scope { + return { + id, + kind: 'Module', + parent: null, + filePath, + range: { startLine: 1, startCol: 0, endLine: 99, endCol: 0 }, + bindings: new Map(), + typeBindings: new Map(), + imports: [], + ownedDefs: [], + } as unknown as Scope; +} + +/** + * Everything `resolveValueRefTarget` reads for a qualified site whose receiver + * is a namespace handle: the consumer's namespace import edge, and the target + * module's local (`scopes.bindings`) and published (`bindingAugmentations`) + * channels. `qualifiedNames` answers nothing so the CONTAINER channel — which + * runs only if this one declines — cannot supply the resolution instead and + * make a declining assertion pass for the wrong reason. + */ +function indexes(opts: { + local?: readonly BindingRef[]; + published?: readonly BindingRef[]; +}): ScopeResolutionIndexes { + const scopesById = new Map([ + [CONSUMER, moduleScope(CONSUMER, CONSUMER_FILE)], + [HUB, moduleScope(HUB, HUB_FILE)], + ]); + const bindings = new Map>(); + if (opts.local !== undefined) bindings.set(HUB, new Map([['scale', opts.local]])); + const augmentations = new Map>(); + if (opts.published !== undefined) augmentations.set(HUB, new Map([['scale', opts.published]])); + return { + scopeTree: { getScope: (id: ScopeId) => scopesById.get(id) }, + moduleScopes: new Map([ + [CONSUMER_FILE, CONSUMER], + [HUB_FILE, HUB], + ]), + imports: new Map([[CONSUMER, [{ kind: 'namespace', localName: 'hub', targetFile: HUB_FILE }]]]), + bindings, + bindingAugmentations: augmentations, + workspaceFqnBindings: new Map(), + namespaceFqnBindings: new Map(), + accessibleNamespacesByScope: new Map(), + defs: new Map(), + qualifiedNames: { get: () => [], has: () => false, size: 0 }, + } as unknown as ScopeResolutionIndexes; +} + +/** `bridge.accessor(hub.scale, …)` written at the consumer's module scope. */ +const SITE = { + name: 'scale', + kind: 'value-ref', + inScope: CONSUMER, + explicitReceiver: { name: 'hub' }, + atRange: { startLine: 10, startCol: 4, endLine: 10, endCol: 20 }, +} as unknown as ReferenceSite; + +const MODEL = {} as unknown as SemanticModel; + +const resolve = (scopes: ScopeResolutionIndexes): SymbolDefinition | undefined => + resolveValueRefTarget(SITE, CONSUMER_FILE, scopes, MODEL, true); + +describe('findNamespaceValueRefTarget — local declarations outrank re-published ones', () => { + it('resolves a re-published callable when the hub declares nothing under the name', () => { + // The control: this is the HUB case the channel exists for, and every + // assertion below is only meaningful while it holds. If the guard were + // written as "decline whenever anything is bound in the target module", + // this is what would break. + expect(resolve(indexes({ published: [ref(PUBLISHED, 'reexport')] }))?.nodeId).toBe( + PUBLISHED.nodeId, + ); + }); + + it('declines when the hub declares a NON-callable under the name', () => { + // The regression. The local lookup type-gates before precedence is settled, + // so `scale` answered nothing locally and the published channel bound + // `dom_utils.scale` — a USES edge to a member the hub does not expose under + // that name, and the opposite of what `hub.scale()` resolves to. + expect( + resolve( + indexes({ + local: [ref(LOCAL_CONST, 'local')], + published: [ref(PUBLISHED, 'reexport')], + }), + ), + ).toBeUndefined(); + }); + + it('still prefers a local CALLABLE over a re-published one', () => { + // The guard suppresses the published channel; it must not suppress the + // local answer that made the precedence rule worth stating. + expect( + resolve( + indexes({ + local: [ref(LOCAL_FN, 'local')], + published: [ref(PUBLISHED, 'reexport')], + }), + )?.nodeId, + ).toBe(LOCAL_FN.nodeId); + }); + + it('declines a re-published callable for a provider that does not publish its imports', () => { + // `namespaceExportsIncludeImportedNames` is opt-in: in a language where a + // module's imports are NOT its exports, the hub channel stays closed and + // the guard above is never consulted. + expect( + resolveValueRefTarget( + SITE, + CONSUMER_FILE, + indexes({ published: [ref(PUBLISHED, 'reexport')] }), + MODEL, + false, + ), + ).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/zig-import-resolver.test.ts b/gitnexus/test/unit/zig-import-resolver.test.ts index 1c23b9f3d..932012d18 100644 --- a/gitnexus/test/unit/zig-import-resolver.test.ts +++ b/gitnexus/test/unit/zig-import-resolver.test.ts @@ -8,10 +8,12 @@ import { fileURLToPath } from 'node:url'; import { resolveZigImportInternal } from '../../src/core/ingestion/import-resolvers/zig.js'; import { loadZigBuildConfig, + loadZigWorkspaceIndex, parseZigBuildModules, parseZigRootModules, parseZigBuildModuleRoots, parseZigBuildZon, + zigPackageFor, } from '../../src/core/ingestion/language-config.js'; const FIXTURES = path.resolve( @@ -816,3 +818,138 @@ describe('loadZigBuildConfig (zig-idioms fixture)', () => { expect(config!.moduleRoots?.has('oldlib')).toBe(false); }); }); + +describe('loadZigWorkspaceIndex / zigPackageFor (zig-monorepo fixture)', () => { + const MONOREPO = path.join(FIXTURES, 'zig-monorepo'); + /** Every `.zig` the fixture indexes, repo-relative — what `allFilePaths` holds. */ + const files = new Set([ + 'packages/core/build.zig', + 'packages/core/src/root.zig', + 'packages/app/build.zig', + 'packages/app/src/main.zig', + 'packages/app/src/util.zig', + 'packages/tool/build.zig', + 'packages/tool/src/core.zig', + 'packages/tool/src/main.zig', + ]); + + it('finds the packages the root-only loader cannot see at all', async () => { + // The regression this exists for: with no build.zig at the repo root the + // root-only loader has nothing to read and answers null, so every bare + // `@import` in the repo is unresolvable. Asserted side by side so the + // difference is the test rather than a claim in a comment. + expect(await loadZigBuildConfig(MONOREPO)).toBeNull(); + + const index = await loadZigWorkspaceIndex(MONOREPO); + expect(index).not.toBeNull(); + expect(index!.packages.map((p) => p.dir)).toEqual([ + 'packages/core', + 'packages/tool', + 'packages/app', + ]); + }); + + it('rebases a package-relative `.path` dep to repo-relative', async () => { + // `app/build.zig.zon` writes `.path = "../core"`, which means nothing + // against `allFilePaths` (repo-relative keys) and is rejected outright by + // `normalizeZigDepPath` as an escape when read from the repo root. + const app = zigPackageFor(await loadZigWorkspaceIndex(MONOREPO), 'packages/app/src/main.zig'); + expect(app!.pathDeps.get('core')).toBe('packages/core'); + expect(app!.moduleRoots?.get('core')).toEqual(['packages/core/src/root.zig']); + }); + + it('resolves a cross-package dependency from every file of the dependent package', async () => { + const index = await loadZigWorkspaceIndex(MONOREPO); + // The module root, and a file that is NOT the module root: membership is + // the root plus what it reaches, so both must resolve the alias. + for (const from of ['packages/app/src/main.zig', 'packages/app/src/util.zig']) { + expect(resolveZigImportInternal(from, 'core', files, zigPackageFor(index, from))).toBe( + 'packages/core/src/root.zig', + ); + } + }); + + it('keeps one alias bound to two different roots in two packages apart', async () => { + // The discriminating case. `tool` binds `core` to its OWN src/core.zig. A + // repo-wide module map — the shape a workspace index invites — would answer + // `packages/core/src/root.zig` here: a confident edge into a package `tool` + // does not depend on, which is worse than the unresolved import this change + // set out to fix. + const index = await loadZigWorkspaceIndex(MONOREPO); + const from = 'packages/tool/src/main.zig'; + expect(resolveZigImportInternal(from, 'core', files, zigPackageFor(index, from))).toBe( + 'packages/tool/src/core.zig', + ); + }); + + it('does not leak one package’s modules to files outside it', async () => { + // `core` declares module `core`; `tool` never depends on `app`. A file in + // one package must not resolve another package's module name. + const index = await loadZigWorkspaceIndex(MONOREPO); + const from = 'packages/core/src/root.zig'; + expect(resolveZigImportInternal(from, 'app', files, zigPackageFor(index, from))).toBeNull(); + }); + + it('governs a file by the NEAREST enclosing package', async () => { + const index = await loadZigWorkspaceIndex(MONOREPO); + expect(zigPackageFor(index, 'packages/core/src/root.zig')!.rootModules?.get('core')).toBe( + 'packages/core/src/root.zig', + ); + // A path under no package at all resolves to no config rather than to the + // first package in the list. + expect(zigPackageFor(index, 'docs/notes.zig')).toBeNull(); + }); + + it('keeps a genuinely single-package repo byte-identical to the root-only loader', async () => { + // `libs/geo` is one directory with one build.zig and no nested marker + // anywhere below it, so the workspace walk finds exactly one package and + // this really does pin the no-op case. `zig-idioms` cannot: it declares + // `libs/geo` as a path dep and that directory has its own build.zig, so the + // walk finds TWO packages there — the assertion below is the one the next + // test makes, and naming this file "single-package" would be the claim, not + // the check. + const single = path.join(FIXTURES, 'zig-idioms', 'libs', 'geo'); + const index = await loadZigWorkspaceIndex(single); + expect(index!.packages.map((p) => p.dir)).toEqual(['']); + expect(index!.packages[0]!.config).toEqual(await loadZigBuildConfig(single)); + expect(zigPackageFor(index, 'src/root.zig')).toEqual(await loadZigBuildConfig(single)); + }); + + it('leaves the ROOT package of a multi-package repo as the root-only loader saw it', async () => { + // The root package is a scope like any other and `dir: ''` matches every + // file no deeper package claims, so a file outside `libs/geo` must still + // get exactly what `loadZigBuildConfig` alone used to answer. + const idioms = path.join(FIXTURES, 'zig-idioms'); + const index = await loadZigWorkspaceIndex(idioms); + expect(index!.packages.map((p) => p.dir)).toEqual(['libs/geo', '']); + const root = index!.packages.find((p) => p.dir === ''); + expect(root).toBeDefined(); + expect(root!.config).toEqual(await loadZigBuildConfig(idioms)); + expect(zigPackageFor(index, 'src/idioms.zig')).toEqual(await loadZigBuildConfig(idioms)); + // …and a file INSIDE the nested package is governed by that package, not + // by the root — the regression the misnamed version of this test could not + // have caught, because it never looked below `src/`. + expect(zigPackageFor(index, 'libs/geo/src/root.zig')).toEqual( + await loadZigBuildConfig(idioms, 'libs/geo'), + ); + }); + + it('rejects an ABSOLUTE `.path` in a nested package instead of rebasing it in', async () => { + // `packages/app` declares `.escapes = .{ .path = "/src" }`. Absolute, so it + // names something outside this repository — but the nested branch prefixes + // the package directory before normalizing, and `packages/app/` + `/src` is + // `packages/app//src`, which is relative by inspection. The empty segment is + // then dropped and the dep lands on `packages/app/src`, a directory that + // really exists here: an out-of-repo dependency fabricated into an in-repo + // resolution. `isAbsoluteZigDepPath` asks the question of the value AS + // WRITTEN, before any prefixing. + // + // `path.posix.join` would NOT have fixed this: it strips the leading slash + // too, producing the same `packages/app/src` without rejecting anything. + const app = zigPackageFor(await loadZigWorkspaceIndex(MONOREPO), 'packages/app/src/main.zig'); + expect(app!.pathDeps.has('escapes')).toBe(false); + expect(app!.moduleRoots?.has('escapes') ?? false).toBe(false); + // The legitimate package-relative dep beside it is untouched. + expect(app!.pathDeps.get('core')).toBe('packages/core'); + }); +}); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index d2534c025..7d69f1ce1 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -64,6 +64,7 @@ export default defineConfig({ test: { name: 'lbug-db', include: [ + 'test/integration/impact-callable-value-references.test.ts', 'test/integration/impact-epistemic-lower-bound.test.ts', 'test/integration/impact-scope-omission-persistence.test.ts', 'test/integration/lbug-core-adapter.test.ts', @@ -143,6 +144,7 @@ export default defineConfig({ sequence: { groupOrder: 3 }, include: ['test/**/*.test.ts'], exclude: [ + 'test/integration/impact-callable-value-references.test.ts', 'test/integration/impact-epistemic-lower-bound.test.ts', 'test/integration/impact-scope-omission-persistence.test.ts', 'test/integration/lbug-core-adapter.test.ts', From eafcf82a14829aaf61148f7d39880356f350c586 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:59:19 +0100 Subject: [PATCH 05/33] chore(deps)(deps): bump react-i18next in /gitnexus-web (#3243) Bumps [react-i18next](https://github.com/i18next/react-i18next) from 17.0.12 to 17.0.13. - [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.13) --- updated-dependencies: - dependency-name: react-i18next dependency-version: 17.0.13 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus-web/package-lock.json | 8 ++++---- gitnexus-web/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index f212024e7..979bea1de 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -36,7 +36,7 @@ "pandemonium": "^2.4.0", "react": "^19.2.5", "react-dom": "^19.2.8", - "react-i18next": "^17.0.12", + "react-i18next": "^17.0.13", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "react-zoom-pan-pinch": "^4.0.3", @@ -7304,9 +7304,9 @@ } }, "node_modules/react-i18next": { - "version": "17.0.12", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz", - "integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==", + "version": "17.0.13", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.13.tgz", + "integrity": "sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.29.7", diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index eef658136..03d6d60cc 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -46,7 +46,7 @@ "pandemonium": "^2.4.0", "react": "^19.2.5", "react-dom": "^19.2.8", - "react-i18next": "^17.0.12", + "react-i18next": "^17.0.13", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "react-zoom-pan-pinch": "^4.0.3", From 43d1d3e35bf7a2afc8d41c42496033d6c4ad81c1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:59:44 +0100 Subject: [PATCH 06/33] chore(deps)(deps): bump @langchain/langgraph in /gitnexus-web (#3244) Bumps [@langchain/langgraph](https://github.com/langchain-ai/langgraphjs/tree/HEAD/libs/langgraph-core) from 1.4.9 to 1.4.14. - [Release notes](https://github.com/langchain-ai/langgraphjs/releases) - [Changelog](https://github.com/langchain-ai/langgraphjs/blob/main/libs/langgraph-core/CHANGELOG.md) - [Commits](https://github.com/langchain-ai/langgraphjs/commits/@langchain/langgraph@1.4.14/libs/langgraph-core) --- updated-dependencies: - dependency-name: "@langchain/langgraph" dependency-version: 1.4.14 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus-web/package-lock.json | 44 ++++++++++++++-------------------- gitnexus-web/package.json | 2 +- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 979bea1de..3bb2343d7 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -11,7 +11,7 @@ "@langchain/anthropic": "^1.5.8", "@langchain/core": "^1.2.8", "@langchain/google-genai": "^2.2.0", - "@langchain/langgraph": "^1.4.9", + "@langchain/langgraph": "^1.4.14", "@langchain/ollama": "^1.3.0", "@langchain/openai": "^1.5.3", "@sigma/edge-curve": "^3.1.0", @@ -1165,14 +1165,14 @@ } }, "node_modules/@langchain/langgraph": { - "version": "1.4.9", - "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.9.tgz", - "integrity": "sha512-EvD9rS66Cya09y6rbMgD3Ir8miAkJQFo7FyJOPRPO736Kz3y5TeyeBDOS8ctff/jRc788bPijHx2NVFM79Qqig==", + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.14.tgz", + "integrity": "sha512-uWAdRYTllfKCnTrlyovExPJCHJwcf3Wl2LzUlnaqsT7Rmoo3aCeYtq/7MV/Pw4q11motG8pR8bjr6T6V8Pe1gQ==", "license": "MIT", "dependencies": { - "@langchain/langgraph-checkpoint": "^1.1.3", - "@langchain/langgraph-sdk": "~1.9.28", - "@langchain/protocol": "^0.0.18", + "@langchain/langgraph-checkpoint": "^1.1.5", + "@langchain/langgraph-sdk": "~1.10.2", + "@langchain/protocol": "^0.0.19", "@standard-schema/spec": "1.1.0" }, "engines": { @@ -1184,9 +1184,9 @@ } }, "node_modules/@langchain/langgraph-checkpoint": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.1.3.tgz", - "integrity": "sha512-wgzdQNeEsdw1e+4lvlj0tdq/RYR/k1vPin10g0ymGoehZDDgd9nvIllGXSXN4TFgF9sf5qQP/KTkOcLfeseIhA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.1.5.tgz", + "integrity": "sha512-BwDwl5VeTOh6CVuiIPgsUgfK51vTJDMSbFcSCUfjJWsl8/DPdK/mbv+ejxJstkSk/BlSPMP4JfXWcN6jD2ea2Q==", "license": "MIT", "engines": { "node": ">=18" @@ -1196,12 +1196,12 @@ } }, "node_modules/@langchain/langgraph-sdk": { - "version": "1.9.28", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.28.tgz", - "integrity": "sha512-4j3XuM0PvtmAbL8mPfBS99ez3+ytRfgbOpAR/nOeaejTRF3Q9dNw2QnaGLGng8wLPtGLoSj+SYgUOVxy9Bv9vg==", + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.10.2.tgz", + "integrity": "sha512-86qsfdBZWu1ZgywLN8AThU/jXi9rjPDZPWcTJp4SA1A/L62ypTNoSXbvtiwZt1odokXccYTxK1XWS8tmVdvEmw==", "license": "MIT", "dependencies": { - "@langchain/protocol": "^0.0.18", + "@langchain/protocol": "^0.0.19", "@types/json-schema": "^7.0.15", "p-queue": "^9.0.1", "p-retry": "^7.1.1" @@ -1209,9 +1209,7 @@ "peerDependencies": { "@langchain/core": "^1.1.48", "react": "^18 || ^19", - "react-dom": "^18 || ^19", - "svelte": "^4.0.0 || ^5.0.0", - "vue": "^3.0.0" + "react-dom": "^18 || ^19" }, "peerDependenciesMeta": { "react": { @@ -1219,12 +1217,6 @@ }, "react-dom": { "optional": true - }, - "svelte": { - "optional": true - }, - "vue": { - "optional": true } } }, @@ -1295,9 +1287,9 @@ } }, "node_modules/@langchain/protocol": { - "version": "0.0.18", - "resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.18.tgz", - "integrity": "sha512-XW1egQtPfsGI41w2AMZNFZrUIwFSQHTjVMZs0OaTpCAvht/QLoaPN8FQcsysMVypOhupG28J29yOorrc70otBQ==", + "version": "0.0.19", + "resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.19.tgz", + "integrity": "sha512-9hKcRrH7cBX6gfutdfXPoft1OCchHe4FEpALoDJMl5Qu+n/YG5ynZmyu8+8cxORlPwHBoKTxggvXz+76M1yX1Q==", "license": "MIT" }, "node_modules/@mapbox/node-pre-gyp": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 03d6d60cc..cc9f43d44 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -21,7 +21,7 @@ "@langchain/anthropic": "^1.5.8", "@langchain/core": "^1.2.8", "@langchain/google-genai": "^2.2.0", - "@langchain/langgraph": "^1.4.9", + "@langchain/langgraph": "^1.4.14", "@langchain/ollama": "^1.3.0", "@langchain/openai": "^1.5.3", "@sigma/edge-curve": "^3.1.0", From 565cc22d18795f87be2446857ee8e3e515d85742 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:59:58 +0100 Subject: [PATCH 07/33] chore(deps)(deps-dev): bump @vitejs/plugin-react in /gitnexus-web (#3247) Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 6.0.5 to 6.1.1. - [Release notes](https://github.com/vitejs/vite-plugin-react/releases) - [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react) --- updated-dependencies: - dependency-name: "@vitejs/plugin-react" dependency-version: 6.1.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus-web/package-lock.json | 12 ++++++++---- gitnexus-web/package.json | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 3bb2343d7..9dcc76b55 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -58,7 +58,7 @@ "@types/react-dom": "^19.2.4", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.10.2", - "@vitejs/plugin-react": "^6.0.5", + "@vitejs/plugin-react": "^6.1.1", "@vitest/coverage-v8": "^4.1.11", "jsdom": "^29.1.1", "tree-sitter-wasms": "^0.1.13", @@ -2708,9 +2708,9 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", - "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2722,6 +2722,7 @@ "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -2730,6 +2731,9 @@ }, "babel-plugin-react-compiler": { "optional": true + }, + "oxc-transform-react": { + "optional": true } } }, diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index cc9f43d44..4bab97ad5 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -68,7 +68,7 @@ "@types/react-dom": "^19.2.4", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.10.2", - "@vitejs/plugin-react": "^6.0.5", + "@vitejs/plugin-react": "^6.1.1", "@vitest/coverage-v8": "^4.1.11", "jsdom": "^29.1.1", "tree-sitter-wasms": "^0.1.13", From d80230a564af9990aae00a2fc3986b93c4b95117 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 06:00:15 +0100 Subject: [PATCH 08/33] chore(deps): bump docker/setup-qemu-action from 4.2.0 to 4.3.0 (#3249) Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 4.2.0 to 4.3.0. - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/96fe6ef7f33517b61c61be40b68a1882f3264fb8...1f40c72289eff860ee54a304f1438e3cff362e0a) --- updated-dependencies: - dependency-name: docker/setup-qemu-action dependency-version: 4.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index dac6a7398..0334531c1 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -138,7 +138,7 @@ jobs: # Required for multi-platform (linux/arm64) emulation. - name: Set up QEMU - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 From 2f7e192add4bcf550eada7432bbeab8ab5b80e7f Mon Sep 17 00:00:00 2001 From: Abhigyan Patwari <126312502+abhigyanpatwari@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:09:49 -0700 Subject: [PATCH 09/33] Remove cryptocurrency notice from README Removed important notice about GitNexus cryptocurrency. --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 95cfaf5f0..2f55daa2a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # GitNexus (Akon Labs) -**⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus. -
From ab25b868072671e79086c355bd221fac5c143e6b Mon Sep 17 00:00:00 2001 From: ClayLeee <41122003+ClayLeee@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:14:53 +0800 Subject: [PATCH 10/33] fix(typescript): resolve tsconfig `paths` aliases on Windows (#3203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(typescript): resolve tsconfig `paths` aliases on Windows `rebaseTarget` turns a `paths` target that `path.resolve` produced back into a repo-relative one, and it recognised the wildcard suffix only as `/*`. On Windows the resolved target is `C:\repo\src\*`, so the check never matched, the bare-`*` branch stripped the star, `path.relative` ate the trailing backslash, and every alias target came back as `src*`. `substituteStar` then built `srclib/date` from `@/lib/date`, nothing resolved, and every alias import was dropped as external — no IMPORTS/CALLS edge for anything reached through `@/`, so `impact` answered UNKNOWN for a repo's whole shared layer. Normalise the separator before looking at the suffix. `tsconfig-index.test.ts` already asserts the `src/*` shape and fails 4 of 13 cases on Windows without this; it only ever ran on Ubuntu, so add it to the cross-platform lane. Co-Authored-By: Claude Fable 5.1 * test(typescript): make `paths` rebasing assertable from any runner `rebaseTarget` reads the platform separator, but the only suite covering it builds real directories — so on Ubuntu it sees `/` whatever the code does with `\`, and only the windows-latest lane can fail it. That is how an alias bug affecting every Windows install reached a green CI in the first place. Thread an injectable `pathApi` through `rebaseTarget` and `repoRelative` — the seam `isInside` and the `\\?\` prefix guard already use — and add a fixture-free suite that pins the win32 and POSIX branches explicitly. Deleting the separator normalisation now fails on every runner rather than only on windows-latest. Registered beside its fixture sibling in the cross-platform lane so the two halves of the same rule stay discoverable as one group. Co-Authored-By: Claude Opus 5 (1M context) * fix(typescript): emit a bare `*` for a repo-root `paths` target A target naming the repo ROOT — `"*": ["./*"]` under `"baseUrl": "."` — rebases to an EMPTY repo-relative prefix, so only the suffix survived and the encoding came out as `/*`. `substituteStar` turns that into `/lib/date`, and `resolveFile` matches repo-relative keys without stripping a leading slash, so the indexed `lib/date.ts` misses and the alias is dropped as external. Emit the bare `*` for that case instead, which substitutes to `lib/date` and resolves. This is the shipped POSIX encoding as much as the Windows one, and it is what the Windows path emitted by accident before the separator was normalised — so the normalisation does not narrow what already resolved there. The common `"@/*": ["./src/*"]` is untouched: it has a real prefix and stays `src/*`. Pinned at all three levels the encoding passes through: the rebaser, on both path flavours; the loader, through a real fixture; and resolution, where `*` finds the file and `/*` does not. Co-Authored-By: Claude Opus 5 (1M context) * docs(test): say why the root-wildcard fixture declares no `baseUrl` The comment introduced the fixture as `"baseUrl": "."`, which the loader encodes as `''`, while the fixture passes `null` — a different scope, since `null` means the config declares no baseUrl at all and takes the paths arm alone. `null` is the right fixture and the comment was the wrong half: with `''` the baseUrl arm resolves `packages/utils/src/index` by itself, so the negative case would return null for a reason unrelated to the target encoding. Verified by substitution — `''` makes that assertion report the file instead. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Gergő Magyar --- gitnexus/scripts/cross-platform-tests.ts | 12 +++ .../languages/typescript/tsconfig.ts | 35 ++++++-- .../test/unit/node-workspace-packages.test.ts | 23 +++++ gitnexus/test/unit/tsconfig-index.test.ts | 18 ++++ .../test/unit/tsconfig-rebase-target.test.ts | 83 +++++++++++++++++++ 5 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 gitnexus/test/unit/tsconfig-rebase-target.test.ts diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 79169214b..abf7de8c3 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -36,6 +36,18 @@ const PLATFORM_LOGIC = [ // must exercise the Windows backslash branch, so run it on the OS matrix (#2394). 'test/unit/cli-entry.test.ts', 'test/unit/platform-capabilities.test.ts', + // The tsconfig loader rebases `paths` targets through `path.resolve`, so the + // wildcard suffix it must recognise is `/*` on POSIX and `\*` on Windows. It + // only looked for `/*`, and every alias target came back as `src*` on + // Windows while the Ubuntu run stayed green — so this file has to run where + // the separator differs. + 'test/unit/tsconfig-index.test.ts', + // The unit half of the same rebasing rule. Fixture-free and pathApi-injectable + // (every separator assertion passes an explicit `path.win32` / `path.posix`), + // so unlike the fixture suite above it fails on EVERY runner when the + // normalisation is removed rather than only on windows-latest. Registered + // beside its fixture sibling so the two halves stay discoverable as one group. + 'test/unit/tsconfig-rebase-target.test.ts', // The gitnexus-plan safe writer resolves every name through a per-platform // backend: Linux anchors through /proc/self/fd, macOS resolves lexically and // verifies each step against descriptors it holds open. Publication is link(2) diff --git a/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts b/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts index d47374473..30da1a677 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts @@ -318,8 +318,8 @@ function parseJsonc(raw: string): Record { return JSON.parse(withoutComments) as Record; } -function repoRelative(repoRoot: string, absDir: string): string { - const rel = path.relative(repoRoot, absDir).split(path.sep).join('/'); +function repoRelative(repoRoot: string, absDir: string, pathApi: typeof path = path): string { + const rel = pathApi.relative(repoRoot, absDir).split(pathApi.sep).join('/'); return rel === '.' || rel === '' ? '' : rel; } @@ -329,11 +329,34 @@ function repoRelative(repoRoot: string, absDir: string): string { * `path.resolve` swallows the wildcard into a path segment, so it is stripped * before resolving and re-appended after — the `*` is a substitution marker, * not a directory named `*`. + * + * `pathApi` is injectable so the win32 separator branch is unit-testable from a + * POSIX runner; production callers always use the platform-bound `path`. */ -function rebaseTarget(repoRoot: string, absTarget: string): string { +function rebaseTarget(repoRoot: string, absTarget: string, pathApi: typeof path = path): string { // `/repo/src/*` must come back as `src/*`, not `src*`: stripping only the // star leaves a trailing slash that `path.relative` then eats. - const suffix = absTarget.endsWith('/*') ? '/*' : absTarget.endsWith('*') ? '*' : ''; - const base = suffix === '' ? absTarget : absTarget.slice(0, -suffix.length); - return `${repoRelative(repoRoot, base)}${suffix}`; + // + // The target arrives from `path.resolve`, so on Windows it is `C:\repo\src\*` + // and an `endsWith('/*')` check never matches. It fell through to the bare + // `*` branch, `path.relative` ate the trailing backslash, and every alias + // target came back as `src*` — which `substituteStar` turns into `srclib/x`, + // so nothing an alias reached was ever resolved on Windows. Normalise the + // separator before looking at the suffix. + const normalized = absTarget.split(pathApi.sep).join('/'); + const suffix = normalized.endsWith('/*') ? '/*' : normalized.endsWith('*') ? '*' : ''; + const base = suffix === '' ? normalized : normalized.slice(0, -suffix.length); + const prefix = repoRelative(repoRoot, base, pathApi); + // A target naming the repo ROOT (`"*": ["./*"]` under `baseUrl: "."`) leaves + // an empty prefix, and `${''}${'/*'}` is `/*`. `substituteStar` turns that + // into `/lib/date`, but `resolveFile` matches repo-relative keys and never + // strips a leading slash, so `lib/date.ts` misses and the alias goes + // external. The bare `*` is the encoding that substitutes correctly — and it + // is what the pre-#3203 Windows path emitted by accident, so this keeps the + // separator fix from narrowing what already resolved there. + if (prefix === '' && suffix === '/*') return '*'; + return `${prefix}${suffix}`; } + +/** Test seam for {@link rebaseTarget} (see `test/unit/tsconfig-rebase-target.test.ts`). */ +export const _rebaseTargetForTests = rebaseTarget; diff --git a/gitnexus/test/unit/node-workspace-packages.test.ts b/gitnexus/test/unit/node-workspace-packages.test.ts index c8e5131ae..ecfd8ef03 100644 --- a/gitnexus/test/unit/node-workspace-packages.test.ts +++ b/gitnexus/test/unit/node-workspace-packages.test.ts @@ -181,6 +181,29 @@ describe('tsconfig paths', () => { expect(resolve('~/index', { tsconfigs: twoTargets })).toBe('packages/utils/src/index.ts'); }); + it('substitutes a repo-root target written as the bare `*`', () => { + // A `"*": ["./*"]` target resolves to the repo root, so `rebaseTarget` + // leaves an EMPTY repo-relative prefix and the suffix is the whole + // encoding. This is the pair that says why it must be `*` and not `/*`. + // + // `baseUrl` is `null` — no baseUrl declared — rather than the `''` the + // loader emits for a real `"baseUrl": "."`, and deliberately so: `''` + // resolves `packages/utils/src/index` through the baseUrl arm on its own, + // which would answer the negative case for a reason that has nothing to do + // with the target encoding. `paths` is tried first either way, so cutting + // the fallback is what leaves this asserting only what it names. + const rootWildcard = (target: string): TsconfigIndex => ({ + scopes: [{ dir: '', baseUrl: null, paths: [{ pattern: '*', targets: [target] }] }], + }); + + expect(resolve('packages/utils/src/index', { tsconfigs: rootWildcard('*') })).toBe( + 'packages/utils/src/index.ts', + ); + // `substituteStar('/*', …)` yields `/packages/utils/src/index`, and + // `resolveFile` matches repo-relative keys without a leading slash. + expect(resolve('packages/utils/src/index', { tsconfigs: rootWildcard('/*') })).toBeNull(); + }); + it('applies the nearest config, not the root one', () => { const nested: TsconfigIndex = { scopes: [ diff --git a/gitnexus/test/unit/tsconfig-index.test.ts b/gitnexus/test/unit/tsconfig-index.test.ts index f711ae9cc..18cab3057 100644 --- a/gitnexus/test/unit/tsconfig-index.test.ts +++ b/gitnexus/test/unit/tsconfig-index.test.ts @@ -231,6 +231,24 @@ describe('parsing', () => { expect(scope?.paths[0]?.targets).toEqual(['src/*', 'generated/*']); }); + it('encodes a repo-root target as the bare `*`', async () => { + // `"*": ["./*"]` resolves to the repo root itself, so the repo-relative + // prefix is empty and the suffix is the whole target. `/*` substitutes to + // `/lib/date`, which `resolveFile` never matches — see the unit assertions + // in `tsconfig-rebase-target.test.ts` for both path flavours. + const root = repo({ + 'tsconfig.json': JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '*': ['./*'], '@/*': ['./src/*'] } }, + }), + }); + + const scope = tsconfigFor(await loadTsconfigIndex(root), 'src/a.ts'); + + expect(scope?.paths.find((mapping) => mapping.pattern === '*')?.targets).toEqual(['*']); + // The common alias is unaffected — only the empty-prefix case changes. + expect(scope?.paths.find((mapping) => mapping.pattern === '@/*')?.targets).toEqual(['src/*']); + }); + it('returns null for a repo with no config at all', async () => { expect(await loadTsconfigIndex(repo({ 'src/a.ts': '' }))).toBeNull(); }); diff --git a/gitnexus/test/unit/tsconfig-rebase-target.test.ts b/gitnexus/test/unit/tsconfig-rebase-target.test.ts new file mode 100644 index 000000000..140fc76ea --- /dev/null +++ b/gitnexus/test/unit/tsconfig-rebase-target.test.ts @@ -0,0 +1,83 @@ +/** + * `rebaseTarget` — `paths` target rebasing, across both path flavours. + * + * The fixture suite (`tsconfig-index.test.ts`) builds real directories, so it + * only ever sees the HOST separator: its `…/*` assertions are green on Ubuntu + * whatever `rebaseTarget` does with a backslash, and only the windows-latest + * lane can fail them. That is how the alias bug survived a green CI in the + * first place. Injecting `pathApi` — the seam `isInside` and the `\\?\` prefix + * guard already use — makes both platform branches assertable from any runner, + * so deleting the separator normalisation fails here on Ubuntu too. + * + * Two behaviours are pinned: + * + * - `path.resolve` emits `C:\repo\src\*` on Windows, so the `endsWith('/*')` + * check never matched, the bare-`*` branch ate the trailing separator, and + * every alias target came back as `src*` — `substituteStar` then produced + * `srclib/date`, which matches no file, so the common Vite/shadcn + * `"@/*": ["./src/*"]` resolved to nothing on Windows. + * - a repo-ROOT target (`"*": ["./*"]` under `baseUrl: "."`) rebases to an + * EMPTY prefix, and `${''}${'/*'}` is `/*`. `substituteStar('/*', 'lib/date')` + * yields `/lib/date`, and `resolveFile` matches repo-relative keys without + * a leading slash, so the alias went external. The bare `*` is the encoding + * that substitutes correctly, on both platforms. + */ +import { describe, it, expect } from 'vitest'; +import path from 'node:path'; +import { _rebaseTargetForTests as rebaseTarget } from '../../src/core/ingestion/languages/typescript/tsconfig.js'; + +describe('rebaseTarget — Windows separator normalisation', () => { + it('rebases the Vite/shadcn `@/*` target to `src/*`, not `src*`', () => { + // What `path.win32.resolve('C:\\repo', './src/*')` hands the rebaser. + expect(rebaseTarget('C:\\repo', 'C:\\repo\\src\\*', path.win32)).toBe('src/*'); + }); + + it('rebases a nested monorepo target', () => { + expect(rebaseTarget('C:\\repo', 'C:\\repo\\packages\\ui\\src\\*', path.win32)).toBe( + 'packages/ui/src/*', + ); + }); + + it('leaves a starless target alone', () => { + expect(rebaseTarget('C:\\repo', 'C:\\repo\\src\\exact.ts', path.win32)).toBe('src/exact.ts'); + }); + + it('produces the identical result on POSIX', () => { + // The normalisation is a no-op here — `split('/').join('/')` is identity — + // so these are the OLD values as much as the new ones. Pinning them keeps + // the Windows fix from being paid for with a POSIX regression. + expect(rebaseTarget('/repo', '/repo/src/*', path.posix)).toBe('src/*'); + expect(rebaseTarget('/repo', '/repo/packages/ui/src/*', path.posix)).toBe('packages/ui/src/*'); + expect(rebaseTarget('/repo', '/repo/src/exact.ts', path.posix)).toBe('src/exact.ts'); + }); + + it('defaults to the platform-bound path module', () => { + const root = path.resolve('repo'); + expect(rebaseTarget(root, path.resolve(root, './src/*'))).toBe('src/*'); + }); +}); + +describe('rebaseTarget — repo-root wildcard', () => { + it('emits a bare `*` rather than `/*` for a root target', () => { + // `"baseUrl": "."` with `"*": ["./*"]` — the target resolves to the repo + // root itself, so the repo-relative prefix is empty and only the suffix is + // left. `/*` substitutes to `/lib/date`, which no indexed key matches. + expect(rebaseTarget('/repo', '/repo/*', path.posix)).toBe('*'); + expect(rebaseTarget('C:\\repo', 'C:\\repo\\*', path.win32)).toBe('*'); + }); + + it('still emits `/*` once the target is one directory in', () => { + // The bare `*` is the empty-prefix case ONLY; a real prefix keeps its + // separator or `src*` comes back, which is the bug above wearing a + // different hat. + expect(rebaseTarget('/repo', '/repo/src/*', path.posix)).toBe('src/*'); + expect(rebaseTarget('C:\\repo', 'C:\\repo\\src\\*', path.win32)).toBe('src/*'); + }); + + it('leaves a starless root target as the empty prefix', () => { + // `resolveFile('')` is `null`, which is the honest answer for a target + // naming the repo root and no file. Unchanged by the wildcard rule. + expect(rebaseTarget('/repo', '/repo', path.posix)).toBe(''); + expect(rebaseTarget('C:\\repo', 'C:\\repo', path.win32)).toBe(''); + }); +}); From 579a438761a9c6a8f0a4fc7b880a2d3fba41387a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:18:33 +0100 Subject: [PATCH 11/33] chore(deps)(deps-dev): bump @types/react in /gitnexus-web (#3245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 19.2.14 to 19.2.18. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react) --- updated-dependencies: - dependency-name: "@types/react" dependency-version: 19.2.18 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- gitnexus-web/package-lock.json | 8 ++++---- gitnexus-web/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 9dcc76b55..9e2991ee4 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -54,7 +54,7 @@ "@testing-library/user-event": "^14.6.6", "@types/dompurify": "^3.2.0", "@types/node": "^26.0.1", - "@types/react": "^19.2.14", + "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.10.2", @@ -2527,9 +2527,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 4bab97ad5..0a664827a 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -64,7 +64,7 @@ "@testing-library/user-event": "^14.6.6", "@types/dompurify": "^3.2.0", "@types/node": "^26.0.1", - "@types/react": "^19.2.14", + "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.10.2", From 1909af4e04648270011fb02c5c8f6fa9596838fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:18:56 +0100 Subject: [PATCH 12/33] chore(deps): bump softprops/action-gh-release from 3.0.2 to 3.0.3 (#3248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.2 to 3.0.3. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/3d0d9888cb7fd7b750713d6e236d1fcb99157228...efb35369e0ad2afab669f228072c1b0d510eae64) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 3.0.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c03a801c1..ca81460f1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -831,7 +831,7 @@ jobs: fi - name: Create GitHub Release - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v2 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v2 with: tag_name: ${{ steps.vtag-gate.outputs.vtag }} name: >- From 2220f4d8513b023d907f50ff4d1dc015b13a906a Mon Sep 17 00:00:00 2001 From: Abhinav Pandey Date: Thu, 10 Sep 2026 03:38:19 -0700 Subject: [PATCH 13/33] fix(resolution): label fallback guesses and preserve export visibility (#3190) * fix(resolution): distinguish name guesses and preserve export visibility * test(go): keep method enrichment fixture in one package * fix(resolution): address split review edge cases and evidence reporting * fix(exports): recognize imported and expression-local receivers * fix(resolution): align export and target evidence with language scope * fix(ingestion): preserve lexical import provenance through resolution * test(ci): rebalance Windows shards from measured slow suites * Address PR review feedback (#3190) - Label constructor unique-name guesses as global-name-fallback and run language vetoes - Tighten Go qualified, Rust crate::, and Ruby class-reopen fallback guards - Ignore for-loop shadowed CommonJS receivers and exclude guesses from the resolved-call census - Refresh FinalizeOutput and hook docs for lexical binding scopes Co-authored-by: Cursor * Tighten review-feedback leftovers on fallback visibility. Qualified Go calls still respect export and test-package rules, nested Rust src/ stays a module segment, and top-level conditional this.x is treated as CommonJS. Co-authored-by: Cursor * chore(autofix): apply prettier + eslint fixes via /autofix command * Address PR review feedback (#3190) - Distinguish Swift package prefixes when comparing target modules - Document lexical import binding and handledSites refusal marking - Drop the stale ci-scope-parity workflow claim and prototype-safe export verdicts Co-authored-by: Cursor * Address PR review feedback (#3190) Supply caller source on Ruby visibility cases so they exercise the named allow branches instead of the missing-text bypass. Co-authored-by: Cursor * fix(resolution): label unique constructor types as name guesses A workspace-unique class hit in findClassBindingInScope was treated as an in-scope bind, so Go/JS constructor-form sites skipped the guess label and the Go unexported veto. Co-authored-by: Cursor * fix(resolution): keep qualified constructors precise after unique-name split Bare constructor unique-name hits stay guesses so Go can veto an unexported type. A written qualifier is now carried as rawQualifiedName so `new pkg.Foo()` and `models.Box[T]{}` can still recover the unique class without that veto. Co-authored-by: Cursor * test(bench): rebaseline Go/Java scope-capture fingerprints for constructor qualifiers Generic Go composite literals and qualified Java `new pkg.Foo()` now carry @reference.qualified-name on existing constructor matches. Co-authored-by: Cursor * fix(resolution): treat import-reached unique constructors as precise C++ #include and Rust re-exports do not mint a lexical class binding. A unique type in an imported file (or imported directory) is therefore a real bind, not a name guess, so those CALLS edges stay import-resolved. Co-authored-by: Cursor * fix(resolution): require named or resolved imports for constructor precision Bare Go Box[T]{} is not package-qualified, and a sibling-file import of a different name is not constructor visibility. Co-authored-by: Cursor --------- Co-authored-by: Gergo Magyar Co-authored-by: Cursor Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- gitnexus-shared/src/index.ts | 1 + .../scope-resolution/finalize-algorithm.ts | 494 +++++++++- .../src/scope-resolution/symbol-definition.ts | 12 + gitnexus-shared/src/scope-resolution/types.ts | 26 +- gitnexus/bench/scope-capture/baselines.json | 13 +- gitnexus/scripts/cross-platform-shard.ts | 22 +- gitnexus/src/core/graph/edge-reasons.ts | 38 + gitnexus/src/core/graph/graph.ts | 12 +- gitnexus/src/core/graph/types.ts | 16 +- .../core/ingestion/finalize-orchestrator.ts | 3 + .../dart/name-fallback-visibility.ts | 77 ++ .../languages/dart/scope-resolver.ts | 2 + .../core/ingestion/languages/go/captures.ts | 23 +- .../languages/go/name-fallback-visibility.ts | 126 +++ .../ingestion/languages/go/scope-resolver.ts | 2 + .../core/ingestion/languages/java/captures.ts | 20 + .../languages/javascript/captures.ts | 24 + .../languages/javascript/scope-resolver.ts | 6 + .../ruby/name-fallback-visibility.ts | 196 ++++ .../languages/ruby/scope-resolver.ts | 2 + .../rust/name-fallback-visibility.ts | 183 ++++ .../languages/rust/scope-resolver.ts | 3 + .../swift/name-fallback-visibility.ts | 67 ++ .../languages/swift/scope-resolver.ts | 2 + .../languages/typescript/captures.ts | 24 + .../languages/typescript/scope-resolver.ts | 9 + .../ingestion/languages/vue/scope-resolver.ts | 3 + gitnexus/src/core/ingestion/pipeline.ts | 34 + .../src/core/ingestion/scope-extractor.ts | 28 +- .../contract/scope-resolver.ts | 118 ++- .../scope-resolution/graph-bridge/ids.ts | 16 + .../scope-resolution/name-fallback-summary.ts | 212 ++++ .../passes/free-call-fallback.ts | 206 +++- .../passes/imported-return-types.ts | 82 +- .../scope-resolution/pipeline/run.ts | 21 + .../scope-resolution/resolution-outcome.ts | 54 + .../scope-resolution/scope/walkers.ts | 100 +- .../utils/name-fallback-visibility.ts | 97 ++ .../src/core/ingestion/ts-js-export-marker.ts | 345 +++++++ gitnexus/src/core/lbug/graph-emit-sink.ts | 48 +- gitnexus/src/core/lbug/pdg-emit-sink.ts | 8 +- gitnexus/src/core/run-analyze.ts | 14 + gitnexus/src/storage/parse-cache.ts | 13 +- gitnexus/src/storage/repo-meta.ts | 10 + gitnexus/src/types/pipeline.ts | 8 + .../go-captures-golden/expected-captures.json | 6 +- .../go-method-enrichment/app.go | 7 +- .../barrel-dir-index-wildcard.test.ts | 136 +++ .../barrel-named-import-class-method.test.ts | 107 ++ .../barrel-named-over-star-precedence.test.ts | 56 ++ .../barrel-review-3182-repros.test.ts | 141 +++ .../barrel-wildcard-arrow-const.test.ts | 94 ++ .../resolvers/name-fallback-edges.test.ts | 207 ++++ .../resolvers/rust-import-scope.test.ts | 73 ++ .../test/unit/cross-platform-shard.test.ts | 29 +- .../test/unit/incremental-parse-cache.test.ts | 13 +- .../esm-export-marker.test.ts | 325 ++++++ .../free-call-fallback-guess-taint.test.ts | 469 +++++++++ .../go/go-captures-smoke.test.ts | 30 + .../import-scope-provenance.test.ts | 159 +++ .../java/java-captures.test.ts | 14 +- .../name-fallback-summary.test.ts | 238 +++++ .../name-fallback-visibility.test.ts | 928 ++++++++++++++++++ .../python/python-fixtures.test.ts | 127 ++- .../resolved-callee-names.test.ts | 82 ++ .../scope-resolution/scope-extractor.test.ts | 22 +- ...wildcard-collision-export-evidence.test.ts | 228 +++++ .../wildcard-topLevelOnly-gate.test.ts | 152 +++ 68 files changed, 6215 insertions(+), 248 deletions(-) create mode 100644 gitnexus/src/core/graph/edge-reasons.ts create mode 100644 gitnexus/src/core/ingestion/languages/dart/name-fallback-visibility.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts create mode 100644 gitnexus/src/core/ingestion/languages/ruby/name-fallback-visibility.ts create mode 100644 gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts create mode 100644 gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts create mode 100644 gitnexus/src/core/ingestion/scope-resolution/name-fallback-summary.ts create mode 100644 gitnexus/src/core/ingestion/scope-resolution/utils/name-fallback-visibility.ts create mode 100644 gitnexus/src/core/ingestion/ts-js-export-marker.ts create mode 100644 gitnexus/test/integration/resolvers/barrel-dir-index-wildcard.test.ts create mode 100644 gitnexus/test/integration/resolvers/barrel-named-import-class-method.test.ts create mode 100644 gitnexus/test/integration/resolvers/barrel-named-over-star-precedence.test.ts create mode 100644 gitnexus/test/integration/resolvers/barrel-review-3182-repros.test.ts create mode 100644 gitnexus/test/integration/resolvers/barrel-wildcard-arrow-const.test.ts create mode 100644 gitnexus/test/integration/resolvers/name-fallback-edges.test.ts create mode 100644 gitnexus/test/integration/resolvers/rust-import-scope.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/free-call-fallback-guess-taint.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/import-scope-provenance.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/name-fallback-summary.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/resolved-callee-names.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/wildcard-collision-export-evidence.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/wildcard-topLevelOnly-gate.test.ts diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 9857a60cc..7958cfa9d 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -137,6 +137,7 @@ export type { FinalizeOutput, FinalizedScc, FinalizeStats, + AmbiguousWildcardExport, } from './scope-resolution/finalize-algorithm.js'; // Scope-aware registries + 7-step lookup (RFC §4; Ring 2 SHARED #917) diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index f6038b3f0..2dc14b398 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -5,13 +5,14 @@ * Pure logic that takes per-file parse output (`ParsedImport[]` + * `SymbolDefinition[]`) and returns: * - * - Linked `ImportEdge[]` per module scope, with `targetModuleScope` and - * `targetDefId` filled where resolvable; edges that could not be - * resolved within the hard fixpoint cap are marked + * - Linked `ImportEdge[]` keyed by binding scope (`fromScope`; module + * scope unless `importsBindAtLexicalScope` is on), with + * `targetModuleScope` and `targetDefId` filled where resolvable; edges + * that could not be resolved within the hard fixpoint cap are marked * `linkStatus: 'unresolved'`. - * - Materialized `bindings` per module scope — local defs merged with - * imported / wildcard-expanded / re-exported names via the provider's - * `mergeBindings` precedence. + * - Materialized `bindings` keyed by the same scopes — local defs merged + * with imported / wildcard-expanded / re-exported names via the + * provider's `mergeBindings` precedence. * - The SCC condensation of the import graph, exposed so disjoint SCCs * can be processed in parallel by callers that want that. * @@ -39,7 +40,7 @@ import type { BindingRef, ImportEdge, ParsedImport, ScopeId, WorkspaceIndex } fr /** Per-file input for the finalize pass. */ export interface FinalizeFile { readonly filePath: string; - /** The module scope id for this file; owns the finalized imports + bindings. */ + /** Default binding scope for imports without lexical provenance or opt-in. */ readonly moduleScope: ScopeId; readonly parsedImports: readonly ParsedImport[]; /** @@ -84,6 +85,10 @@ export interface FinalizeInput { * expects pure answers. */ export interface FinalizeHooks { + /** Bind imports at their extracted lexical scope. Missing provenance retains + * the legacy module-scope behavior. Opt-in: lexical position and language + * import-binding semantics are distinct facts. */ + readonly importsBindAtLexicalScope?: boolean; /** * Resolve a raw import target to the concrete file path that owns it. * Return `null` when no target file is resolvable (e.g., `np.foo` when @@ -114,6 +119,34 @@ export interface FinalizeHooks { */ expandsWildcardTo(targetModuleScope: ScopeId, workspaceIndex: WorkspaceIndex): readonly string[]; + /** + * Does this language make two `wildcard` re-exports that both DECLARE the + * same name AMBIGUOUS (no winner), rather than overloads or redeclarations + * of one entity? + * + * True for ECMAScript modules: `export * from './a'; export * from './b'` + * with `collide` declared in both excludes the name from the module's + * exports, so binding either source is a guess. False (the default) for + * languages whose wildcard import is `#include`, `require`, or a package + * fan-out, where the same name declared in two files is an overload set + * (C++ `write_audit(int)` / `write_audit(int, int)` across two headers), a + * redeclaration of one function, or a per-file `init` — legal, and resolved + * downstream by arity or by definition. Only a language that opts in has + * its collisions refused and reported via `ambiguousWildcardExports`. + */ + readonly wildcardCollisionIsAmbiguous?: boolean; + + /** + * A named import / named re-export binds only to MODULE-LEVEL declarations + * of the target file. Opt-in for languages whose `import { x }` can never + * reach a class member: without it, a class method sharing a name with a + * top-level value — or standing alone — wins the callable preference in + * `findExportByName` and the import binds to a symbol the module cannot + * export (a confident wrong edge). Languages that bind module-level members + * by bare name (static members, module functions) leave it off. + */ + readonly namedImportsBindTopLevelOnly?: boolean; + /** * Merge `incoming` bindings into `existing` for a given name. Called * once per name at each scope. Typical rules: @@ -164,12 +197,32 @@ export interface FinalizeStats { readonly unresolvedEdges: number; readonly sccCount: number; readonly largestSccSize: number; + /** + * Names a file re-exported through two or more `export *` sources that each + * DECLARE the name, so the language names no winner. The finalize pass + * refuses to bind them (they are absent from the file's re-export closure + * AND from its wildcard-expanded module-scope bindings) instead of taking the + * first-listed source and publishing the guess as `import-resolved`. + * Reported so the caller can record the refusal — an importer of that name + * stays unresolved, and the reason must be auditable rather than silent. + */ + readonly ambiguousWildcardExports: readonly AmbiguousWildcardExport[]; +} + +/** One refused `export *` collision — see `FinalizeStats.ambiguousWildcardExports`. */ +export interface AmbiguousWildcardExport { + readonly filePath: string; + readonly name: string; + /** `nodeId`s of the colliding declarations, in `export *` declaration order. */ + readonly candidateDefIds: readonly string[]; } export interface FinalizeOutput { - /** Linked `ImportEdge[]` per module scope, in original input order. */ + /** Linked `ImportEdge[]` keyed by binding scope (`fromScope`). Module scope + * for languages that bind file-wide; nested lexical scopes when + * `importsBindAtLexicalScope` is on. */ readonly imports: ReadonlyMap; - /** Materialized bindings per module scope. */ + /** Materialized bindings keyed by the same scopes as `imports`. */ readonly bindings: ReadonlyMap>; /** SCCs in reverse-topological order (leaves first). */ readonly sccs: readonly FinalizedScc[]; @@ -223,7 +276,33 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu // SCC-condensed). Eliminates the recursive crawl that the per-edge // `tryFinalize` call site used to do; lookups are O(1) afterwards. // See `buildReexportClosures` for the algorithm. - const reexportClosures = buildReexportClosures(input.files, byFilePath, edgeIndex); + // A local import is a dependency of its file, not a re-export of that file. + const moduleEdgeIndex = new Map(); + for (const file of input.files) { + moduleEdgeIndex.set( + file.filePath, + (edgeIndex.get(file.filePath) ?? []).filter((d) => d.fromScope === file.moduleScope), + ); + } + const ambiguityByFile = collectAmbiguityByFile( + input.files, + byFilePath, + moduleEdgeIndex, + hooks.wildcardCollisionIsAmbiguous === true, + hooks.namedImportsBindTopLevelOnly === true, + ); + const ambiguousByFile = new Map>(); + for (const [filePath, byName] of ambiguityByFile) { + ambiguousByFile.set(filePath, new Set(byName.keys())); + } + const topLevelOnly = hooks.namedImportsBindTopLevelOnly === true; + const reexportClosures = buildReexportClosures( + input.files, + byFilePath, + moduleEdgeIndex, + ambiguousByFile, + topLevelOnly, + ); // ── Phase 3: process SCCs in reverse-topological order (leaves first). // Within each SCC, run a bounded fixpoint that resolves intra-SCC edges. @@ -231,6 +310,19 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu // already finalized); edges inside the SCC may need multiple passes. const linkedByScope = new Map(); let linkedEdges = 0; + // Every refused wildcard name, reported from the ambiguity map rather than from + // the edges phase 4 happens to drop: a language whose `expandsWildcardTo` + // returns nothing (TypeScript — `export *` never binds names locally) drops + // no expanded edge, yet its importers were refused through the closure just + // the same, and that refusal must still be visible. Named-vs-named conflicts + // remain refused above but are not export-star collisions in this audit. + const ambiguousWildcardExports: AmbiguousWildcardExport[] = []; + for (const [filePath, byName] of ambiguityByFile) { + for (const [name, ambiguity] of byName) { + if (ambiguity.kind !== 'wildcard') continue; + ambiguousWildcardExports.push({ filePath, name, candidateDefIds: ambiguity.candidateDefIds }); + } + } for (const scc of sccs) { const sccFiles = new Set(scc.files); @@ -249,7 +341,7 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu if (drafts === undefined) continue; for (const draft of drafts) { if (draft.finalized !== null) continue; - const finalized = tryFinalize(draft, byFilePath, reexportClosures); + const finalized = tryFinalize(draft, byFilePath, reexportClosures, topLevelOnly); if (finalized !== null) { draft.finalized = finalized; progressed = true; @@ -272,13 +364,25 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu } } - // ── Phase 4: collect finalized `ImportEdge[]` per module scope, preserving + // ── Phase 4: collect finalized `ImportEdge[]` per binding scope, preserving // input order within each file, and wildcard-expand where applicable. for (const file of input.files) { const drafts = edgeIndex.get(file.filePath); if (drafts === undefined) continue; - const finalized: ImportEdge[] = []; + const finalizedByScope = new Map([[file.moduleScope, []]]); + // Names this file's `export *` sources collide on (see + // `collectAmbiguousWildcards`). Their expanded edges are dropped here, so + // the file's own module scope does not bind an arbitrary winner either — + // suppressing them only in the closure would leave this binding standing, + // and it was this binding, not the closure, that produced the published + // `import-resolved` guess. + const ambiguousHere = ambiguousByFile.get(file.filePath) ?? EMPTY_NAME_SET; for (const d of drafts) { + let finalized = finalizedByScope.get(d.fromScope); + if (finalized === undefined) { + finalized = []; + finalizedByScope.set(d.fromScope, finalized); + } const edge = d.finalized; if (edge === null) { throw new Error(`Invariant violated: import edge was not finalized for ${file.filePath}`); @@ -286,16 +390,25 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu if (d.source.kind === 'wildcard' && edge.linkStatus !== 'unresolved') { // Produce one `wildcard-expanded` ImportEdge per exported name. const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex); - for (const e of expanded) finalized.push(e); + for (const e of expanded) { + if ( + d.fromScope === file.moduleScope && + e.kind === 'wildcard-expanded' && + ambiguousHere.has(e.localName) + ) + continue; + finalized.push(e); + } } else { finalized.push(edge); } if (edge.linkStatus !== 'unresolved') linkedEdges++; } - linkedByScope.set(file.moduleScope, Object.freeze(finalized)); + for (const [scopeId, edges] of finalizedByScope) + linkedByScope.set(scopeId, Object.freeze(edges)); } - // ── Phase 5: materialize module-scope bindings (local + imports + wildcards), + // ── Phase 5: materialize bindings (local + scoped imports + wildcards), // delegating precedence to `provider.mergeBindings`. const bindingsByScope = materializeBindings(input.files, linkedByScope, hooks); @@ -312,6 +425,7 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu unresolvedEdges: totalEdges - linkedEdges, sccCount, largestSccSize, + ambiguousWildcardExports: Object.freeze(ambiguousWildcardExports), }; return Object.freeze({ @@ -339,6 +453,10 @@ function makeEdgeDrafts( hooks: FinalizeHooks, workspace: WorkspaceIndex, ): ImportEdgeDraft[] { + const fromScope = + hooks.importsBindAtLexicalScope === true + ? (parsed.declaredAtScope ?? file.moduleScope) + : file.moduleScope; // Dynamic-unresolved passes through — no `BindingRef`, no target file. if (parsed.kind === 'dynamic-unresolved') { const base: ImportEdge = { @@ -351,7 +469,7 @@ function makeEdgeDrafts( { source: parsed, fromFile: file.filePath, - fromScope: file.moduleScope, + fromScope, targetFile: null, base, finalized: base, // already fully finalized @@ -381,7 +499,7 @@ function makeEdgeDrafts( { source: parsed, fromFile: file.filePath, - fromScope: file.moduleScope, + fromScope, targetFile: null, base, finalized: base, @@ -417,7 +535,7 @@ function makeEdgeDrafts( return { source: parsed, fromFile: file.filePath, - fromScope: file.moduleScope, + fromScope, targetFile: tf, base, finalized: isFileLevelTerminal ? base : null, @@ -529,6 +647,7 @@ function tryFinalize( draft: ImportEdgeDraft, byFilePath: Map, reexportClosures: ReadonlyMap, + topLevelOnly: boolean, ): ImportEdge | null { const targetFile = draft.targetFile; if (targetFile === null) return draft.base; // already terminal @@ -552,7 +671,11 @@ function tryFinalize( // so consumers can reach the module as a symbol — but its absence is not // a failure. if (draft.base.kind === 'namespace') { - const moduleDef = findExportByName(targetModule.localDefs, extractExportedName(draft.source)); + const moduleDef = findExportByName( + targetModule.localDefs, + extractExportedName(draft.source), + topLevelOnly, + ); return { ...draft.base, targetModuleScope: targetModule.moduleScope, @@ -564,7 +687,7 @@ function tryFinalize( // local defs. Multi-hop re-export chains settle iteratively — each hop // resolves once its prior hop is finalized. const importedName = extractExportedName(draft.source); - const exported = findExportByName(targetModule.localDefs, importedName); + const exported = findExportByName(targetModule.localDefs, importedName, topLevelOnly); if (exported !== undefined) { const transitiveVia = @@ -691,15 +814,18 @@ function buildReexportClosures( files: readonly FinalizeFile[], byFilePath: ReadonlyMap, edgeIndex: ReadonlyMap, + ambiguous: ReadonlyMap>, + topLevelOnly: boolean, ): ReadonlyMap { const closures = new Map>(); for (const file of files) closures.set(file.filePath, new Map()); // ── Step 1: build the re-export sub-graph (only resolvable wildcard / - // reexport / flagged-named targets contribute edges), and collect the - // per-file ambiguous names in the same walk. + // reexport / flagged-named targets contribute edges). The per-file + // ambiguous-name sets arrive precomputed (`collectAmbiguityByFile`) because + // phase 4 consults the same sets when it expands wildcards into module + // scope — one source of truth for "this name has no winner". const subGraph = new Map>(); - const ambiguous = new Map>(); for (const file of files) { const targets = new Set(); const drafts = edgeIndex.get(file.filePath); @@ -710,7 +836,6 @@ function buildReexportClosures( if (!byFilePath.has(d.targetFile)) continue; targets.add(d.targetFile); } - ambiguous.set(file.filePath, collectAmbiguousReexports(drafts, byFilePath)); } subGraph.set(file.filePath, targets); } @@ -726,7 +851,7 @@ function buildReexportClosures( if (!scc.isCycle) { const filePath = scc.files[0]; if (filePath !== undefined) { - populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous); + populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous, topLevelOnly); } continue; } @@ -740,7 +865,9 @@ function buildReexportClosures( progressed = false; iter++; for (const filePath of scc.files) { - if (populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous)) { + if ( + populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous, topLevelOnly) + ) { progressed = true; } } @@ -820,6 +947,220 @@ function isNamedReexport(draft: ImportEdgeDraft): draft is ImportEdgeDraft & { * are still filling in, so detecting them needs a set that grows during the * fixpoint — the thing this pre-pass exists to avoid. */ +/** + * Per-file set of re-exported names that have NO decidable winner, from both + * detectors: `collectAmbiguousReexports` (flagged-named vs flagged-named) and + * `collectAmbiguousWildcards` (`export *` vs `export *`, direct declarations). + * Fixed for the whole run; consulted by the closure fixpoint AND by phase 4's + * wildcard expansion, so a refused name is absent from BOTH the exports an + * importer can reach and the module-scope bindings the file itself sees. + */ +interface ReexportAmbiguity { + readonly kind: 'named' | 'wildcard'; + readonly candidateDefIds: readonly string[]; +} + +function collectAmbiguityByFile( + files: readonly FinalizeFile[], + byFilePath: ReadonlyMap, + edgeIndex: ReadonlyMap, + wildcardCollisionIsAmbiguous: boolean, + topLevelOnly: boolean, +): ReadonlyMap> { + const out = new Map>(); + for (const file of files) { + const drafts = edgeIndex.get(file.filePath); + if (drafts === undefined) continue; + const byName = new Map(); + for (const name of collectAmbiguousReexports(drafts, byFilePath)) { + byName.set(name, { + kind: 'named', + candidateDefIds: namedReexportCandidates(drafts, byFilePath, name, topLevelOnly), + }); + } + // Wildcard-vs-wildcard is a language rule (`FinalizeHooks. + // wildcardCollisionIsAmbiguous`): ECMAScript excludes the name, C++ + // overloads it. Without the opt-in this half stays first-wins. + if (wildcardCollisionIsAmbiguous) { + for (const [name, ids] of collectAmbiguousWildcards(file, drafts, byFilePath)) { + if (!byName.has(name)) byName.set(name, { kind: 'wildcard', candidateDefIds: ids }); + } + } + if (byName.size === 0) continue; + out.set(file.filePath, byName); + } + return out; +} + +/** The declarations a flagged-named collision on `localName` points at. */ +function namedReexportCandidates( + drafts: readonly ImportEdgeDraft[], + byFilePath: ReadonlyMap, + localName: string, + topLevelOnly: boolean, +): readonly string[] { + const ids: string[] = []; + for (const draft of drafts) { + if (!isNamedReexport(draft) || draft.source.localName !== localName) continue; + const targetFile = draft.targetFile; + if (targetFile === null) continue; + const target = byFilePath.get(targetFile); + if (target === undefined) continue; + const def = findExportByName(target.localDefs, draft.source.importedName, topLevelOnly); + if (def !== undefined && !ids.includes(def.nodeId)) ids.push(def.nodeId); + } + return Object.freeze(ids); +} + +/** + * `export * from './a'; export * from './b'` where BOTH `a` and `b` declare + * `collide`: the language names no winner (ECMAScript excludes the name from + * the module's exports entirely; a direct `import { collide }` of it is a + * SyntaxError-class ambiguity). First-wins here published the `a` binding as + * `import-resolved` at full confidence — a definite target for a call that has + * none, which is the incorrect-context-over-missing-context failure in its + * purest form. The name is refused instead and reported. + * + * Decidable in this pre-pass because it reads only the targets' own + * `localDefs` — nothing that fills in during the closure fixpoint. Collisions + * that arrive TRANSITIVELY (two wildcards whose targets each re-export the + * name from somewhere else) are still first-wins; detecting them needs a set + * that grows mid-fixpoint, the thing this pre-pass exists to avoid. + * + * A name the file DECLARES itself, or re-exports by NAME, is excluded: an + * explicit export shadows every `export *`, so those collisions are legal and + * resolved by precedence, not ambiguous. + * + * Only MODULE-LEVEL, EXPORT-SHAPED declarations can collide. `localDefs` also + * carries class members, properties and parameters (a `Property:value` on two + * unrelated classes, an interface field named `move`), which no `export *` + * publishes. Counting those produced thousands of phantom collisions on a real + * monorepo (2,640 on grafana) and — the dangerous half — would have refused a + * genuinely exported `move()` because some class elsewhere had a `move` + * property. The wildcard closure loop tolerates the wider set because nobody + * imports a property by name; a refusal cannot afford the same tolerance. + * + * Export evidence, when the language supplies it (`SymbolDefinition.isExported`, + * tri-state), settles the rest: a def marked `false` is module-private and is + * neither a provider here nor published by the closure + * (`indexTopLevelExportsByName`), so a private `function foo` beside an exported + * one no longer refuses the export — and, the half that matters more, cannot be + * the first-listed winner the closure binds either. A def marked `true` counts + * whatever its label, `Variable` included: the closure publishes a `Variable`, + * so two sources each exporting `const alpha` are a real collision and must be + * refused rather than first-wins. + * + * Without evidence (`isExported` undefined — most languages) `Variable` is + * excluded from the COLLISION set only: the typical top-level `const` in a + * barrel's sources is module-private (`const category = ['Axis']` in fourteen + * option-builder files), so counting it would refuse a real exported constant + * of the same name for nothing. Residual risk, accepted, for that evidence-free + * case: a non-exported `function`/`class` sharing its name with an exported one + * behind the same barrel is counted as a collision and the export is refused — + * a missing edge, never a wrong one. `ownerId` is only set for class members, so + * a callable nested in an object literal (`showIf: (cfg) => …` across fourteen + * option-builder files) still counts as a provider when unmarked. Measured + * before the export marker existed: grafana@871af0720 refuses 52 names (from + * 2,640 before the member exclusion), discourse@3f71fa15c 5. + */ + +/** Labels that are never a module export, whatever their owner. */ +const NON_EXPORTABLE_MEMBER_LABELS: readonly string[] = [ + 'Property', + 'Method', + 'Constructor', + 'Parameter', + 'Field', +]; +/** Labels excluded from the collision set when no export evidence is present. */ +const UNMARKED_NON_COLLIDING_LABELS: ReadonlySet = new Set([ + ...NON_EXPORTABLE_MEMBER_LABELS, + 'Variable', +]); +/** + * Labels a module can never export by name: class/interface members and + * parameters. Filtered by LABEL, not `ownerId` — `ownerId` is populated in a + * later pass and is not reliable while the closure is built. + */ +const MEMBER_LABELS: ReadonlySet = new Set(NON_EXPORTABLE_MEMBER_LABELS); + +/** + * A declaration `export *` could publish, for COLLISION purposes: top-level, of + * an exportable kind, and not marked module-private. With export evidence the + * label rule yields to the marker (an exported `Variable` collides; a private + * `function` does not); without it `Variable` is left out — see the header. + */ +function isWildcardPublishable(def: SymbolDefinition): boolean { + // Explicit evidence wins over the label: a CommonJS `module.exports = { + // alpha() {} }` member is labeled Method and IS the module's export. + if (def.isExported === true) return true; + if (def.isExported === false) return false; + if (def.ownerId !== undefined) return false; + return !UNMARKED_NON_COLLIDING_LABELS.has(def.type); +} + +/** + * Can a declaration of the barrel's OWN shadow a name its `export *` sources + * collide on? Only a module-level binding can — ECMAScript's explicit-export + * precedence is about the module's own exports. A class MEMBER named `clash` + * (`export class Unrelated { clash() {} }`) is not such a binding and must not + * switch the collision check off; it did, and a confident edge to one source's + * `clash` was emitted where the import should have been refused. + */ +function canShadowWildcard(def: SymbolDefinition): boolean { + if (def.isExported === true) return true; + if (def.isExported === false) return false; + if (def.ownerId !== undefined) return false; + return !MEMBER_LABELS.has(def.type); +} +function collectAmbiguousWildcards( + file: FinalizeFile, + drafts: readonly ImportEdgeDraft[], + byFilePath: ReadonlyMap, +): ReadonlyMap { + const shadowed = new Set(); + for (const def of file.localDefs) { + if (!canShadowWildcard(def)) continue; + const name = deriveSimpleName(def); + if (name !== null) shadowed.add(name); + } + for (const draft of drafts) { + if (isNamedReexport(draft)) shadowed.add(draft.source.localName); + } + + // name → (target file → declaring def ids), in declaration order. + const providers = new Map>(); + for (const draft of drafts) { + if (draft.source.kind !== 'wildcard') continue; + const targetFile = draft.targetFile; + if (targetFile === null) continue; + const target = byFilePath.get(targetFile); + if (target === undefined) continue; + for (const def of target.localDefs) { + if (!isWildcardPublishable(def)) continue; + const name = deriveSimpleName(def); + if (name === null || shadowed.has(name)) continue; + let byTarget = providers.get(name); + if (byTarget === undefined) { + byTarget = new Map(); + providers.set(name, byTarget); + } + const ids = byTarget.get(targetFile); + if (ids === undefined) byTarget.set(targetFile, [def.nodeId]); + else ids.push(def.nodeId); + } + } + const conflicting = new Map(); + for (const [name, byTarget] of providers) { + // Two DIFFERENT source files declaring the name. The same file declaring + // it twice (overloads, a declaration merged with its namespace) is one + // provider and not a collision. + if (byTarget.size < 2) continue; + conflicting.set(name, Object.freeze([...byTarget.values()].flat())); + } + return conflicting; +} + function collectAmbiguousReexports( drafts: readonly ImportEdgeDraft[], byFilePath: ReadonlyMap, @@ -859,6 +1200,7 @@ function populateFileClosure( edgeIndex: ReadonlyMap, closures: Map>, ambiguousByFile: ReadonlyMap>, + topLevelOnly: boolean, ): boolean { const myClosure = closures.get(filePath); if (myClosure === undefined) return false; @@ -883,7 +1225,7 @@ function populateFileClosure( if (ambiguous.has(localName) || myClosure.has(localName)) continue; const importedName = draft.source.importedName; - const direct = findExportByName(targetModule.localDefs, importedName); + const direct = findExportByName(targetModule.localDefs, importedName, topLevelOnly); if (direct !== undefined) { myClosure.set(localName, { def: direct, via: Object.freeze([targetFile]) }); continue; @@ -909,9 +1251,27 @@ function populateFileClosure( const targetModule = byFilePath.get(targetFile); if (targetModule === undefined) continue; - for (const def of targetModule.localDefs) { - const name = deriveSimpleName(def); - if (name === null || ambiguous.has(name) || myClosure.has(name)) continue; + // Fan out the WINNER per name, not every def. `export const alpha = () => + // {}` emits both a `Variable` (the lexical declaration) and a `Function` + // (the arrow) under the same simple name; iterating `localDefs` raw let + // whichever came first — the `Variable` — claim the closure slot, and a + // call bound to a value shadow emits no CALLS edge. Named re-exports + // already go through `findExportByName`'s callable-preferred index; the + // wildcard hop is the same lookup and must apply the same preference. + // Measured: grafana `Button`/`clearButtonStyles` (arrow consts behind + // `export *`) resolved 8 of 475 ledger entries before this. + // Over TOP-LEVEL declarations only. `localDefs` also carries class members; + // `Foo.render` (label `Method`, callable) outranked the file's real + // `const render` in the callable-preferred index and `import { render }` + // bound to a symbol `export *` can never publish — a confident wrong edge + // where the value shadow used to yield none. Gated by the same hook as the + // named-import path: only a language that opted in (ECMAScript, where + // `export *` cannot publish a class member) narrows; every other language's + // wildcard keeps the wide index, whose members are legitimately reachable. + for (const [name, def] of (topLevelOnly ? indexTopLevelExportsByName : indexExportsByName)( + targetModule.localDefs, + )) { + if (ambiguous.has(name) || myClosure.has(name)) continue; myClosure.set(name, { def, via: Object.freeze([targetFile]) }); } const targetClosure = closures.get(targetFile); @@ -993,6 +1353,13 @@ function deriveSimpleName(def: SymbolDefinition): string | null { function findExportByName( defs: readonly SymbolDefinition[], name: string, + /** + * `true` (a `namedImportsBindTopLevelOnly` language): consult only + * module-level declarations, so a class member can neither outrank a + * top-level value nor bind on its own. Phase-4 wildcard expansion keeps + * the wide index — that is the path languages use to bind members. + */ + topLevelOnly: boolean = false, ): SymbolDefinition | undefined { // GENERIC RULE (applies to every language using this finalize // algorithm): when MULTIPLE `SymbolDefinition`s share the same simple @@ -1018,7 +1385,7 @@ function findExportByName( // // See `gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts` // for the cross-file regression this rule prevents. - return indexExportsByName(defs).get(name); + return (topLevelOnly ? indexTopLevelExportsByName(defs) : indexExportsByName(defs)).get(name); } /** @@ -1062,6 +1429,47 @@ function indexExportsByName( return index; } +/** + * `indexExportsByName` restricted to declarations a module publishes by name: + * members (by LABEL — `ownerId` is stamped by a later reconcile pass and is not + * reliable while the closure is built) are skipped unless the language marked + * them exported (a CommonJS `module.exports = { alpha() {} }` member), and so + * is any def the language marked module-private (`isExported === false`) — a + * function nested inside another function carries the Function label and used + * to displace the real exported value of the same name here; a barrel cannot + * republish what its source never exported, and binding it would put a private + * `function foo` in front of the exported one another source provides. + * `Variable` stays, since a barrel legitimately republishes a `const`. Same + * memoization contract. + */ +const TOP_LEVEL_EXPORTS_BY_NAME = new WeakMap< + readonly SymbolDefinition[], + ReadonlyMap +>(); + +function indexTopLevelExportsByName( + defs: readonly SymbolDefinition[], +): ReadonlyMap { + const cached = TOP_LEVEL_EXPORTS_BY_NAME.get(defs); + if (cached !== undefined) return cached; + const index = new Map(); + for (const d of defs) { + // Evidence over label, both ways: a marked-private def (a function nested + // in another function carries the Function label too) is skipped, and a + // marked-exported member (`module.exports = { alpha() {} }`) is admitted. + if (d.isExported === false) continue; + if (d.isExported !== true && MEMBER_LABELS.has(d.type)) continue; + const name = deriveSimpleName(d); + if (name === null) continue; + const existing = index.get(name); + if (existing === undefined) index.set(name, d); + else if (!isCallableOrTypeLike(existing.type) && isCallableOrTypeLike(d.type)) + index.set(name, d); + } + TOP_LEVEL_EXPORTS_BY_NAME.set(defs, index); + return index; +} + const EMPTY_NAME_SET: ReadonlySet = new Set(); const CALLABLE_OR_TYPE_LIKE: ReadonlySet = new Set([ @@ -1179,7 +1587,7 @@ function materializeBindings( linkedByScope: ReadonlyMap, hooks: FinalizeHooks, ): ReadonlyMap> { - const out = new Map>(); + const buckets = new Map>(); // Build a `nodeId → SymbolDefinition` index once across all files // (O(N_files × D_defs)) so the per-edge lookup below is O(1) instead @@ -1204,8 +1612,17 @@ function materializeBindings( scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope)); } - // Layer in finalized imports. - const imports = linkedByScope.get(file.moduleScope) ?? []; + buckets.set(file.moduleScope, scopeBindings); + } + + // Layer imports into their binding scope; lexical locals already live in + // the scope tree and must not be copied into unrelated scope buckets. + for (const [scopeId, imports] of linkedByScope) { + let scopeBindings = buckets.get(scopeId); + if (scopeBindings === undefined) { + scopeBindings = new Map(); + buckets.set(scopeId, scopeBindings); + } for (const edge of imports) { if (edge.targetDefId === undefined || edge.linkStatus === 'unresolved') continue; const def = defById.get(edge.targetDefId); @@ -1224,15 +1641,18 @@ function materializeBindings( if (name === null) continue; const incoming: BindingRef[] = [{ def, origin, via: edge }]; const existing = scopeBindings.get(name) ?? []; - scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope)); + scopeBindings.set(name, hooks.mergeBindings(existing, incoming, scopeId)); } + } + const out = new Map>(); + for (const [scopeId, scopeBindings] of buckets) { // Freeze nested buckets for immutability. const frozen = new Map(); for (const [name, refs] of scopeBindings) { frozen.set(name, Object.freeze(refs.slice())); } - out.set(file.moduleScope, frozen); + out.set(scopeId, frozen); } return out; diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index e90b0be85..4334d1534 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -111,6 +111,18 @@ export interface SymbolDefinition { * source (for example an anonymous class). Consumers may use this only as a * conservative priority hint; it does not change graph-node identity. */ isSynthetic?: boolean; + /** + * Whether the producing language saw EXPORT EVIDENCE on this declaration — + * an `export` modifier, a later `export { name }` specifier, an `export + * default name`. TRI-STATE, and the absence is load-bearing: `undefined` + * means the language emitted no verdict (most languages, and any ECMAScript + * file whose export surface is a CommonJS assignment the marker cannot read), + * which readers MUST treat as "unknown" and fall back to their prior + * behavior. Only `false` says "this module does not publish the name": a + * `false` keeps a module-private `function foo` from being counted as a + * wildcard provider or bound through a barrel's `export *` closure. + */ + isExported?: boolean; /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ ownerId?: string; /** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`) diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 94a085daa..50a968146 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -107,7 +107,14 @@ export type CaptureMatch = Readonly>; * produced when `expandsWildcardTo` materializes a wildcard against target * exports — a provider must never emit it at parse time. */ -export type ParsedImport = +export type ParsedImport = ParsedImportSyntax & { + /** Lexical location retained by extraction, independently of execution timing. + * Absent for legacy or synthesized imports. Binding semantics remain opt-in + * through FinalizeHooks.importsBindAtLexicalScope. */ + readonly declaredAtScope?: ScopeId; +}; + +type ParsedImportSyntax = /** * Per-name import without rename. * @@ -183,14 +190,15 @@ export type ParsedImport = * field exists.** The natural place to decide it looks like the graph * bridge, by walking the scope the finalized edges hang off; that is * exactly what `graph-bridge/imports-to-edges.ts` once attempted, and it - * is dead code by construction. `finalize-algorithm.ts:295` publishes - * every file's finalized edges as - * `linkedByScope.set(file.moduleScope, …)`, so the map the bridge - * receives is keyed by the file's **Module** scope and by nothing else: - * the walk starts at a `Module` every time and answers `false` for every - * import in the tree. Finalize cannot recover the position either — - * `FinalizeFile.parsedImports` is a flat per-file `ParsedImport[]` with - * no scope attached. The extractor is the last stage that still knows + * is dead code by construction. Default finalize still publishes each + * file's edges under `file.moduleScope`, so a bridge walk that starts + * there answers `false` for every import. Resolvers that opt into + * `importsBindAtLexicalScope` instead key the edge by + * `parsed.declaredAtScope` when extraction placed the statement (Rust + * `use` in a function body is the example). Even then the *execution* + * question — does this import run at module init? — is not the same as + * the lexical key, and `FinalizeFile.parsedImports` is still a flat + * per-file list. The extractor is the last stage that still knows * where the statement sat (`scope-extractor.ts`, Pass 3), so it marks the * fact here and it rides the edge from there — see * {@link ImportEdge.runsOnlyWhenCalled}. diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 272d081c0..94fc262f6 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -1,7 +1,9 @@ { "_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).", "go": { - "fingerprint": "9c554a9d698a2b79fb419852daadca87b8aae88180cceabf9c8d82f3e3300f2e", + "fingerprint": "2e3099db962f82f9d8641707a8b2bcf6875dafec1f54904f2dec7ac65ba22e9f", + "_rebaselined_3190_ctor_qualified_name": "#3190: generic composite-literal constructors now carry @reference.qualified-name with the written pkg.Box[T] spelling so constructor fallback can tell a qualifier from a bare unique-name guess. DIGEST DRIFT ONLY: the tag is added to existing constructor matches (captureGroups unchanged on go-constructor-type-inference/cmd/main.go). Prior 990e4921a7ef0e6aa4d7da92b671de91fa2621cb27918adb27742740dc6ad902 -> 2e3099db962f82f9d8641707a8b2bcf6875dafec1f54904f2dec7ac65ba22e9f; capture_groups_fp 2579, fixture_count 122.", + "_rebaselined_3190": "Corrected go-method-enrichment/app.go to share package animal with its declarations; its capture count is now 16 rather than 15. No Go emitter change. Scaling budget unchanged.", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.", @@ -137,8 +139,9 @@ "_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0." }, "java": { - "fingerprint": "2bf47cc19b595a9889ac21ec0154c6ce6786271d68551f21d1bc14c626bcd4ff", + "fingerprint": "e22a753b9f59331898a3a27c43072b588c33639937b1f618f6c70ce04f072a9f", "scaling_budget": 1.5, + "_rebaselined_3190_ctor_qualified_name": "#3190: qualified constructors (`new pkg.Foo()`, `new pkg.Box()`) now copy @reference.call.constructor.qualified onto @reference.qualified-name so constructor fallback treats the written qualifier as precise. DIGEST DRIFT ONLY: the tag is added to existing F35 matches. Prior 2bf47cc19b595a9889ac21ec0154c6ce6786271d68551f21d1bc14c626bcd4ff -> e22a753b9f59331898a3a27c43072b588c33639937b1f618f6c70ce04f072a9f; capture_groups_fp 3586, fixture_count 209.", "_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: synthesized Java anonymous classes and bodied enum constants now carry the presence-only @declaration.is-synthetic sidecar used to preserve source-written dispatch targets at the fanout cap. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE: the tag is attached to existing synthetic declaration matches; capture groups and fixture count remain 5755/18405, 3512, and 206. Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a; CI scaling 0.971 < 1.5.", "_rebaselined_2917_record_component_accessors": "#2917: every implicit Java record-component accessor now emits a component-bounded @scope.function plus @declaration.method/name/zero-arity/return-type metadata. The scope boundary prevents subsequent record-body references from being attributed to the accessor. Java was the only general language fingerprint to move; capture groups scale by exactly two per generated record component (small 5755 -> 6255, large 18405 -> 20005). Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 901a66c7dc0f071eeef9e4864b2519e5b58a1a141a1f9a7817ea42f7ff70eafb; scaling 0.961 < 1.5. Re-measured after merging origin/main, which carries #2935's is-synthetic sidecar on top of the same corpus: 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a -> 79dafc369eaeb7183ee8cc1149b1a6c21ad672c7e5b806fe8b0060e5a952c79a; scaling 1.085 < 1.5, capture groups 6255/20005, capture_groups_fp 3560, fixture_count 206 (unchanged by the merge).", "_rebaselined_2900_record_heritage": "#2900 review follow-up: the Java scale unit now includes a record implementing Marker, so the record-declaration @reference.inherits path is fingerprinted and exercised at scale. Prior b29e263524f55151dcb7cfc4c929d3d1d7bb360355cee4e832158f927857f663 -> 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5; scaling 1.042 < 1.5.", @@ -171,7 +174,8 @@ "capture_groups_fp": 680 }, "typescript": { - "fingerprint": "fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff", + "fingerprint": "60e75bbe846f7200005f12c4d95c32e82d9d3feae9f2fd594e484670762b82b0", + "_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.", "scaling_budget": 1.5, "_rebaselined_2934_import_type_only": "#2934: `import-decomposer.ts` attaches a presence-only `@import.type-only` synthetic capture to specifiers `tsc` erases, so `check --cycles` can stop counting type-only edges as initialization cycles. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE \u2014 the tag is added to import matches that already existed, never a new match, the same shape as the #2747 receiver-chain rebaseline. Every count is unchanged: capture_groups_fp 2414, fixture_count 155, capture_groups_small/large 4503/14403 (those measure the SYNTHETIC scaling source, which has no imports at all). The fingerprint moves because `canonicalizeMatch` in measure.mjs hashes every TAG on every match, synthetics included, so one extra presence-only tag on an existing match rewrites that match's canonical string. Attribution is exact, not inferred: neutralizing ONLY the `m['@import.type-only'] = \u2026` assignment in import-decomposer.ts and re-running returns the fingerprint to c2fbf8a89e5686dd\u2026 byte-for-byte, so nothing else in the TypeScript capture stream moved. All 14 other languages report ok. Scaling 0.997 < 1.5. NOTE ON THE CONTROL: javascript did not move (2026993b\u2026, 43 fixtures), but it is a WEAK control here \u2014 `import type` is TypeScript-only syntax, so a JS corpus cannot express the construct and could not have drifted either way. It evidences no collateral damage, not the correctness of the TS change; the exact-attribution check above is what does that. Prior c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8 -> f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.", @@ -197,7 +201,8 @@ "_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Drift: `await svc.verify(token, ...)` (typescript-generic-calls/src/guest.ts, member call) and `initializer()(() => {...})` (typescript-hof-callbacks/src/store.ts, call-of-call) lose `direct-callee-name`. `await verifyToken(token, ...)` (admin.ts/auth.ts) KEEPS `direct-callee-name|verifyToken`: tree-sitter-typescript parses `await f(x)` as call_expression(function: await_expression(f), type_arguments, ...), and wrappedExpression now unwraps `await_expression` so the direct designator survives as it does for the un-awaited spelling. capture_groups_fp 2465 (unchanged). Prior 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633 -> fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff." }, "javascript": { - "fingerprint": "2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3", + "fingerprint": "b916c7072b30d09b4949803810604830ea1aca1cfdda0d9d9312f7cb22f7a10a", + "_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3 -> 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b; scaling 1.050 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior 917a9cd975ba035bdad71fdb70cd72eeddec58c25797e5a1addfa6172808a55c -> b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3; scaling 1.093 < 1.5.", diff --git a/gitnexus/scripts/cross-platform-shard.ts b/gitnexus/scripts/cross-platform-shard.ts index 2c52dd86b..0cd9fe8d9 100644 --- a/gitnexus/scripts/cross-platform-shard.ts +++ b/gitnexus/scripts/cross-platform-shard.ts @@ -36,9 +36,9 @@ * * Only files heavy enough to matter are listed; everything else is carried by * {@link PER_FILE_OVERHEAD_SEC} alone. These are load-balancing hints, NOT - * assertions — no - * test asserts a runtime, and drift only makes the split slightly less even, so - * a stale entry is harmless and refreshing them is optional. Deliberately not + * assertions — no test measures elapsed time against this table. Missing or + * stale heavy entries can still overload a shard; refresh them from failed + * CI logs and replay that profile in the partition tests. Deliberately not * auto-generated: a committed table is reviewable and works offline, and the * alternative (timing files at CI runtime to decide the split) would make the * partition depend on the very machine load it is trying to protect against. @@ -49,13 +49,15 @@ export const WINDOWS_WEIGHTS_SEC: Readonly> = { 'test/integration/cli-e2e.test.ts': 621, 'test/integration/worker-pool.test.ts': 222, 'test/unit/incremental-vector-extension-ordering.test.ts': 87, - // ESTIMATE, not a measurement (#2841): this suite drives more full - // `runFullAnalysis` cycles than the VECTOR sibling above, so the 8 s - // PER_FILE_OVERHEAD floor would badly under-charge it and skew the Windows - // split — the failure mode that produced the job timeouts this table exists - // to prevent. Scaled from the sibling's measured 87 s by analyze-run count. - // Replace with a real figure after the first green Windows matrix run. - 'test/unit/incremental-index-extension-dml-gate.test.ts': 180, + // Measured on Windows in run 34014266125 (#3190, 2026-09-06). These DB + // suites landed together on shard 3: the old 180s estimate and missing + // entries made a ~27-minute recorded load look like an ~12-minute shard. + // Upstream speedups may reduce these figures; retaining conservative weights + // keeps the expensive suites distributed without changing the watchdog. + 'test/unit/incremental-index-extension-dml-gate.test.ts': 414, + 'test/integration/skills-e2e.test.ts': 444, + 'test/integration/fts-extension-e2e.test.ts': 146, + 'test/integration/analyze-wal-checkpoint-failure.test.ts': 86, 'test/integration/cli-limit-e2e.test.ts': 75, 'test/unit/hooks.test.ts': 26, 'test/integration/analyze-heap-oom-e2e.test.ts': 23, diff --git a/gitnexus/src/core/graph/edge-reasons.ts b/gitnexus/src/core/graph/edge-reasons.ts new file mode 100644 index 000000000..ad31db850 --- /dev/null +++ b/gitnexus/src/core/graph/edge-reasons.ts @@ -0,0 +1,38 @@ +/** + * Edge `reason` values that mark a CALLS edge as a HEURISTIC GUESS rather than + * a resolution. + * + * The distinction exists because an edge's `confidence` number cannot carry it. + * `GLOBAL_NAME_FALLBACK_REASON` edges are emitted at exactly 0.5 — the same + * number as `process-processor`'s `MIN_TRACE_CONFIDENCE` and + * `community-processor`'s `MIN_CONFIDENCE_LARGE` — so a `confidence < 0.5` + * gate does NOT exclude them. Anything that must exclude guesses has to read + * the reason, which is why `KnowledgeGraph.forEachRelationshipFields` passes it + * and why `GraphEmitSink` retains a reason column. + */ + +/** + * The target was chosen because its SIMPLE NAME is unique in the workspace — + * not because any import, scope chain, or type binding led to it. + * + * Emitted by the unique-simple-name tiers of the free-call fallback + * (`pickUniqueGlobalCallable` and constructor-form `pickUniqueGlobalClass`), + * and only for the languages that opt into `allowGlobalFreeCallFallback`. It + * is a name collision away from being wrong and must never be presented as + * an import-resolved edge: a reader who cannot tell the two apart has no way + * to discount the guess. + */ +export const GLOBAL_NAME_FALLBACK_REASON = 'global-name-fallback'; + +/** + * Reasons excluded from process tracing and large-graph community detection. + * + * Both walks exist to describe how the program actually flows. Seeding a flow + * from a unique-name guess produces a confident-looking trace through code that + * may never call each other, which is worse than a shorter honest trace. + */ +const HEURISTIC_EDGE_REASONS: ReadonlySet = new Set([GLOBAL_NAME_FALLBACK_REASON]); + +/** True when this edge's target was guessed by name rather than resolved. */ +export const isHeuristicEdgeReason = (reason: string): boolean => + HEURISTIC_EDGE_REASONS.has(reason); diff --git a/gitnexus/src/core/graph/graph.ts b/gitnexus/src/core/graph/graph.ts index 1c708a4af..84323bf8e 100644 --- a/gitnexus/src/core/graph/graph.ts +++ b/gitnexus/src/core/graph/graph.ts @@ -163,9 +163,17 @@ export const createKnowledgeGraph = (): KnowledgeGraph => { relationshipMap.forEach(fn); }, forEachRelationshipFields( - fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void, + fn: ( + sourceId: string, + targetId: string, + type: RelationshipType, + confidence: number, + reason: string, + ) => void, ) { - relationshipMap.forEach((rel) => fn(rel.sourceId, rel.targetId, rel.type, rel.confidence)); + relationshipMap.forEach((rel) => + fn(rel.sourceId, rel.targetId, rel.type, rel.confidence, rel.reason), + ); }, getNode: (id: string) => nodeMap.get(id), diff --git a/gitnexus/src/core/graph/types.ts b/gitnexus/src/core/graph/types.ts index 9d9caf12b..a7a7e8a04 100644 --- a/gitnexus/src/core/graph/types.ts +++ b/gitnexus/src/core/graph/types.ts @@ -31,14 +31,26 @@ export interface KnowledgeGraph { * Zero-allocation relationship scan: fields, not objects (#2680). * * The whole-graph scans (the local-symbol pruner, community detection, - * process extraction) read only these four fields, and materializing a + * process extraction) read only these five fields, and materializing a * `GraphRelationship` per edge just to read them dominates iteration cost once * relationships are held columnar — measured at ~90 ms per analyze on a * million-edge graph. Prefer this over `forEachRelationship` in any pass that * walks every edge and needs no other field. + * + * `reason` is passed because confidence alone cannot separate a heuristic + * name guess from a resolved edge that happens to sit at the same number: + * the global-name fallback emits at exactly the process/community threshold + * (0.5), so the walks that must exclude it have to read the reason. See + * `GraphEmitSink`'s reason column, added for this consumer. */ forEachRelationshipFields: ( - fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void, + fn: ( + sourceId: string, + targetId: string, + type: RelationshipType, + confidence: number, + reason: string, + ) => void, ) => void; getNode: (id: string) => GraphNode | undefined; nodeCount: number; diff --git a/gitnexus/src/core/ingestion/finalize-orchestrator.ts b/gitnexus/src/core/ingestion/finalize-orchestrator.ts index 3146aff26..4a0e99f6b 100644 --- a/gitnexus/src/core/ingestion/finalize-orchestrator.ts +++ b/gitnexus/src/core/ingestion/finalize-orchestrator.ts @@ -200,8 +200,11 @@ function collectReferenceSites(parsedFiles: readonly ParsedFile[]) { */ function withDefaultHooks(partial: Partial): FinalizeHooks { return { + importsBindAtLexicalScope: partial.importsBindAtLexicalScope === true, resolveImportTarget: partial.resolveImportTarget ?? (() => null), isNamespaceImport: partial.isNamespaceImport, + wildcardCollisionIsAmbiguous: partial.wildcardCollisionIsAmbiguous === true, + namedImportsBindTopLevelOnly: partial.namedImportsBindTopLevelOnly === true, expandsWildcardTo: partial.expandsWildcardTo ?? (() => []), mergeBindings: partial.mergeBindings ?? diff --git a/gitnexus/src/core/ingestion/languages/dart/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/dart/name-fallback-visibility.ts new file mode 100644 index 000000000..562311bb8 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/dart/name-fallback-visibility.ts @@ -0,0 +1,77 @@ +/** + * Dart's veto on the global-name fallback — see + * `ScopeResolver.isGlobalNameFallbackPlausible`. + * + * Dart's privacy is LIBRARY-scoped and marked in the identifier itself: a name + * beginning with `_` is visible only inside its own library and cannot be + * imported by any spelling. So a `_`-prefixed candidate in another file is an + * impossible call, not an unlikely one. + * + * The exact boundary is `part` / `part of` — one library spanning several + * files, with `_` names shared between them — and the extractor does not + * surface `part` directives yet (the Dart query captures only `library_import`; + * nothing in `languages/dart/` reads `part`). Without them a cross-file `_` + * candidate is UNDECIDABLE, not impossible: refusing on "different file" would + * delete real edges on Flutter's dominant generated-code idiom (`factory + * Foo.fromJson(j) => _$FooFromJson(j)` calls into `foo.g.dart`, a `part` beside + * it), and refusing on "different directory" is wrong too — a `part` URI is a + * relative URI and legally traverses directories (`part '../shared/gen.dart';`). + * An earlier version refused the cross-directory case as "no `part` layout can + * make this legal"; that claim was false, so the hook now REFUSES NOTHING and + * every cross-file `_` candidate stays a LABELED edge (0.5 / + * `global-name-fallback`), which is the honest answer until `part` is + * extracted. A caller that names the candidate's file in a directive is + * recognized already, for the day the extractor surfaces `part` as an import + * target; at that point "not the same library" becomes decidable and the + * refusal can return. + * + * Public names are left to the labeled-edge path. Dart does require an import + * for a cross-library public name, but the fallback exists partly to recover + * edges where the import chain was not reconstructed, and refusing every + * cross-file public call would delete real edges to buy a rule the `_` marker + * already gives for free. + */ + +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { + modulePathReaches, + stripExtension, +} from '../../scope-resolution/utils/name-fallback-visibility.js'; + +/** Dart privacy marker: a leading underscore on the declared identifier. Read + * from the last `qualifiedName` segment, so `_Foo.bar` is public `bar` on a + * private class and `Foo._bar` is the private member. */ +function isPrivateDartName(candidate: SymbolDefinition): boolean { + const qualified = candidate.qualifiedName ?? ''; + const dot = qualified.lastIndexOf('.'); + const simple = dot === -1 ? qualified : qualified.slice(dot + 1); + return simple.startsWith('_'); +} + +/** + * Are these two files parts of one library? True when the caller names the + * candidate's file in a `part` / `part of` directive, which the extractor + * surfaces as an ordinary import target. + */ +function sharesLibrary(callerParsed: ParsedFile, candidateFilePath: string): boolean { + const candidateModule = stripExtension(candidateFilePath); + for (const imp of callerParsed.parsedImports) { + if (modulePathReaches(stripExtension(imp.targetRaw), candidateModule)) return true; + } + return false; +} + +export function dartIsGlobalNameFallbackPlausible(ctx: { + readonly callerParsed: ParsedFile; + readonly candidate: SymbolDefinition; +}): boolean { + if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true; + if (!isPrivateDartName(ctx.candidate)) return true; + // A directive naming the candidate's file is positive evidence of one library. + if (sharesLibrary(ctx.callerParsed, ctx.candidate.filePath)) return true; + // Any other file may be a `part` of the caller's library — a sibling or, via + // a relative `part` URI, a file in another directory. Undecidable without + // `part` extraction (see the header), so allowed as a labeled guess, never + // refused. + return true; +} diff --git a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts index 76bec5d74..bc46ad452 100644 --- a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts @@ -41,6 +41,7 @@ import { decodeMarker } from '../../utils/heritage-marker.js'; import { typeApplicationArguments } from '../../utils/template-arguments.js'; import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js'; import { expandDartWildcardNames } from './expand-wildcards.js'; +import { dartIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; interface ClassDefRef { readonly graphId: string; @@ -233,5 +234,6 @@ export const dartScopeResolver: ScopeResolver = { // No `new`: bare `Foo()` resolves to the type; with cross-file imports the // callee is reachable workspace-wide. allowGlobalFreeCallFallback: true, + isGlobalNameFallbackPlausible: dartIsGlobalNameFallbackPlausible, constructorCallTargetsClass: true, }; diff --git a/gitnexus/src/core/ingestion/languages/go/captures.ts b/gitnexus/src/core/ingestion/languages/go/captures.ts index c1a8cf23c..97fe71d83 100644 --- a/gitnexus/src/core/ingestion/languages/go/captures.ts +++ b/gitnexus/src/core/ingestion/languages/go/captures.ts @@ -508,11 +508,24 @@ function normalizeGenericConstructorCapture( if (referenceNode !== undefined && referenceNode.type === 'generic_type') { const base = referenceNode.childForFieldName('type'); if (base !== null) { - grouped['@reference.name'] = syntheticCapture( - '@reference.name', - base, - extractSimpleTypeNameText(base), - ); + const simple = extractSimpleTypeNameText(base); + // Keep the written `pkg.Box[T]` spelling so constructor resolution + // can tell a package-qualified type from a bare unique-name guess. + // `Box[T]` is also `text !== simple` (`Box[T]` vs `Box`) — that is + // not a qualifier. Only a `qualified_type` base (`pkg.Box`) is. + if ( + grouped['@reference.qualified-name'] === undefined && + base.type === 'qualified_type' && + referenceNode.text.length > 0 && + referenceNode.text !== simple + ) { + grouped['@reference.qualified-name'] = syntheticCapture( + '@reference.qualified-name', + referenceNode, + referenceNode.text, + ); + } + grouped['@reference.name'] = syntheticCapture('@reference.name', base, simple); } } } diff --git a/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts new file mode 100644 index 000000000..02b968e7b --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts @@ -0,0 +1,126 @@ +/** + * Go's veto on the global-name fallback — see + * `ScopeResolver.isGlobalNameFallbackPlausible`. + * + * Go's visibility rules are unusually decidable from a file path and an + * identifier, which is why this is the language the guard is most complete for: + * + * 1. A package IS a directory. Two files in the same directory see each + * other's identifiers with no import and no qualification, so a same- + * directory candidate is always plausible. + * 2. An identifier is exported iff it begins with an upper-case letter. An + * UNEXPORTED identifier is invisible outside its own package — no import + * makes it reachable, so a cross-directory candidate with a lower-case + * initial is not unlikely, it is IMPOSSIBLE. + * 3. A bare exported identifier from another package requires a DOT import. + * Ordinary, alias, and blank imports never introduce a bare callable. + * + * Rule 2 is the one that matters most in practice: `uniqueHelperXyz` defined + * once in package `a` used to acquire a caller in package `b` purely because + * the name was unique in the repo, and the resulting edge was published as + * `import-resolved` — a caller Go itself would reject. + * + * Methods require a receiver and never qualify for this bare-name tier. + */ + +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { + modulePathReaches, + directoryOf, +} from '../../scope-resolution/utils/name-fallback-visibility.js'; +import { inferGoPackageName } from './package-clause.js'; + +/** + * Compare actual package clauses, not a guessed `_test` suffix convention: + * `foo_test` can itself be a production package name. Missing source remains + * undecidable, while the test-file boundary is always available from the path. + */ +function classifyGoFile( + filePath: string, + sourceTextOf: ((p: string) => string | undefined) | undefined, +): { isTest: boolean; declared: string | undefined } { + const isTest = filePath.endsWith('_test.go'); + const text = sourceTextOf?.(filePath); + return { + isTest, + declared: text === undefined ? undefined : (inferGoPackageName(text) ?? undefined), + }; +} + +/** Go export rule: an upper-case initial, by Unicode letter case. */ +function isExportedGoName(name: string): boolean { + return /^\p{Lu}/u.test(name); +} + +/** + * The simple identifier a Go declaration contributes to its package scope: the + * last segment of `qualifiedName`, which is `Type.method` for a method and the + * bare identifier for a function. Either way the LAST segment is the identifier + * whose case decides export. + */ +function goSimpleName(candidate: SymbolDefinition): string { + const qualified = candidate.qualifiedName ?? ''; + const dot = qualified.lastIndexOf('.'); + return dot === -1 ? qualified : qualified.slice(dot + 1); +} + +export function goIsGlobalNameFallbackPlausible(ctx: { + readonly sourceTextOf?: (filePath: string) => string | undefined; + readonly callerParsed: ParsedFile; + readonly candidate: SymbolDefinition; + readonly site?: { readonly rawQualifiedName?: string }; +}): boolean { + // A PATH-QUALIFIED call (`pkg.Export()`) reached this tier because the + // qualifier could not be followed. Qualification removes the bare-name + // "only a dot import introduces this identifier" rule — not export or + // test-package visibility. `other.private()` is still impossible. + const isPackageQualified = ctx.site?.rawQualifiedName !== undefined; + // Methods require a receiver, even within their own package or a dot import. + if (ctx.candidate.type === 'Method' || ctx.candidate.qualifiedName?.includes('.')) return false; + const callerDir = directoryOf(ctx.callerParsed.filePath); + const candidateDir = directoryOf(ctx.candidate.filePath); + // Same directory is NOT the same package (rule 1, refined): a `_test.go` + // file may declare `foo_test`, and test-only declarations are invisible to + // non-test files. Apply the split `package-siblings.ts` uses for the + // confident tier, so the heuristic tier cannot reopen what it closed. + if (callerDir === candidateDir) { + const caller = classifyGoFile(ctx.callerParsed.filePath, ctx.sourceTextOf); + const cand = classifyGoFile(ctx.candidate.filePath, ctx.sourceTextOf); + // Non-test files never see test-only declarations. + if (cand.isTest && !caller.isTest) return false; + // Different clauses require an explicit dot import, even in one directory. + // Fall through to the exported/import checks for external test packages. + // Missing source leaves package identity undecidable. + if ( + caller.declared === undefined || + cand.declared === undefined || + caller.declared === cand.declared + ) + return true; + } + + // Different package — and a `_test.go` file's + // declarations are compiled only into ITS OWN package's test binary. No other + // package, test or not, can see them, exported or not. Decidable from the + // path alone, so it comes before every exception below (the module-root + // exception in particular used to accept a root `helper_test.go` export). + if (classifyGoFile(ctx.candidate.filePath, ctx.sourceTextOf).isTest) return false; + + const simpleName = goSimpleName(ctx.candidate); + // No identifier to read the case of — an unanswered question, not a refusal. + if (simpleName === '') return true; + // Unexported across a package boundary (rule 2): no import can reach it. + if (!isExportedGoName(simpleName)) return false; + if (isPackageQualified) return true; + + // Only a dot import introduces a bare name. A candidate in the module ROOT package has an empty + // directory, which `modulePathReaches` cannot align against any import path + // (the root package is imported by the module path alone, which the repo- + // relative layout does not carry). That is an unanswered question, not a + // refusal — a dot import is plausible even if its path cannot be aligned. + return ctx.callerParsed.parsedImports.some( + (imp) => + imp.kind === 'wildcard' && + (candidateDir === '' || modulePathReaches(imp.targetRaw, candidateDir)), + ); +} diff --git a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts index ff891d079..7c03e10f6 100644 --- a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts @@ -16,6 +16,7 @@ import { detectGoInterfaceImplementations } from './interface-impls.js'; import { populateGoRangeBindings } from './range-binding.js'; import { expandGoWildcardNames } from './expand-wildcards.js'; import { goMapValueType } from './interpret.js'; +import { goIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; /** Slice `[]T` and array `[N]T` / `[...]T` → the element spelling. Hoisted — * a literal inside the hook would mint a fresh RegExp per folded subscript. */ @@ -85,6 +86,7 @@ export const goScopeResolver: ScopeResolver = { hoistTypeBindingsToModule: true, propagatesReturnTypesAcrossImports: true, allowGlobalFreeCallFallback: true, + isGlobalNameFallbackPlausible: goIsGlobalNameFallbackPlausible, populateNamespaceSiblings: populateGoPackageSiblings, mirrorNamespaceTypeBindings: mirrorGoNamespaceTypeBindings, diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 5d31aed4a..b227d549c 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -357,6 +357,26 @@ export function emitJavaScopeCaptures( } } + // Qualified constructor `new pkg.Foo()` already binds the simple tail as + // `@reference.name` (F35). Copy the scoped spelling onto + // `@reference.qualified-name` so constructor resolution can treat it as a + // written qualifier instead of a bare unique-name guess (C# does the same). + const ctorQualifiedNode = nodeMap['@reference.call.constructor.qualified']; + const ctorQualifiedText = grouped['@reference.call.constructor.qualified']?.text; + if ( + ctorQualifiedNode !== undefined && + ctorQualifiedText !== undefined && + ctorQualifiedText.length > 0 && + ctorQualifiedText !== grouped['@reference.name']?.text && + grouped['@reference.qualified-name'] === undefined + ) { + grouped['@reference.qualified-name'] = syntheticCapture( + '@reference.qualified-name', + ctorQualifiedNode, + ctorQualifiedText, + ); + } + // Synthesize `@reference.arity` on every callsite. const callTag = ( ['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const diff --git a/gitnexus/src/core/ingestion/languages/javascript/captures.ts b/gitnexus/src/core/ingestion/languages/javascript/captures.ts index d3ba94791..b16cb2ba1 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/captures.ts @@ -36,6 +36,7 @@ import { syntheticCapture, type SyntaxNode, } from '../../utils/ast-helpers.js'; +import { collectEsmExportEvidence, esmExportVerdict } from '../../ts-js-export-marker.js'; import { splitImportStatement } from '../typescript/import-decomposer.js'; import { getJsParser, getJsScopeQuery, jsCachedTreeMatchesGrammar } from './query.js'; import { computeTsArityMetadata } from '../typescript/arity-metadata.js'; @@ -983,6 +984,8 @@ export function emitJsScopeCaptures( } const rawMatches = getJsScopeQuery(filePath).matches(tree.rootNode); + // Export evidence, read once per file (see `ts-js-export-marker.ts`). + const exportEvidence = collectEsmExportEvidence(tree.rootNode, filePath); const out: CaptureMatch[] = []; for (const m of rawMatches) { @@ -1124,6 +1127,13 @@ export function emitJsScopeCaptures( fnNode, deriveDefaultExportHocName(filePath), ); + // This declaration's name is synthetic, so the later query-name + // marker cannot see it. The HOC predicate already proves the export. + grouped['@declaration.is-exported'] = syntheticCapture( + '@declaration.is-exported', + fnNode, + 'true', + ); } } @@ -1207,6 +1217,20 @@ export function emitJsScopeCaptures( // non-call match, an absent receiver, or a chain with no nameable base // all leave `grouped` untouched. synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']); + // `@declaration.is-exported`: a verdict for every declaration the file's + // export surface can decide (see `ts-js-export-marker.ts`); nothing where + // it cannot, because absence is the honest answer there. + const declNameNode = groupedNodes['@declaration.name']; + if (exportEvidence !== undefined && declNameNode !== undefined) { + const verdict = esmExportVerdict(declNameNode, exportEvidence); + if (verdict !== undefined) { + grouped['@declaration.is-exported'] = syntheticCapture( + '@declaration.is-exported', + declNameNode, + verdict ? 'true' : 'false', + ); + } + } out.push(grouped); // Synthesize `this` receiver type-bindings on class member functions. diff --git a/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts index 8d74798ee..280fd9929 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts @@ -97,6 +97,12 @@ const javascriptScopeResolver: ScopeResolver = { // explicit imports at the call site. Workspace-wide unique-name fallback // recovers these edges. allowGlobalFreeCallFallback: true, + + // Same ECMAScript `export *` exclusivity as TypeScript: a name declared by + // two wildcard sources is refused, not guessed. + exclusiveWildcardReexports: true, + // Same ECMAScript rule as TypeScript: named imports bind module-level declarations only. + namedImportsBindTopLevelOnly: true, }; export { javascriptScopeResolver }; diff --git a/gitnexus/src/core/ingestion/languages/ruby/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/ruby/name-fallback-visibility.ts new file mode 100644 index 000000000..4b225c96f --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/name-fallback-visibility.ts @@ -0,0 +1,196 @@ +/** + * Ruby's veto on the global-name fallback — see + * `ScopeResolver.isGlobalNameFallbackPlausible`. + * + * Ruby keeps the labeled fallback deliberately: with autoload (Rails' zeitwerk, + * `ActiveSupport::Dependencies`) a file genuinely can call a method whose + * defining file it never requires, so "no require" is NOT evidence of + * impossibility the way it is in Go or Rust. + * + * What IS decidable is namespacing — but only for CLASS bodies. A method + * defined inside a `class` is not callable as a bare `helper()` from another + * file unless that file names the class somehow (a receiver `Ns.helper`, a + * subclass declaration, an `include`, a `require`). A method defined inside a + * `module` body is different in kind: modules exist to be mixed in, and Rails + * mixes them in for you — every `*Helper` module is included into views and + * controllers by the framework, and concerns arrive through `included do` + * hooks — so a bare `format_money()` in a view legitimately reaches + * `module ApplicationHelper` with no `include`, `require`, or constant + * anywhere in the caller. Refusing that would delete real edges on exactly the + * codebase shape Ruby's fallback exists to serve. So module-owned methods stay + * a LABELED guess, and the refusal targets CLASS-owned methods whose class the + * caller never names. A top-level method (`ownerId === undefined`) is left + * alone, since that is the shape autoload actually delivers. When the owner + * cannot be found or typed (no `parsedFileOf`, owner outside the file set), + * the question is unanswered and the edge is allowed. + * + * "Never names" is read from the caller's own text-visible signals — its + * `require`/`include`/`extend` targets, and any reference site spelling the + * namespace's constant. If any of them mentions the namespace, the call is + * plausible and the labeled edge stands. `require` paths are snake_case + * (`billing/invoice_service`) while constants are CamelCase + * (`Billing::InvoiceService`), so the comparison normalizes both sides — + * without that the `require` branch never matched anything. + * + * One more thing a caller file can do without naming the class: INHERIT its + * way to it. `class UsersController < AdminController` reaches every method + * `ApplicationController` defines while naming only `AdminController`, and an + * `include Concern` reaches whatever that concern includes in turn. Neither + * chain is decidable from one file, so a caller with ANY inheritance or mixin + * surface (an `inherits` site, an `include`/`extend`/`prepend` marker) is + * treated as plausible. So is a caller file that DEFINES a module: a module's + * methods run against whatever class includes the module, and call that class's + * methods bare — `module PostGuardian; def can_see?; is_staff? ...` reaches + * `class Guardian#is_staff?` because Guardian includes PostGuardian, a fact the + * module file never states. What remains refused is the shape Ruby itself + * rejects: a bare call to a class's method from a file that inherits nothing, + * mixes in nothing, defines no module, and never spells the class. Measured on + * discourse@3f71fa15c that is ~1000 refusals, and the sample is what the rule + * promises: RSpec `before`/`after`/`subject` guessed to serializer methods of + * the same name, `Gemfile`'s `gem` to `Plugin::Instance#gem`, `routes.rb`'s + * `get` to `Draft#get` — fabricated callers, every one. + */ + +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { moduleSegments } from '../../scope-resolution/utils/name-fallback-visibility.js'; +import { HERITAGE_MARKER_PREFIX } from '../../utils/heritage-marker.js'; + +/** + * The namespace segments a candidate's qualified name declares, minus the + * method itself. `Billing::Invoice#total` / `Billing.Invoice.total` → the + * `Billing`, `Invoice` constants a caller would have to name. + */ +function namespaceConstantsOf(candidate: SymbolDefinition): readonly string[] { + const qualified = candidate.qualifiedName; + if (qualified === undefined || qualified === '') return []; + const segments = qualified + .split(/::|\.|#/) + .map((s) => s.trim()) + .filter((s) => s !== ''); + // Drop the trailing method name; what remains is the namespace chain. + const namespace = segments.slice(0, -1); + // Only CONSTANTS name a Ruby namespace (upper-case initial). A lower-case + // segment is a receiver expression, not a namespace a caller can mention. + return namespace.filter((s) => /^[A-Z]/.test(s)); +} + +/** + * `billing/invoice_service` vs `InvoiceService`: a path segment names a + * constant when, with underscores removed, the two are equal case-insensitively. + * Zeitwerk's own inflection rule, minus acronym overrides — over-matching here + * only loses a refusal (the edge stays, labeled), which is the safe direction. + */ +function pathSegmentNamesConstant(segment: string, constant: string): boolean { + return segment.replace(/_/g, '').toLowerCase() === constant.toLowerCase(); +} + +function requireReachesConstant(targetRaw: string, constant: string): boolean { + for (const segment of moduleSegments(targetRaw)) { + if (pathSegmentNamesConstant(segment, constant)) return true; + } + return false; +} + +/** + * The declared kind of the candidate's owner, read from the candidate's own + * file. Ruby labels `class` bodies `Class` and `module` bodies `Trait` + * (`query.ts`: "module (labeled Trait for class-like registry lookup)"). + * `undefined` when the owner cannot be found — an unanswered question. + */ +function ownerLabelOf( + candidate: SymbolDefinition, + parsedFileOf: ((filePath: string) => ParsedFile | undefined) | undefined, +): string | undefined { + const ownerId = candidate.ownerId; + if (ownerId === undefined || parsedFileOf === undefined) return undefined; + const owner = parsedFileOf(candidate.filePath)?.localDefs.find((d) => d.nodeId === ownerId); + return owner?.type; +} + +/** + * Calls that rebind `self` for the duration of a block: inside + * `service.instance_eval do … end` a bare `helper()` is dispatched on + * `service`, so it legitimately reaches a class-owned method the caller file + * never names. Detected on the caller's SOURCE TEXT, not the call site — the + * site does not know which block encloses it — so any file that uses one of + * these forms keeps its class-owned guesses LABELED rather than refused. Coarse + * in the safe direction: it loses refusals in that file, never an edge. + */ +const SELF_REBINDING_CALL = + /\b(?:instance_eval|instance_exec|class_eval|class_exec|module_eval|module_exec)\b/; + +export function rubyIsGlobalNameFallbackPlausible(ctx: { + readonly callerParsed: ParsedFile; + readonly candidate: SymbolDefinition; + readonly parsedFileOf?: (filePath: string) => ParsedFile | undefined; + readonly sourceTextOf?: (filePath: string) => string | undefined; +}): boolean { + if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true; + // Top-level method — the autoload shape the fallback exists for. + if (ctx.candidate.ownerId === undefined) return true; + // A self-rebinding block anywhere in the caller makes "the class is never + // named here" no proof of impossibility (see `SELF_REBINDING_CALL`). The + // pipeline always supplies the source; a missing text is an unanswered + // question and keeps the labeled edge as well. + const text = ctx.sourceTextOf?.(ctx.callerParsed.filePath); + if (text === undefined || SELF_REBINDING_CALL.test(text)) return true; + // Only a CLASS body makes a bare cross-file call impossible without naming + // it (see the header). A module owner, or an owner we cannot type, is not a + // refusal. + if (ownerLabelOf(ctx.candidate, ctx.parsedFileOf) !== 'Class') return true; + + const constants = namespaceConstantsOf(ctx.candidate); + // Owned but with no nameable namespace (an anonymous or lower-cased owner): + // nothing to check, so do not refuse on an unanswered question. + if (constants.length === 0) return true; + + // Any inheritance or mixin surface in the caller can reach the class + // transitively (see the header) — not decidable here, so not refused. + for (const site of ctx.callerParsed.referenceSites) { + if (site.kind === 'inherits') return true; + } + for (const imp of ctx.callerParsed.parsedImports) { + if (imp.targetRaw.startsWith(HERITAGE_MARKER_PREFIX)) return true; + } + // A file that defines a module is a mixin whose methods run inside some + // including class (see the header). Ruby labels `module` bodies `Trait`. + for (const def of ctx.callerParsed.localDefs) { + if (def.type === 'Trait') return true; + // A file that REOPENS `class Invoice` names the owner even without an + // inherits site or a reference capture — the declaration itself is the + // mention. + if (def.type === 'Class') { + const declared = (def.qualifiedName ?? '') + .split(/::|\.|#/) + .map((s) => s.trim()) + .filter((s) => s !== '') + .at(-1); + if (declared !== undefined && constants.includes(declared)) return true; + } + } + + for (const imp of ctx.callerParsed.parsedImports) { + for (const constant of constants) { + if (requireReachesConstant(imp.targetRaw, constant)) return true; + // `include Billing::Invoice` arrives as an import whose LOCAL name is the + // constant rather than a path. Not every variant carries one. + if ('localName' in imp && imp.localName === constant) return true; + } + } + // A bare mention of the constant anywhere in the caller (`Billing::Invoice`, + // `Invoice.new`) is enough to make the namespace present in this file. The + // qualified spelling is matched SEGMENT-wise on `::` / `.`: `InvoiceService` + // is not a mention of `Invoice`, and a substring test made it one. + for (const site of ctx.callerParsed.referenceSites) { + for (const constant of constants) { + if (site.name === constant) return true; + if ( + site.rawQualifiedName !== undefined && + site.rawQualifiedName.split(/::|\./).some((segment) => segment === constant) + ) { + return true; + } + } + } + return false; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts index 160bec94e..088651a81 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts @@ -10,6 +10,7 @@ import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-l import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; import { decodeMarker } from '../../utils/heritage-marker.js'; +import { rubyIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; /** * #1991: resolve a BARE mixin reference (`include Loggable`) to a nested module by @@ -287,4 +288,5 @@ export const rubyScopeResolver: ScopeResolver = { fieldFallbackOnMethodLookup: true, propagatesReturnTypesAcrossImports: true, allowGlobalFreeCallFallback: true, + isGlobalNameFallbackPlausible: rubyIsGlobalNameFallbackPlausible, }; diff --git a/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts new file mode 100644 index 000000000..1afdb900f --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts @@ -0,0 +1,183 @@ +/** + * Rust's veto on the global-name fallback — see + * `ScopeResolver.isGlobalNameFallbackPlausible`. + * + * Rust has NO ambient namespace. A bare `helper()` resolves only against names + * in scope, and for an item declared in another module the only way in is a + * `use` path (or a fully-qualified `crate::a::b::helper()` call, which is not a + * bare free call and never reaches this tier — it carries a qualified name and + * is resolved earlier by `resolveQualifiedFreeCall`). + * + * This hook checks import evidence, not Rust item visibility. A child module + * can use private ancestor items, and visibility restrictions such as + * `pub(crate)` require more context than `SymbolDefinition` carries. A matching + * import therefore keeps a labeled guess rather than proving accessibility. + * + * Module paths are matched against the candidate's FILE path (extension + * stripped, and `mod`/`lib`/`main` stem dropped, since `a/b/mod.rs` IS module + * `a::b`). `crate::` names the root; `self::` and `super::` resolve relative + * to the caller's module before comparison. + */ + +import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import { + modulePathReaches, + stripExtension, +} from '../../scope-resolution/utils/name-fallback-visibility.js'; + +/** File-stems that name their PARENT directory as the module, not themselves. */ +const RUST_DIRECTORY_MODULE_STEMS: ReadonlySet = new Set(['mod', 'lib', 'main']); + +/** Directories that hold a crate's root and contribute no module segment, so + * `src/net/http.rs` is module `net::http` and not `src::net::http`. */ +const RUST_CRATE_ROOT_DIRS: ReadonlySet = new Set(['src', 'tests', 'benches', 'examples']); + +/** Path prefixes of a `use` that name a root rather than a module segment. */ +const RUST_USE_ROOT_PREFIXES: ReadonlySet = new Set(['crate', '$crate']); + +// One scope lookup per immutable parsed-file snapshot, not per fallback site. +// Weak keys release both the snapshot and its index at the end of ingestion. +const scopeLookupByFile = new WeakMap>(); + +/** + * The module path a Rust file provides, as a `/`-joined path. + * + * Two normalizations, both needed for a file path and a `use` path to line up + * on their trailing segments: the `mod`/`lib`/`main` stem names its parent + * directory, and a leading crate-root directory (`src/`) is not a module. + */ +function rustModulePathOf(filePath: string): string { + const withoutExtension = stripExtension(filePath); + const segments = withoutExtension.split('/').filter((s) => s !== ''); + const stem = segments[segments.length - 1]; + if (stem !== undefined && RUST_DIRECTORY_MODULE_STEMS.has(stem)) segments.pop(); + // Only the crate-root folder is non-semantic. A nested `src/` is module + // `src` (`src/src/helper.rs` → `crate::src::helper`), not another root. + if (segments.length > 0 && RUST_CRATE_ROOT_DIRS.has(segments[0]!)) segments.shift(); + return segments.join('/'); +} + +/** + * Directory that owns the crate-root folder (`src`/`tests`/…). Empty when the + * file sits at the analyzed root (`src/lib.rs`) so a single-crate tree is not + * refused on a missing workspace prefix. + */ +function rustCrateRootOf(filePath: string): string { + const segments = stripExtension(filePath) + .split('/') + .filter((s) => s !== ''); + const stem = segments[segments.length - 1]; + if (stem !== undefined && RUST_DIRECTORY_MODULE_STEMS.has(stem)) segments.pop(); + const rootIdx = segments.findIndex((s) => RUST_CRATE_ROOT_DIRS.has(s)); + if (rootIdx <= 0) return ''; + return segments.slice(0, rootIdx).join('/'); +} + +/** Resolve explicit relative prefixes against the caller's module path. */ +function rustUsePathOf(targetRaw: string, callerFilePath: string): string { + const segments = targetRaw.split('::').filter((s) => s !== ''); + if (segments[0] === 'self' || segments[0] === 'super') { + const base = rustModulePathOf(callerFilePath).split('/').filter(Boolean); + if (segments[0] === 'self') segments.shift(); + while (segments[0] === 'super') { + base.pop(); + segments.shift(); + } + return [...base, ...segments].join('::'); + } + while (segments.length > 0 && RUST_USE_ROOT_PREFIXES.has(segments[0]!)) segments.shift(); + return segments.join('::'); +} + +export function rustIsGlobalNameFallbackPlausible(ctx: { + readonly callerParsed: ParsedFile; + readonly candidate: SymbolDefinition; + readonly site: { + readonly name: string; + readonly rawQualifiedName?: string; + readonly inScope?: ScopeId; + }; +}): boolean { + if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true; + // A PATH-QUALIFIED call (`User::new(...)`, `crate::a::helper()`) reaches this + // tier when the qualifier could not be followed, carrying only its tail name. + // It is not a bare-name guess: the source named the path, so the module rule + // below would refuse an edge the code spells out. + if (ctx.site.rawQualifiedName !== undefined) return true; + + const candidateModule = rustModulePathOf(ctx.candidate.filePath); + // A candidate whose file maps to no module path (a crate root reduced to '') + // is not something this rule can speak about; allow the labeled edge rather + // than refuse on an unanswered question. + if (candidateModule === '') return true; + + const candidateName = rustSimpleNameOf(ctx.candidate); + // Imports are lexical evidence, not a file-wide allowlist. Legacy/synthetic + // imports without a scope receipt retain the previous conservative behavior. + let visibleScopes: Set | undefined; + if (ctx.site.inScope !== undefined) { + let scopes = scopeLookupByFile.get(ctx.callerParsed); + if (scopes === undefined) { + scopes = new Map(ctx.callerParsed.scopes.map((scope) => [scope.id, scope])); + scopeLookupByFile.set(ctx.callerParsed, scopes); + } + visibleScopes = new Set(); + let current: ScopeId | null = ctx.site.inScope; + while (current !== null && !visibleScopes.has(current)) { + visibleScopes.add(current); + const scope = scopes.get(current); + if (scope === undefined || scope.kind === 'Namespace') break; + current = scope.parent; + } + } + for (const imp of ctx.callerParsed.parsedImports) { + if ( + imp.declaredAtScope !== undefined && + visibleScopes !== undefined && + !visibleScopes.has(imp.declaredAtScope) + ) + continue; + // `crate::` is the caller's crate. A same trailing module in another + // workspace crate is a different item and cannot authorize the guess. + if (imp.targetRaw === 'crate' || imp.targetRaw.startsWith('crate::')) { + const callerRoot = rustCrateRootOf(ctx.callerParsed.filePath); + const candidateRoot = rustCrateRootOf(ctx.candidate.filePath); + if (callerRoot !== '' && candidateRoot !== '' && callerRoot !== candidateRoot) continue; + } + const usePath = rustUsePathOf(imp.targetRaw, ctx.callerParsed.filePath); + // Only a glob introduces every bare item of a module. A named import must + // match both the candidate's original name and the call's local spelling. + if (imp.kind === 'wildcard') { + if (modulePathReaches(usePath, candidateModule)) return true; + continue; + } + if (!('localName' in imp) || imp.localName !== ctx.site.name) continue; + // Otherwise the path names ONE item inside a module (`use crate::a::other`). + // Its PARENT is the candidate's module only if that item IS the candidate: + // importing `other` says nothing about a `helper` in `a`, and the bare + // parent-path match used to accept every item of `a` on its strength. + // An alias authorizes only the local spelling checked above. + if (importedNameOf(imp) !== candidateName) continue; + if (modulePathReaches(usePath, candidateModule)) return true; + const parent = usePath.slice(0, Math.max(0, usePath.lastIndexOf('::'))); + if (parent !== '' && modulePathReaches(parent, candidateModule)) return true; + } + return false; +} + +/** The identifier a `use` binds, as written at its source (`importedName`). */ +function importedNameOf(imp: ParsedFile['parsedImports'][number]): string | undefined { + return 'importedName' in imp ? imp.importedName : undefined; +} + +/** + * The identifier a Rust declaration contributes to its module: the FIRST + * segment of `qualifiedName` after any module prefix — `User` for `User.new` + * (an associated function is reached through its type, so it is the type the + * `use` must name), the bare name for a free function. + */ +function rustSimpleNameOf(candidate: SymbolDefinition): string { + const qualified = candidate.qualifiedName ?? ''; + const segments = qualified.split(/::|\./).filter((s) => s !== ''); + return segments[0] ?? ''; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts index bea53d9fd..71fb697db 100644 --- a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts @@ -19,6 +19,7 @@ import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-l import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js'; import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; +import { rustIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; /** * Emit Rust `S IMPLEMENTS T` edges from `impl T for S` trait implementations. @@ -192,4 +193,6 @@ export const rustScopeResolver: ScopeResolver = { hoistTypeBindingsToModule: true, propagatesReturnTypesAcrossImports: true, allowGlobalFreeCallFallback: true, + importsBindAtLexicalScope: true, + isGlobalNameFallbackPlausible: rustIsGlobalNameFallbackPlausible, }; diff --git a/gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts new file mode 100644 index 000000000..916465d94 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts @@ -0,0 +1,67 @@ +/** + * Swift's veto on the global-name fallback — see + * `ScopeResolver.isGlobalNameFallbackPlausible`. + * + * Swift's default access level is `internal`: visible throughout the MODULE and + * nowhere else. Swift needs no per-file import inside a module, which is why + * the global fallback is enabled for it at all — but that whole-module + * visibility stops hard at the module boundary. A candidate in a DIFFERENT + * module is reachable only if the caller wrote `import `, and even + * then only if the declaration is `public`. + * + * A module is approximated by its source directory, the layout every Swift + * package configuration discovers: `Sources//…` (also under `Package/`). + * `src//…` is also recognized by the package configuration loader. + * Files outside these layouts have unknown module identity. + * + * The `private` / `fileprivate` half of the rule is NOT implemented, because + * neither marker is recoverable from the parse model this hook sees — + * `SymbolDefinition` carries no access level and `ParsedFile` no modifiers. + * Those candidates keep the labeled low-confidence edge, which is the + * "cannot decide, so do not refuse" direction the hook contract asks for. + */ + +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { modulePathReaches } from '../../scope-resolution/utils/name-fallback-visibility.js'; + +/** Keep aligned with loadSwiftPackageConfig: other layouts are unconfigured. */ +const SWIFT_TARGET_ROOTS: ReadonlySet = new Set(['Sources', 'src']); + +/** + * The module (target) a Swift file belongs to. + * + * `Sources/Core/User.swift` → `Core`. Arbitrary Xcode folders are not module + * boundaries; without a recognized target layout leave the answer unknown. + */ +function swiftModuleOf(filePath: string): string { + const segments = filePath.split('/').filter((s) => s !== ''); + for (let i = 0; i < segments.length - 2; i++) { + if (SWIFT_TARGET_ROOTS.has(segments[i]!)) { + // Keep the path through the target so `Sources/Core` and + // `Package/Sources/Core` stay distinct modules. `import Core` still + // reaches either via trailing-segment containment. + return segments.slice(0, i + 2).join('/'); + } + } + return ''; +} + +export function swiftIsGlobalNameFallbackPlausible(ctx: { + readonly callerParsed: ParsedFile; + readonly candidate: SymbolDefinition; +}): boolean { + if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true; + + const callerModule = swiftModuleOf(ctx.callerParsed.filePath); + const candidateModule = swiftModuleOf(ctx.candidate.filePath); + // Same module: whole-module `internal` visibility, no import needed. + if (callerModule === candidateModule) return true; + // A file the layout heuristic cannot place is not something this rule can + // speak about — allow rather than refuse on an unanswered question. + if (callerModule === '' || candidateModule === '') return true; + + for (const imp of ctx.callerParsed.parsedImports) { + if (modulePathReaches(imp.targetRaw, candidateModule)) return true; + } + return false; +} diff --git a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts index 6d71117e4..83a8e6ce7 100644 --- a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts @@ -66,6 +66,7 @@ import { mirrorSwiftSiblingTypeBindings, type SwiftResolveContext, } from './index.js'; +import { swiftIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const; @@ -136,6 +137,7 @@ const swiftScopeResolver: ScopeResolver = { // global free-call fallback (as Python/Go/Ruby/COBOL do for the same // no-`new` constructor + cross-file free-call shape). allowGlobalFreeCallFallback: true, + isGlobalNameFallbackPlausible: swiftIsGlobalNameFallbackPlausible, // Swift's call graph models `Type(...)` as a reference to the type // itself, not its `init` — both the legacy DAG and this test suite link diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index 2e3a76290..955b70a25 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -32,6 +32,7 @@ import { syntheticCapture, type SyntaxNode, } from '../../utils/ast-helpers.js'; +import { collectEsmExportEvidence, esmExportVerdict } from '../../ts-js-export-marker.js'; import { splitImportStatement } from './import-decomposer.js'; import { getTsParser, getTsScopeQuery, tsCachedTreeMatchesGrammar } from './query.js'; import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; @@ -388,6 +389,8 @@ export function emitTsScopeCaptures( } const rawMatches = getTsScopeQuery(filePath).matches(tree.rootNode); + // Export evidence, read once per file (see `ts-js-export-marker.ts`). + const exportEvidence = collectEsmExportEvidence(tree.rootNode, filePath); const out: CaptureMatch[] = []; for (const m of rawMatches) { @@ -561,6 +564,13 @@ export function emitTsScopeCaptures( fnNode, deriveDefaultExportHocName(filePath), ); + // This declaration's name is synthetic, so the later query-name + // marker cannot see it. The HOC predicate already proves the export. + grouped['@declaration.is-exported'] = syntheticCapture( + '@declaration.is-exported', + fnNode, + 'true', + ); } } @@ -660,6 +670,20 @@ export function emitTsScopeCaptures( // instead of re-parsing the receiver's source text. Self-gating: a // non-call match, an absent receiver, or a chain with no nameable base // all leave `grouped` untouched. + // `@declaration.is-exported`: a verdict for every declaration the file's + // export surface can decide (see `ts-js-export-marker.ts`); nothing where + // it cannot, because absence is the honest answer there. + const declNameNode = groupedNodes['@declaration.name']; + if (exportEvidence !== undefined && declNameNode !== undefined) { + const verdict = esmExportVerdict(declNameNode, exportEvidence); + if (verdict !== undefined) { + grouped['@declaration.is-exported'] = syntheticCapture( + '@declaration.is-exported', + declNameNode, + verdict ? 'true' : 'false', + ); + } + } synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']); out.push(grouped); diff --git a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts index bc2af4bcb..b0d12a3e8 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts @@ -132,6 +132,15 @@ const typescriptScopeResolver: ScopeResolver = { fieldFallbackOnMethodLookup: false, propagatesReturnTypesAcrossImports: true, + // ECMAScript: `export * from './a'; export * from './b'` with a name declared + // in both exports NEITHER — the finalize pass refuses the binding instead of + // taking the first-listed source (see `exclusiveWildcardReexports`). + exclusiveWildcardReexports: true, + // `import { x }` never reaches a class member: named imports and named + // re-exports bind to module-level declarations only (a class method sharing + // a name with a top-level value must not win the callable preference). + namedImportsBindTopLevelOnly: true, + // TypeScript uses `.values()` / `.keys()` method-call syntax for collection // views -- no property-style accessors like C#'s `Dictionary.Values` -- // so `elementTypeOf` answers only the `index` route and lets the regular diff --git a/gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts index d2f37781f..d0b0cfaed 100644 --- a/gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts @@ -117,6 +117,9 @@ const vueScopeResolver: ScopeResolver = { // Vue uses explicit imports for all external symbols; no global free- // call fallback needed (would produce spurious edges for built-ins). allowGlobalFreeCallFallback: false, + // Vue SFC scripts are TypeScript/JavaScript: a named import binds a module-level + // declaration, never a class member (see the TS resolver). + namedImportsBindTopLevelOnly: true, /** * Expand the scope-resolution file universe for Vue by performing a diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 8ee94e265..d81b7116e 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -16,6 +16,8 @@ */ import { createKnowledgeGraph } from '../graph/graph.js'; +import type { KnowledgeGraph } from '../graph/types.js'; +import { GLOBAL_NAME_FALLBACK_REASON } from '../graph/edge-reasons.js'; import { GraphEmitSink, type GraphEmitManifest } from '../lbug/graph-emit-sink.js'; import { type PipelineProgress } from 'gitnexus-shared'; import { PipelineResult } from '../../types/pipeline.js'; @@ -410,6 +412,11 @@ export const runPipelineFromRepo = async ( graphEmitSink?.close(); } + // Resolved-call index for the name-fallback census: read through the SINK, + // whose field-wise scan includes every streamed edge, not through `graph`, + // which under streaming holds none of them. + const resolvedCalleeNamesByCaller = collectResolvedCalleeNames(graphEmitSink ?? graph, graph); + // Extract final results for the PipelineResult contract const { totalFiles, @@ -473,6 +480,7 @@ export const runPipelineFromRepo = async ( communityResult, processResult, resolutionOutcomes, + resolvedCalleeNamesByCaller, undecidedSatisfaction, usedWorkerPool, reparsedFileCount, @@ -518,3 +526,29 @@ export const runPipelineFromRepo = async ( return result; }; + +/** + * Caller node id → the simple names of every callee it has a CALLS edge to. + * + * `edges` may be the streaming sink or the raw graph; `nodes` is always the raw + * graph, which holds every node in both modes (only relationships stream). One + * O(E) field-wise pass, allocation-free per edge except for the per-caller set. + */ +export function collectResolvedCalleeNames( + edges: Pick, + nodes: Pick, +): ReadonlyMap> { + const out = new Map>(); + edges.forEachRelationshipFields((sourceId, targetId, type, _confidence, reason) => { + if (type !== 'CALLS' || reason === GLOBAL_NAME_FALLBACK_REASON) return; + const name = nodes.getNode(targetId)?.properties.name; + if (typeof name !== 'string' || name === '') return; + let names = out.get(sourceId); + if (names === undefined) { + names = new Set(); + out.set(sourceId, names); + } + names.add(name); + }); + return out; +} diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index d8dbd42dd..898282b5e 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -707,6 +707,10 @@ function buildDefFromDeclarationMatch( const isExplicit = parseBooleanCapture(match['@declaration.is-explicit']); const isDeleted = parseBooleanCapture(match['@declaration.is-deleted']); const isSynthetic = parseBooleanCapture(match['@declaration.is-synthetic']); + // Tri-state on purpose: only a producer that saw the file's export surface + // emits the marker, and both `true` and `false` are verdicts (see + // `SymbolDefinition.isExported`). Absent stays absent. + const isExported = parseBooleanCapture(match['@declaration.is-exported']); return { nodeId: makeDefId(filePath, anchor.range, type, nameCap.text), @@ -725,6 +729,7 @@ function buildDefFromDeclarationMatch( ...(isExplicit === true ? { isExplicit: true } : {}), ...(isDeleted === true ? { isDeleted: true } : {}), ...(isSynthetic === true ? { isSynthetic: true } : {}), + ...(isExported !== undefined ? { isExported } : {}), }; } @@ -1037,8 +1042,7 @@ function pass3CollectImports( if (provider.interpretImport === undefined) return; // Hoisted: the capability is a property of the language, identical for every // match in the file. A provider that declares its imports do not execute - // where they are written (C/C++ `#include`, Rust `use`, COBOL `COPY`) skips - // the position walk entirely — position cannot defer something that never + // where they are written skips the execution-deferral walk — position cannot defer something that never // runs, and marking one deferred would hide a real cycle. Absent reads as // `true`, so an undeclared provider is unchanged. See // `LanguageProvider.importsExecuteWhereWritten`. @@ -1049,14 +1053,22 @@ function pass3CollectImports( const parsed = provider.interpretImport(match); if (parsed === null) continue; // The statement's own position, resolved to the innermost scope holding - // it. An unlocatable anchor leaves the import unmarked, which reads as + // it. Provenance is retained independently of execution timing. An + // unlocatable anchor leaves the import unmarked, which reads as // "runs at initialization" — the fail-safe direction, since it can only // make `check --cycles` over-report. - const inScopeId = positionCanDefer - ? positionIndex.atPosition(filePath, anchor.range.startLine, anchor.range.startCol) - : undefined; - const deferred = inScopeId !== undefined && runsOnlyWhenCalled(scopeTree, inScopeId); - parsedImports.push(deferred ? { ...parsed, runsOnlyWhenCalled: true } : parsed); + const inScopeId = positionIndex.atPosition( + filePath, + anchor.range.startLine, + anchor.range.startCol, + ); + const deferred = + positionCanDefer && inScopeId !== undefined && runsOnlyWhenCalled(scopeTree, inScopeId); + parsedImports.push({ + ...parsed, + ...(inScopeId !== undefined ? { declaredAtScope: inScopeId } : {}), + ...(deferred ? { runsOnlyWhenCalled: true } : {}), + }); } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index e364b0227..d6da6d4d9 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -82,13 +82,14 @@ * `handledSites` IFF a `tryEmitEdge` call returned `true` for it. * Sites a pass touched but couldn't resolve do NOT get marked — * they still get a chance from the shared resolver. Exception: - * the free-call fallback marks the site unconditionally after - * 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`. + * the free-call fallback marks the site after it decides the site, + * including when it emits no edge — dedup-collapse, a visibility + * veto (`fallback-refused`), or a selected callable that is deleted + * / invisible. The later generic pass must not recreate an edge + * that pass just refused. `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 @@ -248,8 +249,8 @@ * ## Semantic-model source of truth * * `ParsedFile` (from `gitnexus-shared/src/scope-resolution/parsed-file.ts`) - * is the single semantic model consumed by both the legacy DAG and the - * scope-resolution pipeline. Scope-resolution passes MUST NOT build a + * is the single semantic model consumed by the scope-resolution pipeline. + * Scope-resolution passes MUST NOT build a * parallel parse representation; if a pass needs AST-level facts that * `ParsedFile` doesn't expose, it should reuse the orchestrator's * `treeCache` (see `RunScopeResolutionInput.treeCache`) rather than @@ -257,23 +258,20 @@ * * ## Same-graph guarantee * - * Edges emitted by `runScopeResolution` and edges emitted by the legacy - * DAG are indistinguishable to downstream consumers: + * All language resolvers emit edges through `runScopeResolution` using the + * shared graph contract: * - Node identity: same `generateId(...)` helper, same qualified-name * keyspace, same File/Folder/Method/Class node labels. * - Edge vocabulary: `'import-resolved' | 'global' | 'local-call' | - * 'same-file' | 'interface-dispatch' | 'read' | 'write'` — both - * paths emit the same reasons (see - * `gitnexus/src/core/ingestion/call-processor.ts` for the legacy - * emitter and `passes/receiver-bound-calls.ts` / + * 'same-file' | 'interface-dispatch' | 'read' | 'write' | + * 'global-name-fallback'` (see `passes/receiver-bound-calls.ts` / * `passes/free-call-fallback.ts` for the scope-resolution emitters). - * - Overload disambiguation: both paths use + * - Overload disambiguation: resolvers use * `generateId('Method', ...)` suffixed with `parameterTypes` when a * method has overloads — see `graph-bridge/ids.ts`. * - * The CI parity workflow (`.github/workflows/ci-scope-parity.yml`) - * runs both paths on every migrated language's fixture corpus and - * fails if the graph outputs diverge. + * Resolver fixture coverage lives in `ci-tests.yml` (the separate + * `ci-scope-parity.yml` gate was removed in RING4-1 #942). * * Plan that introduced most of these invariants: * `docs/plans/2026-04-20-001-refactor-emit-pipeline-generalization-plan.md`. @@ -865,6 +863,88 @@ export interface ScopeResolver { */ readonly allowGlobalFreeCallFallback?: boolean; + /** Opt in to binding imports at their extracted lexical scope, rather than + * publishing every file's imports at module scope. */ + readonly importsBindAtLexicalScope?: boolean; + + /** + * Two `wildcard` re-exports that both DECLARE a name make it AMBIGUOUS in + * this language — ECMAScript `export *` semantics, where the module simply + * does not export the name and any binding is a guess. Opt-in: for a + * language whose wildcard import is `#include`, `require` or a package + * fan-out, the same name in two files is an overload set or a redeclaration, + * and refusing it would delete real edges (C++ arity-narrowed overloads + * across two headers). Forwarded to `FinalizeHooks.wildcardCollisionIsAmbiguous`. + */ + readonly exclusiveWildcardReexports?: boolean; + + /** + * A named import or named re-export can only bind to a MODULE-LEVEL + * declaration of the target file — ECMAScript semantics, where + * `import { x }` never reaches a class member. Opt-in: languages that bind + * module-level members by bare name (static members, module functions) + * leave it off. Forwarded to `FinalizeHooks.namedImportsBindTopLevelOnly`. + */ + readonly namedImportsBindTopLevelOnly?: boolean; + + /** + * Veto for a single `allowGlobalFreeCallFallback` guess. + * + * The fallback picks a callable because its SIMPLE NAME is unique in the + * workspace — it consults no import and no scope chain. For most languages a + * large share of those guesses are not merely unlikely but IMPOSSIBLE: Go + * cannot call an unexported identifier from another package, ESM cannot see a + * name it did not import, Rust cannot reach an item with no `use` path. This + * hook is where a language states those rules, so the shared pass can drop + * the edge instead of publishing a guess that the language forbids. + * + * Return `false` to REFUSE (no edge, recorded as `fallback-refused`). Return + * `true`, or leave the hook undefined, to emit the labeled + * `global-name-fallback` edge. **Only answer `false` when the call is + * impossible, not when it is merely unproven** — a wrongly-refused candidate + * is a lost real edge, whereas a wrongly-allowed one is at least labeled and + * excluded from flows. + * + * Deliberately NOT folded into `isCallableVisibleFromCaller`: that hook also + * gates precise dispatch paths (implicit-this, member calls), so a rule + * written for the name-guess tier would silently suppress resolved edges too. + * + * `parsedFileOf` reaches the CANDIDATE's parse result — a language whose rule + * depends on declaration-side facts not carried by `SymbolDefinition` (for + * example Rust's `pub` form) needs it. `isExported` is the tri-state export + * marker when the language supplies one; this hook still needs the parse for + * visibility facts that marker does not represent. It returns `undefined` + * for a path outside this pass's file set; treat that as "cannot decide" + * and allow. + */ + readonly isGlobalNameFallbackPlausible?: (ctx: { + readonly callerParsed: ParsedFile; + readonly candidate: SymbolDefinition; + readonly parsedFileOf: (filePath: string) => ParsedFile | undefined; + /** + * Raw source of any parsed file, for languages whose visibility rule needs + * a declaration the parse does not carry (Go's package clause: a test file's + * `package foo_test` is a different package from its directory's `foo`). + * Absent when the pipeline has no contents at hand; hooks must then answer + * from paths alone and, when undecidable, allow. + */ + readonly sourceTextOf?: (filePath: string) => string | undefined; + /** + * The call site, so a language can tell a BARE name from a PATH-QUALIFIED + * one. Both reach this tier — a qualified call whose qualifier the resolver + * could not follow falls through to the bare-name search with only its tail + * name — but they are not the same claim. Rust's `User::new(...)` named its + * type in source, so refusing it for lacking a `use` of the module would + * delete an edge the source spells out. + */ + readonly site: { + readonly name: string; + readonly rawQualifiedName?: string; + /** Lexical call-site scope; absent only for legacy/synthetic hook callers. */ + readonly inScope?: ScopeId; + }; + }) => boolean; + /** * In this language every `Method` belongs to a class instance, so a * FREE (receiver-less) call may resolve to a `Method` only when the diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index 453c0b406..48673f027 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -505,9 +505,25 @@ function resolveDefGraphIdUncached( if (qualifiedHit !== undefined) return qualifiedHit; } const simpleName = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1); + // FAIL CLOSED before the label-agnostic simple key when a FUNCTION-LOCAL + // callable of this name exists in the file. The guards above cover a def + // that is itself a callable; a NON-callable def (`export const selected = + // factory()`, a `Variable` with no graph node of its own) used to fall + // through here and alias onto `wrapper.selected`, the function-local one + // (#3182 review). Whatever the def's label, a bare name matching a local + // callable is the aliasing this key cannot tell apart, and a missing edge + // is the correct failure direction. + for (const localLabel of LOCAL_CALLABLE_LABELS) { + if (nodeLookup.get(localNameKey(filePath, localLabel, simpleName)) !== undefined) { + return undefined; + } + } return nodeLookup.get(simpleKey(filePath, simpleName)); } +/** Labels the structure phase registers function-local declarations under. */ +const LOCAL_CALLABLE_LABELS: readonly NodeLabel[] = ['Function', 'Method']; + /** Derive the simple (unqualified) name of a def from its `qualifiedName`. */ export function simpleQualifiedName(def: SymbolDefinition): string | undefined { const q = def.qualifiedName; diff --git a/gitnexus/src/core/ingestion/scope-resolution/name-fallback-summary.ts b/gitnexus/src/core/ingestion/scope-resolution/name-fallback-summary.ts new file mode 100644 index 000000000..e96d9d5c7 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/name-fallback-summary.ts @@ -0,0 +1,212 @@ +/** + * Per-language census of global-name fallback decisions: how many call sites + * used a unique-name guess, and how many candidates visibility rules refused. + * Sites are counted before edge coalescing: a precisely bound site can prove + * the same caller/target dependency, so this is not a count of heuristic edges. + * + * Both halves are needed and neither is meaningful alone. A guess count with no + * refusal count cannot distinguish a language with genuinely few impossible + * candidates from one whose hook is missing; a refusal count with no guess count + * cannot distinguish a working guard from one that rejects everything. The pair + * is what makes the guard auditable on a real repository, which is the whole + * point of recording it — before this, guessed edges were emitted with the same + * reason and confidence as import-resolved ones and the number was unknowable. + * + * Structural sibling of `unresolved-receivers.ts`'s summary, and persisted the + * same way (`RepoMeta.nameFallbackEdges`). + */ + +import { getLanguageFromFilename } from 'gitnexus-shared'; +import { logger } from '../../logger.js'; +import type { ResolutionOutcome } from './resolution-outcome.js'; + +/** + * Distinct caller-file/callee-name pairs per language, from the caller→callee-name index the pipeline + * builds while its streaming sink is live (so it is complete under `--force`), + * bucketed by the CALLER's file language. Gives `NameFallbackSummary.byLanguage` + * its denominator: a guess count is only readable as a share of the calls. + */ +export function countCallsByLanguage( + index: ReadonlyMap> | undefined, + nodes: { getNode(id: string): { properties: Record } | undefined } | undefined, +): Readonly> | undefined { + if (index === undefined || nodes === undefined) return undefined; + const counts = new Map(); + const namesByFile = new Map>(); + for (const [callerId, callees] of index) { + const filePath = nodes.getNode(callerId)?.properties?.filePath; + if (typeof filePath !== 'string') continue; + let names = namesByFile.get(filePath); + if (names === undefined) { + names = new Set(); + namesByFile.set(filePath, names); + } + for (const name of callees) names.add(name); + } + for (const [filePath, names] of namesByFile) { + const language = getLanguageFromFilename(filePath) ?? 'unknown'; + counts.set(language, (counts.get(language) ?? 0) + names.size); + } + if (counts.size === 0) return undefined; + const out: Record = {}; + for (const [language, count] of [...counts.entries()].sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0, + )) { + out[language] = count; + } + return out; +} + +/** Unattributed bucket for a pass that recorded no language. */ +const UNKNOWN_LANGUAGE = 'unknown'; + +export interface NameFallbackLanguageCounts { + /** Call sites resolved by a unique-name guess, before edge coalescing. */ + readonly guessed: number; + /** + * Distinct (caller file, callee name) pairs among those sites — the unit + * `callsByLanguage` counts in, so `guessedPairs / callsByLanguage[lang]` is a + * ratio bounded by 1. Absent on a summary persisted before this field existed. + */ + readonly guessedPairs?: number; + /** Candidates this language's `isGlobalNameFallbackPlausible` hook refused. */ + readonly refused: number; +} + +export interface NameFallbackSummary { + /** Language → guessed/refused counts. Languages with neither are absent. */ + readonly byLanguage: Readonly>; + /** Guessed call sites, repo-wide. */ + readonly totalGuessed: number; + /** Distinct (caller file, callee name) pairs among the guessed sites. */ + readonly distinctGuessedPairs?: number; + readonly totalRefused: number; + /** + * Barrel names refused because two `export *` sources both declared them + * (`reexport-ambiguous`). Not a guess and not per-language — a name the + * finalize pass declined to bind at all — but it belongs in the same census: + * it is the other place the resolver used to publish an arbitrary winner as + * `import-resolved`. + */ + readonly totalAmbiguousReexports: number; + /** + * The refused barrel names themselves (`file:name`), sorted, capped at + * `MAX_AMBIGUOUS_NAMES`. A count alone cannot say whether a refusal landed on + * a name anyone calls; the list can be joined against the ledger's `byName`. + */ + readonly ambiguousReexportNames?: readonly string[]; + /** + * Distinct caller-file/callee-name pairs per language (through any path), so `guessedPairs` can be + * read as a SHARE of a language's call graph rather than a bare count. Absent + * when the caller did not supply the totals. + */ + readonly callsByLanguage?: Readonly>; +} + +/** Bound on the persisted ambiguous-name list; the total beside it stays exact. */ +export const MAX_AMBIGUOUS_NAMES = 200; + +/** + * Build the summary, or `undefined` when the run neither guessed nor refused — + * a repository with no opt-in language stores no key at all, so the artifact + * stays absent rather than recording a row of zeroes. + */ +export function summarizeNameFallback( + outcomes: readonly ResolutionOutcome[], + callsByLanguage?: Readonly>, +): NameFallbackSummary | undefined { + const guessed = new Map(); + const guessedPairsByLanguage = new Map(); + const refused = new Map(); + const ambiguousNames = new Set(); + // Two units, both kept. `guessed` counts call SITES, not final graph edges: + // a precise site may prove the same dependency during edge coalescing. + // Preserve the unit earlier summaries hold. `guessedPairs` dedupes by (caller file, + // callee name), the unit `callsByLanguage` is counted in: ten guessed `foo()` + // calls in one file are one pair against a denominator that counts `foo` + // once, so the guessy RATIO uses pairs and is bounded by 1. Changing the unit + // of `guessed` itself silently read as a large improvement across engines. + const guessedPairs = new Set(); + let totalGuessed = 0; + let totalRefused = 0; + let totalAmbiguousReexports = 0; + + for (const outcome of outcomes) { + if (outcome.kind === 'fallback-guessed') { + const language = outcome.language ?? UNKNOWN_LANGUAGE; + guessed.set(language, (guessed.get(language) ?? 0) + 1); + totalGuessed++; + const pair = `${outcome.filePath}\u0000${outcome.name}`; + if (!guessedPairs.has(pair)) { + guessedPairs.add(pair); + guessedPairsByLanguage.set(language, (guessedPairsByLanguage.get(language) ?? 0) + 1); + } + } else if (outcome.kind === 'fallback-refused') { + const language = outcome.language ?? UNKNOWN_LANGUAGE; + refused.set(language, (refused.get(language) ?? 0) + 1); + totalRefused++; + } else if (outcome.kind === 'reexport-ambiguous') { + totalAmbiguousReexports++; + ambiguousNames.add(`${outcome.filePath}:${outcome.name}`); + } + } + if (totalGuessed === 0 && totalRefused === 0 && totalAmbiguousReexports === 0) return undefined; + + const byLanguage: Record = {}; + for (const language of new Set([...guessed.keys(), ...refused.keys()])) { + byLanguage[language] = { + guessed: guessed.get(language) ?? 0, + guessedPairs: guessedPairsByLanguage.get(language) ?? 0, + refused: refused.get(language) ?? 0, + }; + } + const sortedNames = [...ambiguousNames].sort(); + return { + byLanguage, + totalGuessed, + distinctGuessedPairs: guessedPairs.size, + totalRefused, + totalAmbiguousReexports, + ...(sortedNames.length > 0 + ? { ambiguousReexportNames: sortedNames.slice(0, MAX_AMBIGUOUS_NAMES) } + : {}), + ...(callsByLanguage !== undefined ? { callsByLanguage } : {}), + }; +} + +/** + * One-line readout for the analyze summary. `undefined` when there is nothing to + * report, so a run on a repository with no opt-in language prints no line. + */ +export function formatNameFallbackSummary( + summary: NameFallbackSummary | undefined, +): string | undefined { + if (summary === undefined) return undefined; + const perLanguage = Object.entries(summary.byLanguage) + .sort(([, a], [, b]) => b.guessed + b.refused - (a.guessed + a.refused)) + .map(([language, counts]) => `${language} ${counts.guessed}/${counts.refused}`) + .join(', '); + const ambiguous = + summary.totalAmbiguousReexports > 0 + ? `; ${summary.totalAmbiguousReexports} barrel name(s) refused as ambiguous \`export *\`` + : ''; + const languages = perLanguage === '' ? 'none' : perLanguage; + const pairs = + summary.distinctGuessedPairs !== undefined + ? ` (${summary.distinctGuessedPairs} distinct caller-file/name pairs)` + : ''; + const siteUnit = summary.totalGuessed === 1 ? 'call site' : 'call sites'; + return `name-fallback resolution: ${summary.totalGuessed} ${siteUnit}${pairs}, ${summary.totalRefused} refused as impossible (guessed/refused by language: ${languages})${ambiguous}`; +} + +/** + * Print the readout as part of the analyze summary. Unconditional (not behind a + * debug env var, unlike the receiver-drop diagnostic): a reader deciding how far + * to trust this index's call graph needs to know how much of it is guessed, and + * a number nobody sees is the state this work exists to end. + */ +export function logNameFallbackSummary(summary: NameFallbackSummary | undefined): void { + const line = formatNameFallbackSummary(summary); + if (line === undefined) return; + logger.info(line); +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 0457767b2..7dcc792f3 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -36,6 +36,7 @@ import type { ResolutionOutcomeRecorder, ResolutionSuppressionReason, } from '../resolution-outcome.js'; +import { GLOBAL_NAME_FALLBACK_REASON } from '../../../graph/edge-reasons.js'; import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js'; import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js'; import { @@ -43,7 +44,9 @@ import { findAllCallableBindingsInScope, findCallableBindingInScope, findCallableBindingsAndAdlBlocker, + findClassBindingInScope, findEnclosingClassDef, + isClassFileImportGrounded, resolveInheritanceBaseInScope, type CallableBindingCandidate, } from '../scope/walkers.js'; @@ -64,6 +67,15 @@ export function emitFreeCallFallback( workspaceIndex: WorkspaceResolutionIndex, options: { readonly allowGlobalFallback?: boolean; + /** Language whose pass this is, carried onto the fallback outcome records + * so the analyze summary can report guesses/refusals PER LANGUAGE. A + * repo-wide total hides which language's rules are the loose ones. */ + readonly language?: string; + /** Per-language veto on a name guess — see + * `ScopeResolver.isGlobalNameFallbackPlausible`. */ + readonly isGlobalNameFallbackPlausible?: ScopeResolver['isGlobalNameFallbackPlausible']; + /** Raw source lookup handed to `isGlobalNameFallbackPlausible` (optional). */ + readonly sourceTextOf?: (filePath: string) => string | undefined; /** When true, `Type(...)` constructor calls link to the Class def * itself rather than its explicit Constructor. Swift opts in. */ readonly constructorCallTargetsClass?: boolean; @@ -138,6 +150,14 @@ export function emitFreeCallFallback( let allFilePathsMemo: ReadonlySet | undefined; const allFilePaths = (): ReadonlySet => (allFilePathsMemo ??= new Set(parsedFiles.map((p) => p.filePath))); + // Candidate-side parse lookup for `isGlobalNameFallbackPlausible`. Built + // lazily and once, on the same terms as `allFilePaths` above: a language + // without the hook never pays for the index. + let parsedByPathMemo: ReadonlyMap | undefined; + const parsedFileByPath = (): ((filePath: string) => ParsedFile | undefined) => { + parsedByPathMemo ??= new Map(parsedFiles.map((p) => [p.filePath, p])); + return (filePath) => parsedByPathMemo!.get(filePath); + }; // Per-pass memo of pickUniqueGlobalCallable's post-filter candidate list, // keyed (simpleName, callerFilePath). Only created when no per-caller // visibility filter applies (the list is then a pure function of name+file — @@ -183,7 +203,17 @@ export function emitFreeCallFallback( }; for (const parsed of parsedFiles) { - type PendingRel = { rel: Parameters[0]; gatedAll: boolean }; + type PendingRel = { + rel: Parameters[0]; + gatedAll: boolean; + /** + * The confidence/reason a PRECISELY resolved site (a real binding, not a + * unique-name guess) proved for this edge; `undefined` while every site + * collapsed into it so far was a guess. Decided at flush, not by the + * first site the walk met. + */ + precise: { confidence: number; reason: string } | undefined; + }; const pending = new Map(); const bindingCandidatesByScope = options.freeCallsRequireInstanceOwnership === true @@ -199,13 +229,48 @@ export function emitFreeCallFallback( // to the Class node itself (implicit constructor). Legacy emits // the same two targets; see test expectations. let fnDef: SymbolDefinition | undefined; + // Guess flag starts here so the constructor unique-class pick can mark + // the same class of guess as `pickUniqueGlobalCallable` below. The + // veto/label path keys off this flag; declaring it after that pick left + // constructor-form unique-name hits labeled `import-resolved` at 0.85. + let fnDefFromGlobalNameFallback = false; + // Language visibility vetoes the constructed TYPE (Class/Struct), not a + // Constructor child — Go refuses any `qualifiedName` containing `.`. + let globalFallbackVetoTarget: SymbolDefinition | undefined; if (site.callForm === 'constructor') { - const classDef = resolveInheritanceBaseInScope( + // Lexical / written-qualifier first. A bare unique type with no + // import/#include evidence is a guess (JS `new UniqueWidget()`, + // Go unexported `uniqueWidget{}`). A unique type whose file is + // imported, whose import resolved to this def, or whose name + // was imported is precise — C++ `#include "user.h"` and Rust + // `use`/`pub use` often mint no lexical class binding. An + // imported sibling file of a different name is not evidence. + let classDef = resolveInheritanceBaseInScope( site.inScope, site.name, scopes, site.rawQualifiedName, + undefined, + { uniqueQualifiedNameFallback: site.rawQualifiedName !== undefined }, ); + if (classDef === undefined) { + classDef = + findClassBindingInScope(site.inScope, site.name, scopes, undefined, { + uniqueQualifiedNameFallback: true, + }) ?? + (options.allowGlobalFallback === true + ? pickUniqueGlobalClass(site.name, globalClassesBySimpleName) + : undefined); + if ( + classDef !== undefined && + classDef.type !== 'Interface' && + site.rawQualifiedName === undefined && + !isClassFileImportGrounded(site.inScope, classDef, scopes, site.name) + ) { + fnDefFromGlobalNameFallback = true; + globalFallbackVetoTarget = classDef; + } + } if (classDef !== undefined && classDef.type !== 'Interface') { // Most languages link `Type(...)` to the explicit Constructor def // when one exists (else the Class). Languages that model the call @@ -215,21 +280,6 @@ export function emitFreeCallFallback( options.constructorCallTargetsClass === true ? classDef : pickConstructorOrClass(classDef, workspaceIndex, scopes, site.arity); - } else if (options.allowGlobalFallback === true) { - // The constructed type may live in a sibling/imported file that is - // not in the call-site's lexical scope-chain bindings. Fall back to - // a unique workspace-wide Class def by simple name (gated on the - // same global-fallback opt-in as free calls). Then target the - // Class or its Constructor per the language's preference. - const globalClass = pickUniqueGlobalClass(site.name, globalClassesBySimpleName); - if (globalClass !== undefined) { - fnDef = - globalClass.type === 'Interface' - ? undefined - : options.constructorCallTargetsClass === true - ? globalClass - : pickConstructorOrClass(globalClass, workspaceIndex, scopes, site.arity); - } } } // Module-qualified free call (`mod::fn()`): the source named the module @@ -543,10 +593,17 @@ export function emitFreeCallFallback( } } } - // V1: pickUniqueGlobalCallable ignores import context — resolves to any - // globally-unique callable. False cross-package edges are possible when - // the caller does not import the target package. Same-package calls are - // usually caught by nearest-scope lookup before reaching here. + // Name-guess tier: pickUniqueGlobalCallable consults no import context — + // it resolves to any globally-unique callable. Same-package calls are + // usually caught by nearest-scope lookup before reaching here, so what + // lands in this tier is disproportionately cross-module, and a + // cross-module name match is a guess. + // + // Two things make that honest rather than a lie. The language's + // `isGlobalNameFallbackPlausible` hook refuses candidates its own + // visibility rules forbid (below), and every edge that survives is + // emitted with `GLOBAL_NAME_FALLBACK_REASON` at 0.5 rather than + // masquerading as `import-resolved` at 0.85 (see the emit site). if (fnDef === undefined && options.allowGlobalFallback === true) { fnDef = pickUniqueGlobalCallable( site.name, @@ -570,6 +627,53 @@ export function emitFreeCallFallback( scopeDefsCache, options.conversionOnlyArgTypePrefixes, ); + fnDefFromGlobalNameFallback = fnDef !== undefined; + if (fnDef !== undefined) globalFallbackVetoTarget = fnDef; + } + if (fnDefFromGlobalNameFallback && fnDef !== undefined) { + // An explicit named import cannot bind a declaration proven private + // to another module. In particular, a rejected named import must not + // reappear as a name guess to a class member or nested function. + // Unknown export evidence (e.g. dynamic module exports) keeps the + // existing fallback behavior. + const vetoCandidate = globalFallbackVetoTarget ?? fnDef; + const importsPrivateDeclaration = + vetoCandidate.filePath !== parsed.filePath && + vetoCandidate.isExported === false && + parsed.parsedImports.some( + (imported) => + (imported.kind === 'named' || imported.kind === 'alias') && + imported.localName === site.name, + ); + if ( + importsPrivateDeclaration || + options.isGlobalNameFallbackPlausible?.({ + callerParsed: parsed, + candidate: vetoCandidate, + parsedFileOf: parsedFileByPath(), + sourceTextOf: options.sourceTextOf, + site: { + name: site.name, + rawQualifiedName: site.rawQualifiedName, + inScope: site.inScope, + }, + }) === false + ) { + // The language proved this call impossible. Mark the site handled so + // `emit-references` does not substitute its own looser guess for the + // edge we just refused — the point is no edge, not a different one. + options.recordResolutionOutcome?.({ + kind: 'fallback-refused', + candidateId: fnDef.nodeId, + language: options.language, + phase: 'free-call-fallback', + filePath: parsed.filePath, + name: site.name, + range: site.atRange, + }); + handledSites.add(siteKey(parsed.filePath, site)); + continue; + } } if (fnDef === undefined) continue; if (fnDef.isDeleted === true) { @@ -617,40 +721,78 @@ export function emitFreeCallFallback( site.atRange.startCol, tgtGraphId, ); + if (fnDefFromGlobalNameFallback) { + options.recordResolutionOutcome?.({ + kind: 'fallback-guessed', + targetId: fnDef.nodeId, + language: options.language, + phase: 'free-call-fallback', + filePath: parsed.filePath, + name: site.name, + range: site.atRange, + }); + } const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`; // One edge per (caller, callee): `staticGated` is the AND over every site // that collapses into it, so a callee reached from one live site and one // dead site stays live whichever site the walk meets first. Emission is // deferred to the end of this file's sites for that reason. + const preciseHere = fnDefFromGlobalNameFallback + ? undefined + : { + confidence: 0.85, + // Match legacy DAG's reason convention so consumers that + // assert `reason === 'import-resolved'` keep working. The + // construction-site marker is opt-in for the same reason. + reason: constructionSiteReason( + fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call', + site, + options.markConstructionSites, + ), + }; const pendingRel = pending.get(relId); if (pendingRel !== undefined) { if (site.staticGated !== true) pendingRel.gatedAll = false; + // The edge's label is decided at flush time from EVERY site that + // collapsed into it, not from whichever the walk met first. One site + // resolved through a real binding PROVES the dependency; a guessed + // site for the same pair is then redundant evidence, not a taint. + if (pendingRel.precise === undefined) pendingRel.precise = preciseHere; continue; } if (seen.has(relId)) continue; seen.add(relId); pending.set(relId, { gatedAll: site.staticGated === true, + precise: preciseHere, rel: { id: relId, sourceId: callerGraphId, targetId: tgtGraphId, type: 'CALLS', - confidence: 0.85, - // Match legacy DAG's reason convention so consumers that - // assert `reason === 'import-resolved'` keep working. The - // construction-site marker is opt-in for the same reason. - reason: constructionSiteReason( - fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call', - site, - options.markConstructionSites, - ), + // Guess values as placeholders; decided at flush from `precise`. + confidence: 0.5, + reason: GLOBAL_NAME_FALLBACK_REASON, }, }); emitted++; } - for (const { rel, gatedAll } of pending.values()) { - graph.addRelationship(gatedAll ? { ...rel, staticGated: true } : rel); + for (const { rel, gatedAll, precise } of pending.values()) { + // A name guess is not an import resolution and must not be spelled like + // one. It used to be emitted at 0.85 / `'import-resolved'`, which made + // it indistinguishable from an edge a real import produced — so every + // consumer that wanted to discount guesses had no field to do it with. + // 0.5 is the deliberate "coin flip" value, and the reason is what the + // process/community walks and the MCP tools actually key on, because + // 0.5 sits exactly ON their thresholds (see graph/edge-reasons.ts). + // An edge is a guess only when EVERY site that collapsed into it was one; + // a single precisely resolved site proves it, whatever order the walk + // met the sites in. Independent of `gatedAll`. + const labeled = + precise !== undefined + ? { ...rel, confidence: precise.confidence, reason: precise.reason } + : rel; + graph.addRelationship(gatedAll ? { ...labeled, staticGated: true } : labeled); } } return emitted; diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts index 157348c5b..0736bb53c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts @@ -120,19 +120,10 @@ export function followChainPostFinalize( * typeBindings — the in-extractor pass-4 ran before propagation and * missed any chain whose terminal lived in a foreign file. * - * Scope-chain concern (verified 2026-04-21): `pythonImportOwningScope` - * documents that function-local `from x import y` binds `y` to the - * inner function scope, which would make a module-only write miss - * non-module importers. In practice `finalize-algorithm` hoists those - * bindings into `indexes.bindings[moduleScope]` regardless of where - * the `import` statement appears — the integration fixture - * `python-function-local-import-chain` exercises a chained - * receiver-bound call `u = get_user(); u.save()` inside a function - * body and emits the expected `do_work → User.save` edge. The - * module-scope write is sufficient today. If finalize routing ever - * changes to honor the hook's per-scope contract, this pass must - * iterate `indexes.bindings` over every scope and mirror into the - * binding-owning scope's `typeBindings`, not just the module's. + * Finalize can retain lexical import ownership. Mirror into each binding's + * owning scope, never hoist local import-derived types into the file scope. + * Module ordering remains SCC-based; non-module chains are followed after + * all import bindings have been mirrored. */ export function propagateImportedReturnTypes( parsedFiles: readonly ParsedFile[], @@ -140,6 +131,7 @@ export function propagateImportedReturnTypes( index: WorkspaceResolutionIndex, ): void { const moduleScopeByFile = index.moduleScopeByFile; + const scopesByFile = new Map(parsedFiles.map((file) => [file.filePath, file.scopes])); // Walk SCCs in reverse-topological order (`indexes.sccs` is leaves- // first per `tarjanSccs`). For each file we mirror import bindings @@ -167,42 +159,44 @@ export function propagateImportedReturnTypes( // import-derived typeBinding mirror. Both helpers fast-path when // no augmentations exist for the scope, so the common case is // allocation-free. See I8. - for (const localName of namesAtScope(importerModule.id, indexes)) { - // Skip if importer already has a typeBinding for this name — - // an explicit local annotation must win over import-derived. - if (importerModule.typeBindings.has(localName)) continue; + for (const importerScope of scopesByFile.get(filePath) ?? [importerModule]) { + for (const localName of namesAtScope(importerScope.id, indexes)) { + // Skip if importer already has a typeBinding for this name — + // an explicit local annotation must win over import-derived. + if (importerScope.typeBindings.has(localName)) continue; - const refs = lookupBindingsAt(importerModule.id, localName, indexes); - for (const ref of refs) { - if (ref.origin !== 'import' && ref.origin !== 'reexport' && ref.origin !== 'wildcard') - continue; - const sourceModule = moduleScopeByFile.get(ref.def.filePath); - if (sourceModule === undefined) continue; + const refs = lookupBindingsAt(importerScope.id, localName, indexes); + for (const ref of refs) { + if (ref.origin !== 'import' && ref.origin !== 'reexport' && ref.origin !== 'wildcard') + continue; + const sourceModule = moduleScopeByFile.get(ref.def.filePath); + if (sourceModule === undefined) continue; - // The source file's typeBinding is keyed by the def's simple - // name (e.g. `get_user`), not the importer's local alias. - const qn = ref.def.qualifiedName; - if (qn === undefined) continue; - const dot = qn.lastIndexOf('.'); - const sourceName = dot === -1 ? qn : qn.slice(dot + 1); + // The source file's typeBinding is keyed by the def's simple + // name (e.g. `get_user`), not the importer's local alias. + const qn = ref.def.qualifiedName; + if (qn === undefined) continue; + const dot = qn.lastIndexOf('.'); + const sourceName = dot === -1 ? qn : qn.slice(dot + 1); - const sourceTypeRef = sourceModule.typeBindings.get(sourceName); - if (sourceTypeRef === undefined) continue; + const sourceTypeRef = sourceModule.typeBindings.get(sourceName); + if (sourceTypeRef === undefined) continue; - // Chain-follow inside the source module so we mirror the - // terminal type, not an intermediate intra-source reference. - const terminal = followChainPostFinalize(sourceTypeRef, sourceModule.id, indexes); + // Chain-follow inside the source module so we mirror the + // terminal type, not an intermediate intra-source reference. + const terminal = followChainPostFinalize(sourceTypeRef, sourceModule.id, indexes); - // Mutating typeBindings is safe because draftToScope - // produced a non-frozen Map (Contract Invariant I3/I8). - (importerModule.typeBindings as Map).set(localName, terminal); - // First-write-wins for the local alias: if the same - // `localName` was registered multiple times via - // `mergeBindings` (rare; happens with conflicting - // re-exports), only the first ref with a usable - // typeBinding source is mirrored. Conflict resolution - // among multiple sources is the merger's job, not ours. - break; + // Mutating typeBindings is safe because draftToScope + // produced a non-frozen Map (Contract Invariant I3/I8). + (importerScope.typeBindings as Map).set(localName, terminal); + // First-write-wins for the local alias: if the same + // `localName` was registered multiple times via + // `mergeBindings` (rare; happens with conflicting + // re-exports), only the first ref with a usable + // typeBinding source is mirrored. Conflict resolution + // among multiple sources is the merger's job, not ours. + break; + } } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 15976130f..7cc96e829 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -726,6 +726,7 @@ export function runScopeResolution( const resolutionConfig = input.resolutionConfig; const finalized = finalizeScopeModel(parsedFiles, { hooks: { + importsBindAtLexicalScope: provider.importsBindAtLexicalScope === true, resolveImportTarget: (targetRaw, fromFile, _workspaceIndex, parsedImport) => provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig, { parsedFiles, @@ -737,9 +738,23 @@ export function runScopeResolution( provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [], mergeBindings: (existing, incoming, scopeId) => provider.mergeBindings(existing, incoming, scopeId), + wildcardCollisionIsAmbiguous: provider.exclusiveWildcardReexports === true, + namedImportsBindTopLevelOnly: provider.namedImportsBindTopLevelOnly === true, }, }); logHeapProbe('sr-post-finalize', `lang=${provider.language}`); + // `export *` collisions the shared finalize refused to bind (WS1 C2). Recorded + // as outcomes so the refusal is auditable next to the name-fallback census — + // a silently unresolved importer is indistinguishable from a resolver gap. + for (const refused of finalized.stats.ambiguousWildcardExports) { + recordResolutionOutcome({ + kind: 'reexport-ambiguous', + candidateIds: refused.candidateDefIds, + phase: 'finalize', + filePath: refused.filePath, + name: refused.name, + }); + } // One store and ONE writer rule for heritage instantiations (#2912), shared by // the pre-pass below and by the language hook further down — a heritage shape // the pre-pass cannot express (Rust `impl T for S`, Dart `implements`) records @@ -1085,6 +1100,12 @@ export function runScopeResolution( workspaceIndex, { allowGlobalFallback: provider.allowGlobalFreeCallFallback === true, + language: provider.language, + isGlobalNameFallbackPlausible: provider.isGlobalNameFallbackPlausible, + sourceTextOf: + provider.isGlobalNameFallbackPlausible !== undefined + ? (filePath: string) => getFileContents().get(filePath) + : undefined, constructorCallTargetsClass: provider.constructorCallTargetsClass === true, markConstructionSites: provider.markConstructionSites === true, isFileLocalDef: provider.isFileLocalDef, diff --git a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts index a0e5e0919..8a14460a6 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts @@ -108,6 +108,60 @@ export type ResolutionOutcome = * every real codebase and taught readers to ignore it. */ readonly receiverOrigin?: ReceiverOrigin; + } + /** + * The global-name fallback fired: a callable was chosen because its SIMPLE + * NAME is unique in the workspace, with no import or scope chain leading to + * it. A labeled low-confidence edge WAS emitted. + * + * Separate from `resolved` because it is not a resolution, and separate from + * `suppressed` because an edge exists. Counting it is the only way a reader + * can tell how much of a language's call graph rests on name uniqueness — the + * number that was previously invisible because these edges were emitted with + * the same reason and confidence as import-resolved ones. + */ + | { + readonly kind: 'fallback-guessed'; + readonly targetId: string; + readonly language?: string; + readonly phase: string; + readonly filePath: string; + readonly name: string; + readonly range: Range; + } + /** + * A global-name-fallback candidate was REFUSED by the language's plausibility + * hook: the language's own visibility rules make that call impossible, so no + * edge was emitted. + * + * The counterpart of `fallback-guessed`, and the pair is what makes the + * refusal auditable — a refusal count with no guess count cannot distinguish + * "the guard works" from "the guard rejects everything". + */ + | { + readonly kind: 'fallback-refused'; + readonly candidateId: string; + readonly language?: string; + readonly phase: string; + readonly filePath: string; + readonly name: string; + readonly range: Range; + } + /** + * A barrel re-exported `name` through two or more `export *` sources that + * each declare it, so the language names no winner. The shared finalize pass + * REFUSED the binding (see `FinalizeStats.ambiguousWildcardExports`) instead + * of publishing the first-listed source as `import-resolved`; every importer + * of `name` through `filePath` stays unresolved. No `range`: the collision + * belongs to the file's export surface, not to one statement. + */ + | { + readonly kind: 'reexport-ambiguous'; + readonly candidateIds: readonly string[]; + readonly phase: string; + /** The barrel file whose `export *` sources collide. */ + readonly filePath: string; + readonly name: string; }; /** diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 835f9a7a8..54daf1c92 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -782,6 +782,20 @@ function soleBoundBaseName(bound: string): string | undefined { return base.length === 0 ? undefined : base; } +export type ClassBindingLookup = { + /** + * When false, skip the workspace-unique QualifiedNameIndex hit (and the + * dotted-tail variant). Default true — Go inheritance still needs that + * hit because namespace-style imports often create no scope binding. + * + * Constructor-form free calls pass false so a unique type name is not + * treated as a precise in-scope binding. Those sites belong to + * `pickUniqueGlobalClass`, which labels the edge as a guess and runs + * `isGlobalNameFallbackPlausible`. + */ + readonly uniqueQualifiedNameFallback?: boolean; +}; + export function findClassBindingInScope( startScope: ScopeId, receiverName: string, @@ -798,6 +812,7 @@ export function findClassBindingInScope( * selection. Only receiver-chain base and step resolution passes this. */ stripDecoration?: DecorationStripper, + lookup?: ClassBindingLookup, ): SymbolDefinition | undefined { // A TYPE PARAMETER is not a class, and it is checked before every route below // rather than inside one of them because each route would otherwise reach a @@ -806,7 +821,13 @@ export function findClassBindingInScope( // fallback after stripping. The declaration that introduced the parameter is // the only thing that knows, and it knows for all three. if (bindsTypeParameter(startScope, receiverName, scopes)) { - return resolveThroughTypeParameterBound(startScope, receiverName, scopes, stripDecoration); + return resolveThroughTypeParameterBound( + startScope, + receiverName, + scopes, + stripDecoration, + lookup, + ); } const local = walkScopeChain(startScope, receiverName, scopes, (def) => isClassLike(def.type)); @@ -815,21 +836,25 @@ export function findClassBindingInScope( // Fallback for languages (Go) where namespace-style imports don't // create scope bindings: resolve via QualifiedNameIndex. Only fires // when the scope-chain walk found nothing; single-match wins. - const qnames = scopes.qualifiedNames.get(receiverName); - if (qnames.length === 1) { - const def = scopes.defs.get(qnames[0]!); - if (def !== undefined && isClassLike(def.type)) return def; - } - // Second fallback: dotted names like "models.User" — try the simple - // name (tail after last dot) for languages where defs are indexed by - // simple name (Go). Only when the dotted lookup fails. - if (receiverName.includes('.')) { - const simple = receiverName.slice(receiverName.lastIndexOf('.') + 1); - if (simple.length > 0 && simple !== receiverName) { - const simpleIds = scopes.qualifiedNames.get(simple); - if (simpleIds.length === 1) { - const def = scopes.defs.get(simpleIds[0]!); - if (def !== undefined && isClassLike(def.type)) return def; + // Constructor-form free calls opt out — a unique type name is a guess, + // not proof the type is in scope (see `ClassBindingLookup`). + if (lookup?.uniqueQualifiedNameFallback !== false) { + const qnames = scopes.qualifiedNames.get(receiverName); + if (qnames.length === 1) { + const def = scopes.defs.get(qnames[0]!); + if (def !== undefined && isClassLike(def.type)) return def; + } + // Second fallback: dotted names like "models.User" — try the simple + // name (tail after last dot) for languages where defs are indexed by + // simple name (Go). Only when the dotted lookup fails. + if (receiverName.includes('.')) { + const simple = receiverName.slice(receiverName.lastIndexOf('.') + 1); + if (simple.length > 0 && simple !== receiverName) { + const simpleIds = scopes.qualifiedNames.get(simple); + if (simpleIds.length === 1) { + const def = scopes.defs.get(simpleIds[0]!); + if (def !== undefined && isClassLike(def.type)) return def; + } } } } @@ -875,6 +900,7 @@ function resolveThroughTypeParameterBound( parameterName: string, scopes: ScopeResolutionIndexes, stripDecoration?: DecorationStripper, + lookup?: ClassBindingLookup, ): SymbolDefinition | undefined { const bound = typeParameterAt(startScope, parameterName, scopes)?.bound; if (bound === undefined) return undefined; @@ -882,7 +908,7 @@ function resolveThroughTypeParameterBound( if (baseName === undefined || baseName === parameterName) return undefined; // A bound naming another parameter terminates here rather than recursing. if (bindsTypeParameter(startScope, baseName, scopes)) return undefined; - return findClassBindingInScope(startScope, baseName, scopes, stripDecoration); + return findClassBindingInScope(startScope, baseName, scopes, stripDecoration, lookup); } function normalizeTemplateArgToken(value: string): string { @@ -1283,6 +1309,7 @@ export function resolveInheritanceBaseInScope( scopes: ScopeResolutionIndexes, rawQualifiedName?: string, enclosingClassDef?: SymbolDefinition, + lookup?: ClassBindingLookup, ): SymbolDefinition | undefined { // #1982: when the source wrote a qualified base (`Other::Inner`), resolve it // against the full-path QualifiedNameIndex FIRST, so a same-tail nested base @@ -1302,7 +1329,7 @@ export function resolveInheritanceBaseInScope( if (qualified !== undefined) return qualified; } return ( - findClassBindingInScope(startScope, baseName, scopes) ?? + findClassBindingInScope(startScope, baseName, scopes, undefined, lookup) ?? resolveAmbiguousInheritanceBaseViaImports(startScope, baseName, scopes) ); } @@ -1528,6 +1555,43 @@ function dirnameOf(filePath: string): string { return idx === -1 ? '' : filePath.slice(0, idx); } +/** + * True when the caller can see `classDef` through import evidence, not + * merely because the class name is workspace-unique. + * + * Exact imported file (`#include "user.h"`) and a finalize-resolved + * `targetDefId` (named `use` / `pub use` followed to the def) are + * precise. An import that *names* this class (`targetExportedName`) + * is also precise — a barrel's `targetFile` is the re-exporting module, + * not the defining file. A sibling-file import of a *different* name + * is not: `use crate::a::bar` does not make `Foo` in `a/foo.rs` visible. + * + * Directory-only matching is intentionally omitted. The same-directory + * tier on `resolveAmbiguousInheritanceBaseViaImports` is for picking + * among *ambiguous* names (C# namespace `using`); it is not proof a + * unique constructor type is in scope. + */ +export function isClassFileImportGrounded( + startScope: ScopeId, + classDef: Pick, + scopes: ScopeResolutionIndexes, + importedName: string, +): boolean { + const callerModule = moduleScopeIdOf(startScope, scopes); + const callerFile = scopes.scopeTree.getScope(startScope)?.filePath; + if (callerFile !== undefined && classDef.filePath === callerFile) return true; + if (callerModule === null) return false; + const importEdges = scopes.imports.get(callerModule); + if (importEdges === undefined || importEdges.length === 0) return false; + for (const edge of importEdges) { + if (edge.targetDefId !== undefined && edge.targetDefId === classDef.nodeId) return true; + if (edge.targetFile === null) continue; + if (edge.targetFile === classDef.filePath) return true; + if (importedName.length > 0 && edge.targetExportedName === importedName) return true; + } + return false; +} + /** * Predicate for value-receiver bridge: the labels for which * `reconcileOwnership` registers methods/fields under the def's diff --git a/gitnexus/src/core/ingestion/scope-resolution/utils/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/scope-resolution/utils/name-fallback-visibility.ts new file mode 100644 index 000000000..2c66f1c05 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/utils/name-fallback-visibility.ts @@ -0,0 +1,97 @@ +/** + * Language-agnostic primitives for `ScopeResolver.isGlobalNameFallbackPlausible` + * implementations. + * + * The hook itself is per-language — the RULES here are not. What every + * implementation needs is the same small set of path arithmetic: which + * directory a file sits in, and whether a module path a caller wrote can name + * a given file or directory. Those questions are about paths, not about any + * language, so they live in shared code (see AGENTS.md: shared ingestion must + * not name languages) and the language files supply only the semantics. + */ + +import type { ParsedFile } from 'gitnexus-shared'; + +/** POSIX-style parent directory. `''` for a file at the repo root. */ +export function directoryOf(filePath: string): string { + const slash = filePath.lastIndexOf('/'); + return slash === -1 ? '' : filePath.slice(0, slash); +} + +/** + * Split a module path into segments, accepting the three separators languages + * spell module nesting with: `/` (Go, Node), `::` (Rust, C++) and `.` (JVM, + * Python). Empty segments and `.` are dropped, so a leading `./` contributes + * nothing. Named root prefixes are NOT stripped here — `crate::a` yields + * `['crate', 'a']`; a language whose paths carry one (Rust's `crate::` / + * `super::`) removes it before calling, see `rustUsePathOf`. + * + * `.` is only treated as a separator when the path contains no `/`: a Node + * specifier like `./util/parse.js` must not split on the extension dot. + */ +export function moduleSegments(modulePath: string): readonly string[] { + const bySlashOrColon = modulePath.split(/\/|::/).filter((s) => s !== '' && s !== '.'); + if (modulePath.includes('/')) return bySlashOrColon; + return bySlashOrColon.flatMap((s) => s.split('.').filter((p) => p !== '')); +} + +/** + * Does a module path a caller wrote reach `targetPath`? + * + * True when either side's segments are a SUFFIX of the other's. Both directions + * are needed and neither alone is sufficient: + * + * - The written path is usually longer than the repo-relative one, because it + * carries a module/package prefix that is not a directory + * (`github.com/org/svc/internal/models` → `internal/models`). + * - The repo-relative path is longer when the manifest that defines the module + * root sits in a subdirectory of the analyzed tree (`svc/go.mod`, so + * `svc/internal/models` is written `mod/internal/models`). + * + * So the match is on ALIGNED TRAILING SEGMENTS, and it succeeds when either the + * shorter side is fully contained in the longer, or at least two segments align. + * Both conditions are needed: full containment covers a one-segment package + * (`mod/models` reaching `models`), while the two-segment floor covers the case + * where neither side contains the other because each carries a different root + * (`mod/internal/models` vs `svc/internal/models`). A SINGLE aligned segment + * with neither side contained is not enough — `handlers` appearing at the end of + * two unrelated trees says nothing. + * + * Tolerance is the SAFE direction here: this predicate is consulted to decide + * whether to REFUSE an edge, so over-matching loses a refusal (the edge stays, + * labeled and excluded from flows) while under-matching loses a real edge. + */ +export function modulePathReaches(writtenPath: string, targetPath: string): boolean { + const written = moduleSegments(writtenPath); + const target = moduleSegments(targetPath); + if (written.length === 0 || target.length === 0) return false; + const shorter = Math.min(written.length, target.length); + let aligned = 0; + while ( + aligned < shorter && + written[written.length - 1 - aligned] === target[target.length - 1 - aligned] + ) { + aligned++; + } + return aligned === shorter || aligned >= 2; +} + +/** Every module path this file's import statements named, in source order. */ +function importedModulePaths(parsed: ParsedFile): readonly string[] { + return parsed.parsedImports.map((imp) => imp.targetRaw); +} + +/** True when any of the caller's imports reaches `targetPath`. */ +export function anyImportReaches(parsed: ParsedFile, targetPath: string): boolean { + for (const written of importedModulePaths(parsed)) { + if (modulePathReaches(written, targetPath)) return true; + } + return false; +} + +/** Strip a trailing file extension. `a/b.rs` → `a/b`; `a/b` → `a/b`. */ +export function stripExtension(filePath: string): string { + const slash = filePath.lastIndexOf('/'); + const dot = filePath.lastIndexOf('.'); + return dot > slash ? filePath.slice(0, dot) : filePath; +} diff --git a/gitnexus/src/core/ingestion/ts-js-export-marker.ts b/gitnexus/src/core/ingestion/ts-js-export-marker.ts new file mode 100644 index 000000000..b01440433 --- /dev/null +++ b/gitnexus/src/core/ingestion/ts-js-export-marker.ts @@ -0,0 +1,345 @@ +/** + * Export evidence for ECMAScript declarations — the `@declaration.is-exported` + * marker both the TypeScript and the JavaScript capture emitters synthesize. + * + * `SymbolDefinition.isExported` is tri-state, and this is where the three + * states are decided for TS/JS: + * + * - `true` — the declaration sits under an `export_statement` (`export + * function f`, `export const x`, `export default class`), or the + * file names it in an `export { f }` / `export { f as g }` clause + * or an `export default f` / `export = f` statement. + * - `false` — an ESM-shaped file (no CommonJS export assignment) that does + * neither. The declaration is module-private: `export *` cannot + * republish it and it must not be counted as a wildcard provider. + * - no verdict — the file exports through CommonJS (`module.exports = …`, + * `exports.x = …`, top-level `this.x = …`) or is an ambient + * `.d.ts`, where "not under `export`" says nothing about what the + * module publishes. Nothing is emitted, and the reader keeps its + * prior behavior. One CommonJS shape IS decidable and gets `true`: + * a method or property declared directly in the object literal + * assigned to `module.exports` (`module.exports = { alpha() {} }`) + * is that module's export of `alpha`. + * + * Only a declaration reached from the top level through DECLARATION nodes can + * be a module export. The walk therefore stops with `false` at the first + * nesting boundary — a class body, an interface/enum body, a function body, an + * object literal that is not the `module.exports` value: `export class C { + * m() {} }` exports `C`, not `m`; `function w() { function s() {} }` exports + * nothing even when the file has `export { s }` for a different `s`. + * + * The ancestor walk here is deliberately NOT `tsExportChecker` + * (`export-detection.ts`): that checker's text fallback (`text.startsWith('export ')`) + * fires on the `program` node of any file whose first token is `export`, which + * would mark every declaration in such a file exported. + */ + +import type { SyntaxNode } from './utils/ast-helpers.js'; + +export interface EsmExportEvidence { + /** Local names published by `export { … }`, `export default `, `export = `. */ + readonly namedLocals: ReadonlySet; + /** The file exports through a CommonJS assignment: a plain "not under + * `export`" is no verdict there. */ + readonly commonJs: boolean; +} + +/** Read binding patterns, never initializer expressions or property keys. */ +function bindsReceiver(node: SyntaxNode | null, name: string): boolean { + if (node === null) return false; + if (node.type === 'identifier' || node.type === 'shorthand_property_identifier_pattern') { + return node.text === name; + } + if (node.type === 'variable_declarator') + return bindsReceiver(node.childForFieldName('name'), name); + if (node.type === 'assignment_pattern') + return bindsReceiver(node.childForFieldName('left'), name); + if (node.type === 'pair_pattern') return bindsReceiver(node.childForFieldName('value'), name); + if (node.type === 'import_specifier') { + return bindsReceiver(node.childForFieldName('alias') ?? node.childForFieldName('name'), name); + } + if (node.type === 'required_parameter' || node.type === 'optional_parameter') { + return bindsReceiver(node.childForFieldName('pattern'), name); + } + return ( + [ + 'formal_parameters', + 'object_pattern', + 'array_pattern', + 'rest_pattern', + 'import_clause', + 'named_imports', + 'namespace_import', + ].includes(node.type) && node.namedChildren.some((child) => bindsReceiver(child, name)) + ); +} + +/** A locally bound `module`/`exports` is not Node's export receiver. */ +function isExportReceiverShadowed(node: SyntaxNode, name: string): boolean { + for (let scope = node.parent; scope !== null; scope = scope.parent) { + if ( + ['function_expression', 'generator_function', 'class'].includes(scope.type) && + scope.childForFieldName('name')?.text === name + ) + return true; + if ( + bindsReceiver(scope.childForFieldName('parameters'), name) || + bindsReceiver(scope.childForFieldName('parameter'), name) + ) + return true; + if (scope.type === 'for_in_statement' || scope.type === 'for_of_statement') { + if (bindsReceiver(scope.childForFieldName('left'), name)) return true; + } + if (scope.type === 'for_statement') { + const initializer = scope.childForFieldName('initializer'); + if ( + initializer !== null && + (initializer.type === 'lexical_declaration' || + initializer.type === 'variable_declaration') && + initializer.namedChildren.some((child) => bindsReceiver(child, name)) + ) + return true; + } + if (scope.type !== 'program' && scope.type !== 'statement_block') continue; + for (const statement of scope.namedChildren) { + const declaration = + statement.type === 'export_statement' + ? statement.childForFieldName('declaration') + : statement; + if (declaration === null) continue; + if ( + declaration.type === 'import_statement' && + declaration.namedChildren.some( + (child) => child.type === 'import_clause' && bindsReceiver(child, name), + ) + ) + return true; + if ( + declaration.type === 'lexical_declaration' || + declaration.type === 'variable_declaration' + ) { + if (declaration.namedChildren.some((child) => bindsReceiver(child, name))) return true; + } else if ( + ['function_declaration', 'class_declaration'].includes(declaration.type) && + declaration.childForFieldName('name')?.text === name + ) + return true; + } + } + return false; +} + +/** Static dot and bracket spellings of the same CommonJS export object. */ +function isModuleExportsReference(node: SyntaxNode): boolean { + const object = node.childForFieldName('object'); + if ( + object?.type !== 'identifier' || + object.text !== 'module' || + isExportReceiverShadowed(node, 'module') + ) + return false; + if (node.type === 'member_expression') { + return node.childForFieldName('property')?.text === 'exports'; + } + if (node.type !== 'subscript_expression') return false; + const index = node.childForFieldName('index'); + return index?.type === 'string' && (index.text === "'exports'" || index.text === '"exports"'); +} + +/** + * Does the file touch a CommonJS export object anywhere — `module.exports` or + * `exports.x` / `exports[x]` — as an actual expression? Read from AST nodes, + * not source text, so a comment or string mentioning `module.exports` does not + * disable the file's verdicts. + */ +function hasCommonJsExportSurface(root: SyntaxNode): boolean { + for (const member of root.descendantsOfType('member_expression')) { + const object = member.childForFieldName('object'); + if (object === null) continue; + if ( + object.type === 'identifier' && + object.text === 'exports' && + !isExportReceiverShadowed(member, 'exports') + ) + return true; + if (isModuleExportsReference(member)) return true; + } + for (const sub of root.descendantsOfType('subscript_expression')) { + const object = sub.childForFieldName('object'); + if ( + object?.type === 'identifier' && + object.text === 'exports' && + !isExportReceiverShadowed(sub, 'exports') + ) + return true; + if (isModuleExportsReference(sub)) return true; + } + return false; +} + +/** + * Top-level control-flow wrappers around `this.x =` / `this['x'] =`. + * Recurse through these only — a function or class body is a different `this`. + */ +const TOP_LEVEL_THIS_WALK: ReadonlySet = new Set([ + 'if_statement', + 'else_clause', + 'for_statement', + 'for_in_statement', + 'for_of_statement', + 'while_statement', + 'do_statement', + 'switch_statement', + 'switch_body', + 'switch_case', + 'switch_default', + 'try_statement', + 'catch_clause', + 'finally_clause', + 'labeled_statement', + 'statement_block', +]); + +/** Module-wrapper `this.x =` / `this['x'] =` as a statement. */ +function isThisExportAssignment(stmt: SyntaxNode): boolean { + if (stmt.type !== 'expression_statement') return false; + const assignment = stmt.namedChildren[0]; + const left = + assignment?.type === 'assignment_expression' ? assignment.childForFieldName('left') : null; + return ( + left !== null && + (left.type === 'member_expression' || left.type === 'subscript_expression') && + left.childForFieldName('object')?.type === 'this' + ); +} + +/** + * A top-level `this` export, including `if (enabled) this.api = api` and the + * braced form. Does not walk into function or class bodies. + */ +function hasTopLevelThisExportAssignment(stmt: SyntaxNode): boolean { + if (isThisExportAssignment(stmt)) return true; + if (!TOP_LEVEL_THIS_WALK.has(stmt.type)) return false; + return stmt.namedChildren.some(hasTopLevelThisExportAssignment); +} + +/** Node types below which a declaration is nested, not module-level. */ +const NESTING_BOUNDARIES: ReadonlySet = new Set([ + 'class_body', + 'interface_body', + 'enum_body', + 'object_type', + 'statement_block', + 'arrow_function', + 'function_expression', + 'function_declaration', + 'generator_function', + 'generator_function_declaration', + 'method_definition', + // `export namespace NS { export function f() {} }` / `declare module 'x' { + // export function q(): void }`: an `export` inside these bodies is an export + // of the namespace/ambient module, not of the file. + 'internal_module', + 'module', + 'ambient_declaration', +]); + +/** + * Scan a file's top level once. `undefined` means the file's export surface + * cannot be read at all (ambient `.d.ts`), so no marker should be emitted. + */ +export function collectEsmExportEvidence( + root: SyntaxNode, + filePath: string, +): EsmExportEvidence | undefined { + if (filePath.endsWith('.d.ts')) return undefined; + const namedLocals = new Set(); + // Any CommonJS export surface anywhere in the file — a direct `module.exports + // = …`, an alias (`const m = module.exports; m.x = …`), an `exports.x` — means + // "not under `export`" says nothing. + let commonJs = hasCommonJsExportSurface(root); + for (const stmt of root.namedChildren) { + if (stmt.type === 'export_statement') { + // `export { a } from './x'` / `export type { T } from './t'` re-export + // ANOTHER module's names: they say nothing about a local `a`, and adding + // them here marked a private local of the same name exported. + if (stmt.childForFieldName('source') !== null) continue; + for (const child of stmt.namedChildren) { + if (child.type === 'export_clause') { + for (const spec of child.namedChildren) { + if (spec.type !== 'export_specifier') continue; + const name = spec.childForFieldName('name')?.text; + if (name !== undefined && name !== '') namedLocals.add(name); + } + } else if (child.type === 'identifier') { + // `export default f;` and TS `export = f;`. + namedLocals.add(child.text); + } + } + } else if (hasTopLevelThisExportAssignment(stmt)) { + commonJs = true; + } + } + return { namedLocals, commonJs }; +} + +/** Is `object` the value of a top-level `module.exports = { … }` assignment? */ +function isModuleExportsObject(object: SyntaxNode): boolean { + const assignment = object.parent; + if (assignment === null || assignment.type !== 'assignment_expression') return false; + if (assignment.childForFieldName('right')?.id !== object.id) return false; + const left = assignment.childForFieldName('left'); + if (left === null || !isModuleExportsReference(left)) return false; + return ( + assignment.parent?.type === 'expression_statement' && + assignment.parent.parent?.type === 'program' + ); +} + +/** + * The export verdict for a declaration whose NAME node is `nameNode`: + * `true` / `false` as documented in the header, `undefined` when this file's + * export surface cannot decide it (CommonJS, other than the `module.exports` + * object literal itself). + */ +export function esmExportVerdict( + nameNode: SyntaxNode, + evidence: EsmExportEvidence, +): boolean | undefined { + // The name node's own declaration node is where the walk starts; the + // declaration itself (a `method_definition`, a `function_declaration`) must + // not count as its own nesting boundary. + let current: SyntaxNode | null = nameNode.parent; + // An `export` keyword is only a FILE-level export when the walk reaches the + // program without crossing a nesting boundary — one inside a namespace or + // ambient-module body is that container's export (see NESTING_BOUNDARIES). + let underExport = false; + let functionScopedVariable = false; + while (current !== null && current.type !== 'program') { + if (current.type === 'variable_declaration') functionScopedVariable = true; + if (current.type === 'export_statement') { + underExport = true; + current = current.parent; + continue; + } + if (current.type === 'object') { + // `module.exports = { alpha() {} }`: the literal's own members are the + // module's exports. Any other object literal is a nesting boundary. + if (isModuleExportsObject(current) && nameNode.parent?.parent?.id === current.id) return true; + return evidence.commonJs ? undefined : false; + } + if (NESTING_BOUNDARIES.has(current.type) && current.id !== nameNode.parent?.id) { + // `var` crosses blocks but never a function/namespace boundary. A + // module-scoped `var` can therefore be exported by a later clause. + if (current.type === 'statement_block' && functionScopedVariable) { + current = current.parent; + continue; + } + return evidence.commonJs ? undefined : false; + } + current = current.parent; + } + if (underExport) return true; + if (evidence.commonJs) return undefined; + return evidence.namedLocals.has(nameNode.text); +} diff --git a/gitnexus/src/core/lbug/graph-emit-sink.ts b/gitnexus/src/core/lbug/graph-emit-sink.ts index 6b263701f..d13ab47c7 100644 --- a/gitnexus/src/core/lbug/graph-emit-sink.ts +++ b/gitnexus/src/core/lbug/graph-emit-sink.ts @@ -70,7 +70,7 @@ * replaced, so the obvious rewrite is not the one that shipped. * 3. The remaining ~90 ms was object allocation itself, irreducible while the * read API returns objects — so the five whole-graph scans moved to - * `forEachRelationshipFields`, which passes the four fields they actually + * `forEachRelationshipFields`, which passes the five fields they actually * read as primitives and allocates nothing. See * {@link GraphEmitSink.forEachRelationshipFields}. * @@ -282,14 +282,19 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { * safe because `buildRelRow` never persists `rel.id` and no consumer keys on * it (audited). * - * The dropped `reason`/`step` are safe too, but for a different reason worth - * stating: the PERSISTED row keeps their true values, because `buildRelRow` is - * handed the original relationship on the way through. Only in-memory reads - * see the `'streamed'` placeholder, and the in-pipeline consumers of streamed - * edges read neither field. So e.g. the `ACCESSES reason: 'read'|'write'` - * distinction that MCP queries rely on survives in the database. A future - * in-pipeline consumer needing `reason` or `step` on a streamed edge must add - * the column, not trust the placeholder. + * `reason` IS now retained, as an interned index — the in-pipeline consumer + * this JSDoc anticipated arrived. Process tracing and large-graph community + * detection must exclude global-name-fallback edges, which are emitted at + * exactly their confidence threshold (0.5) and so cannot be separated by + * confidence alone. Interning keeps the cost at one small integer per edge + * (the reason vocabulary is a fixed set of literals), not one string. + * + * `id` and `step` remain dropped. The PERSISTED row keeps `step`'s true value, + * because `buildRelRow` is handed the original relationship on the way + * through; only in-memory OBJECT reads see the `'streamed'`-era placeholder, + * and no in-pipeline consumer of streamed edges reads `step`. A future + * in-pipeline consumer needing `step` must add the column, not trust the + * placeholder. * * Node ids are interned; the strings are shared by reference with the node * map's, so interning adds bookkeeping, not new text. @@ -300,6 +305,12 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { private readonly tgtIx: number[] = []; private readonly relTypes: RelationshipType[] = []; private readonly confidences: number[] = []; + /** Interned reason strings, and the per-edge index into them. The vocabulary + * is a fixed set of emitter literals, so this is O(vocabulary) text plus one + * small integer per edge. */ + private readonly reasonIds = new Map(); + private readonly reasonByIx: string[] = []; + private readonly reasonIx: number[] = []; private finalized = false; /** * Streaming is OFF until {@link beginStreaming} is called by `parse`. @@ -472,6 +483,16 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { this.tgtIx.push(tgtIx); this.relTypes.push(relationship.type); this.confidences.push(relationship.confidence); + this.reasonIx.push(this.internReason(relationship.reason)); + } + + private internReason(reason: string): number { + const existing = this.reasonIds.get(reason); + if (existing !== undefined) return existing; + const ix = this.reasonByIx.length; + this.reasonByIx.push(reason); + this.reasonIds.set(reason, ix); + return ix; } /** Flush + close every writer and return the COPY manifest. Every fd is @@ -599,7 +620,13 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { * with the object-based graph despite holding relationships columnar. */ forEachRelationshipFields( - fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void, + fn: ( + sourceId: string, + targetId: string, + type: RelationshipType, + confidence: number, + reason: string, + ) => void, ): void { this.real.forEachRelationshipFields(fn); for (let ix = 0; ix < this.srcIx.length; ix++) { @@ -608,6 +635,7 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { this.nodeIdByIx[this.tgtIx[ix]], this.relTypes[ix], this.confidences[ix], + this.reasonByIx[this.reasonIx[ix]], ); } } diff --git a/gitnexus/src/core/lbug/pdg-emit-sink.ts b/gitnexus/src/core/lbug/pdg-emit-sink.ts index 12db53ac4..fafcd2c53 100644 --- a/gitnexus/src/core/lbug/pdg-emit-sink.ts +++ b/gitnexus/src/core/lbug/pdg-emit-sink.ts @@ -299,7 +299,13 @@ export class PdgEmitSink implements KnowledgeGraph { this.real.forEachRelationship(fn); } forEachRelationshipFields( - fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void, + fn: ( + sourceId: string, + targetId: string, + type: RelationshipType, + confidence: number, + reason: string, + ) => void, ): void { this.real.forEachRelationshipFields(fn); } diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 8b70ef10e..b7b9685b8 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -17,6 +17,11 @@ import { constants as fsConstants } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { retryRename } from '../storage/fs-atomic.js'; import { acquireIndexLock } from '../storage/index-lock.js'; +import { + logNameFallbackSummary, + summarizeNameFallback, + countCallsByLanguage, +} from './ingestion/scope-resolution/name-fallback-summary.js'; import { runPipelineFromRepo } from './ingestion/pipeline.js'; import { logUnresolvedReceiverFiles, @@ -3861,6 +3866,14 @@ async function runFullAnalysisInner( const resolutionOutcomes = pipelineResult.resolutionOutcomes ?? []; logUnresolvedReceiverFiles(resolutionOutcomes); + // Census of guessed call sites (before edge coalescing), refused candidates + // and ambiguous `export *` names. The legacy `nameFallbackEdges` metadata + // key stores site counts, not the final population of heuristic edges. + const nameFallbackSummary = summarizeNameFallback( + resolutionOutcomes, + countCallsByLanguage(pipelineResult.resolvedCalleeNamesByCaller, pipelineResult.graph), + ); + logNameFallbackSummary(nameFallbackSummary); // Annotated so the capabilities stamp below is compile-checked against // RepoMeta's status unions (tri-review 4669518496 P1/U3) — an unannotated @@ -3977,6 +3990,7 @@ async function runFullAnalysisInner( // Git-only: non-git repos never take the incremental path. schemaFingerprint: hasGitDir(repoPath) ? SCHEMA_FINGERPRINT : undefined, unresolvedReceiverMembers: summarizeUnresolvedReceivers(resolutionOutcomes), + nameFallbackEdges: nameFallbackSummary, scopeExtractionFailures: summarizeScopeExtractionFailures( pipelineResult.scopeExtractionFailures, ), diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index a06f646df..0ce2b7dab 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -759,14 +759,11 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // USES edge, `callableValueReferenceBoundaries` measures a real zero, and // `impact` on a registered accessor republishes `epistemic: "exact"` — the exact // #3399 defect this change exists to close, silently un-fixed. -// -// 94-97 went to #3179 (Objective-C), which has since merged; 98 is the next -// value above it. Re-checked against origin/main and every open PR touching -// this file at merge time, which is the rule the paragraphs above were written -// by PRs that each checked only once: main is at 97, and the remaining open -// claims (#3190 at 94, #2840 at 71, #1616 at 2) all sit below it and must -// re-bump themselves. -const SCHEMA_BUMP = 98; +// v99: ParsedImport retains declaredAtScope and export evidence changes in +// #3190. Old durable ParsedFiles lack the facts needed for scoped binding; +// invalidate both stores so warm indexing actually applies the correction. +// origin/main took 98 for #3219; 99 is the next free value. +const SCHEMA_BUMP = 99; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/src/storage/repo-meta.ts b/gitnexus/src/storage/repo-meta.ts index f65fc8ec1..3dddc911a 100644 --- a/gitnexus/src/storage/repo-meta.ts +++ b/gitnexus/src/storage/repo-meta.ts @@ -28,6 +28,7 @@ import fs from 'fs/promises'; import path from 'path'; import type { UnresolvedReceiverSummary } from '../core/ingestion/scope-resolution/unresolved-receivers.js'; +import type { NameFallbackSummary } from '../core/ingestion/scope-resolution/name-fallback-summary.js'; import type { UndecidedSatisfactionSummary } from '../core/ingestion/scope-resolution/undecided-satisfaction.js'; import type { ScopeExtractionFailureSummary } from '../core/ingestion/scope-resolution/scope-extraction-failures.js'; @@ -311,6 +312,15 @@ export interface RepoMeta { * reads as absent, and both correctly mean "no hedge available from here". */ undecidedInterfaceSatisfaction?: UndecidedSatisfactionSummary; + /** + * Census of name-guessed call sites before edge coalescing, the impossible + * candidates the run refused, and the ambiguous + * `export *` names it declined to publish. Absent on indexes built before the + * census existed. The legacy key does not imply final edge counts: a precise + * site may prove a dependency shared with a guessed site. + * See `scope-resolution/name-fallback-summary.ts`. + */ + nameFallbackEdges?: NameFallbackSummary; /** * SHA-256 of every file's content at the time of the last successful * indexing run. The next run computes current hashes and diffs against diff --git a/gitnexus/src/types/pipeline.ts b/gitnexus/src/types/pipeline.ts index d665a7acf..4136cca42 100644 --- a/gitnexus/src/types/pipeline.ts +++ b/gitnexus/src/types/pipeline.ts @@ -33,6 +33,14 @@ export interface PipelineResult { * produced; graph edge semantics are unchanged. */ resolutionOutcomes: readonly ResolutionOutcome[]; + /** + * Caller node id → simple names of every callee it has a CALLS edge to, read + * through the streaming sink when one was active (the raw graph holds no + * streamed edge). Denominator for the name-fallback census + * (`countCallsByLanguage`), so a guess count can be read as a share of the + * call graph. Absent only when scope resolution did not run. + */ + resolvedCalleeNamesByCaller?: ReadonlyMap>; /** * Interfaces whose structural-satisfaction check could not be completed * (#2873). Empty for languages with no structural detection. diff --git a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json index c8c5d6efe..0035a60c4 100644 --- a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json @@ -85,7 +85,7 @@ }, "go-constructor-type-inference/cmd/main.go": { "captureGroups": 19, - "digest": "4f2e8bb6dbca59bcb82fc2efd43899b7a92816af28811cfc1858f0ce7ba9f6fe" + "digest": "8dff73176d5890d7ede1d8b9cd9f8555518a1dcc11fbf02190beb474c0f9e47e" }, "go-constructor-type-inference/models/repo.go": { "captureGroups": 8, @@ -252,8 +252,8 @@ "digest": "ad4cfabcd53397ab664ba4bb05df4e1a803455b5c255a19e2ed283c6523a1660" }, "go-method-enrichment/app.go": { - "captureGroups": 15, - "digest": "35929a31f6921feb074f50385aa91879e93f25b88d7f50774fddcfbb7b4e8539" + "captureGroups": 16, + "digest": "b2ab0936ab28e22d0333119838c929ec6e3d460cc0be8d27aa4d178b9f6452d3" }, "go-mixed-chain/cmd/main.go": { "captureGroups": 22, diff --git a/gitnexus/test/fixtures/lang-resolution/go-method-enrichment/app.go b/gitnexus/test/fixtures/lang-resolution/go-method-enrichment/app.go index 9de68c3b0..c2734574a 100644 --- a/gitnexus/test/fixtures/lang-resolution/go-method-enrichment/app.go +++ b/gitnexus/test/fixtures/lang-resolution/go-method-enrichment/app.go @@ -1,9 +1,10 @@ -package main - -import "animal" +// This fixture exercises method enrichment and a valid same-package free call. +// A different package with a normal import cannot refer to Dog or Classify bare. +package animal func main() { dog := Dog{} sound := dog.Speak() category := Classify("dog") + _, _ = sound, category } diff --git a/gitnexus/test/integration/resolvers/barrel-dir-index-wildcard.test.ts b/gitnexus/test/integration/resolvers/barrel-dir-index-wildcard.test.ts new file mode 100644 index 000000000..276015b25 --- /dev/null +++ b/gitnexus/test/integration/resolvers/barrel-dir-index-wildcard.test.ts @@ -0,0 +1,136 @@ +/** + * C2 — the grafana `Button` shape: a workspace barrel `export *`s a components + * index, which re-exports NAMED bindings (with inline `type` modifiers) from a + * DIRECTORY index, which `export *`s the real file, whose `Button` is a + * `React.forwardRef` const. Measured on grafana@871af0720: `Button` resolved 8 + * of 475 ledger entries while siblings through plain hops resolved at scale. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import path from 'path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { + getRelationships, + getResolutionOutcomes, + runPipelineFromRepo, + writeFixtureRepo, + type PipelineResult, +} from './helpers.js'; + +describe('named re-export through a directory index that wildcards (grafana Button shape)', () => { + let result: PipelineResult; + let repoDir: string | undefined; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-c2-dir-index-')); + writeFixtureRepo(repoDir, { + 'package.json': '{ "name": "root", "private": true, "workspaces": ["packages/*"] }\n', + 'packages/ui/package.json': + '{ "name": "@x/ui", "version": "1.0.0", "main": "src/index.ts" }\n', + 'packages/ui/src/index.ts': `export * from './components';\nexport * from './themes';\n`, + // Inline `type` modifiers on the same statement as value re-exports. + 'packages/ui/src/components/index.ts': `export { Stack } from './Layout/Stack'; +export { Button, LinkButton, type ButtonVariant, ButtonGroup, type ButtonProps, clearButtonStyles } from './Button'; +`, + // Directory index: wildcard + one named re-export. + 'packages/ui/src/components/Button/index.ts': `export * from './Button';\nexport { ButtonGroup } from './ButtonGroup';\n`, + 'packages/ui/src/components/Button/Button.tsx': `import React from 'react'; +export type ButtonVariant = 'primary' | 'secondary'; +export interface ButtonProps { variant?: ButtonVariant; label: string } +export const Button = React.forwardRef((props, ref) => { + return null; +}); +export const LinkButton = React.forwardRef((props, ref) => { + return null; +}); +export const clearButtonStyles = (theme: string) => { + return theme; +}; +`, + 'packages/ui/src/components/Button/ButtonGroup.tsx': `export function ButtonGroup(children: string) { + return children; +} +`, + 'packages/ui/src/components/Layout/Stack.tsx': `export function Stack(children: string) { + return children; +} +`, + 'packages/ui/src/themes/index.ts': `export * from './hooks';\n`, + 'packages/ui/src/themes/hooks/index.ts': `export * from './useStyles2';\n`, + 'packages/ui/src/themes/hooks/useStyles2.ts': `export function useStyles2(fn: (t: string) => string) { + return fn('theme'); +} +`, + 'packages/app/package.json': + '{ "name": "@x/app", "version": "1.0.0", "main": "src/main.tsx", "dependencies": { "@x/ui": "1.0.0" } }\n', + 'packages/app/tsconfig.json': `{ "compilerOptions": { "jsx": "react-jsx" } }\n`, + 'packages/app/src/main.tsx': `import { Button, LinkButton, ButtonGroup, clearButtonStyles, Stack, useStyles2 } from '@x/ui'; + +export function render() { + const styles = useStyles2((t) => t); + clearButtonStyles(styles); + ButtonGroup('x'); + Stack('y'); + const a =