gitnexus 1.6.5-rc.44 → 1.6.5-rc.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -60,8 +60,8 @@ export declare function isCppInlineNamespaceScope(scopeId: ScopeId): boolean;
60
60
  * Returns the most specific (innermost) match — for `outer::foo()`
61
61
  * where `inline namespace v1` declares `foo`, returns `v1::foo`. When
62
62
  * multiple inline-namespace children declare the same name, ISO C++
63
- * leaves the call ambiguous; V1 returns the first match in source
64
- * order (stable across runs).
63
+ * leaves the call ambiguous; returns `'ambiguous'` so the caller
64
+ * suppresses edge emission rather than picking arbitrarily (#1564).
65
65
  */
66
- export declare function resolveCppQualifiedNamespaceMember(receiverName: string, memberName: string, parsedFiles: readonly ParsedFile[], _scopes: ScopeResolutionIndexes): SymbolDefinition | undefined;
66
+ export declare function resolveCppQualifiedNamespaceMember(receiverName: string, memberName: string, parsedFiles: readonly ParsedFile[], _scopes: ScopeResolutionIndexes): SymbolDefinition | 'ambiguous' | undefined;
67
67
  export {};
@@ -26,6 +26,7 @@
26
26
  * `std::vector` qualified calls resolve to the inline-namespace
27
27
  * declaration transparently.
28
28
  */
29
+ import { isOverloadAmbiguousAfterNormalization, narrowOverloadCandidates, } from '../../scope-resolution/passes/overload-narrowing.js';
29
30
  const inlineNamespaceRangesByFile = new Map();
30
31
  const inlineNamespaceScopeIds = new Set();
31
32
  function rangeKey(r) {
@@ -80,10 +81,12 @@ export function isCppInlineNamespaceScope(scopeId) {
80
81
  * Returns the most specific (innermost) match — for `outer::foo()`
81
82
  * where `inline namespace v1` declares `foo`, returns `v1::foo`. When
82
83
  * multiple inline-namespace children declare the same name, ISO C++
83
- * leaves the call ambiguous; V1 returns the first match in source
84
- * order (stable across runs).
84
+ * leaves the call ambiguous; returns `'ambiguous'` so the caller
85
+ * suppresses edge emission rather than picking arbitrarily (#1564).
85
86
  */
86
87
  export function resolveCppQualifiedNamespaceMember(receiverName, memberName, parsedFiles, _scopes) {
88
+ const allHits = [];
89
+ const seenNodeId = new Set();
87
90
  for (const parsed of parsedFiles) {
88
91
  const scopesById = new Map();
89
92
  for (const sc of parsed.scopes)
@@ -97,27 +100,58 @@ export function resolveCppQualifiedNamespaceMember(receiverName, memberName, par
97
100
  const nsName = nsDef.qualifiedName?.split('.').pop() ?? nsDef.qualifiedName ?? '';
98
101
  if (nsName !== receiverName)
99
102
  continue;
100
- // Found a matching namespace scope in this file. Collect the
101
- // member transitively through any inline-namespace children.
102
- const hit = findMemberInNamespaceTransitive(scope, scopesById, memberName);
103
- if (hit !== undefined)
104
- return hit;
103
+ // Found a matching namespace scope in this file. Collect ALL
104
+ // members transitively through any inline-namespace children.
105
+ const hits = findMemberInNamespaceTransitive(scope, scopesById, memberName);
106
+ for (const hit of hits) {
107
+ if (seenNodeId.has(hit.nodeId))
108
+ continue;
109
+ seenNodeId.add(hit.nodeId);
110
+ allHits.push(hit);
111
+ }
105
112
  }
106
113
  }
107
- return undefined;
114
+ if (allHits.length === 0)
115
+ return undefined;
116
+ if (allHits.length === 1)
117
+ return allHits[0];
118
+ // Multi-candidate: the `resolveQualifiedReceiverMember` hook has no
119
+ // access to call-site arity or argument types, so
120
+ // `narrowOverloadCandidates` cannot actually narrow here — the call
121
+ // with `(allHits, undefined, undefined)` is effectively a pass-through.
122
+ // We retain it so that `isOverloadAmbiguousAfterNormalization` can
123
+ // still detect int/long-style normalization collisions on this path,
124
+ // but for any multi-hit case where candidates have genuinely distinct
125
+ // signatures (e.g. `foo(int)` vs `foo(double)` in different inline
126
+ // children), we conservatively suppress rather than pick arbitrarily.
127
+ // A future enhancement could thread call-site argument info through
128
+ // the `resolveQualifiedReceiverMember` contract to enable real
129
+ // narrowing here.
130
+ const narrowed = narrowOverloadCandidates(allHits, undefined, undefined);
131
+ if (narrowed.length === 1)
132
+ return narrowed[0];
133
+ if (narrowed.length === 0)
134
+ return undefined;
135
+ if (isOverloadAmbiguousAfterNormalization(narrowed, undefined))
136
+ return 'ambiguous';
137
+ // Multiple surviving candidates (distinct signatures) — conservative
138
+ // suppress because we lack call-site info to disambiguate.
139
+ return 'ambiguous';
108
140
  }
109
141
  /** Recursively search a namespace scope and any inline-namespace
110
- * descendants for a callable def with the given simple name. Non-inline
142
+ * descendants for callable defs with the given simple name. Non-inline
111
143
  * nested namespaces are NOT traversed — they require explicit
112
- * qualification (`outer::nested::foo`). */
144
+ * qualification (`outer::nested::foo`). Returns ALL matches so the
145
+ * caller can detect same-name ambiguity across inline children (#1564). */
113
146
  function findMemberInNamespaceTransitive(scope, scopesById, memberName) {
147
+ const results = [];
114
148
  // Check this scope's own ownedDefs first.
115
149
  for (const def of scope.ownedDefs) {
116
150
  if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor')
117
151
  continue;
118
152
  const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
119
153
  if (simple === memberName)
120
- return def;
154
+ results.push(def);
121
155
  }
122
156
  // Descend into inline-namespace children.
123
157
  for (const childScope of scopesById.values()) {
@@ -127,11 +161,11 @@ function findMemberInNamespaceTransitive(scope, scopesById, memberName) {
127
161
  continue;
128
162
  if (!inlineNamespaceScopeIds.has(childScope.id))
129
163
  continue;
130
- const hit = findMemberInNamespaceTransitive(childScope, scopesById, memberName);
131
- if (hit !== undefined)
132
- return hit;
164
+ const childHits = findMemberInNamespaceTransitive(childScope, scopesById, memberName);
165
+ for (const hit of childHits)
166
+ results.push(hit);
133
167
  }
134
- return undefined;
168
+ return results;
135
169
  }
136
170
  function findNamespaceDefInScope(scope) {
137
171
  for (const def of scope.ownedDefs) {
@@ -205,7 +205,7 @@ export const cppScopeResolver = {
205
205
  if (imp.localName !== site.name)
206
206
  continue;
207
207
  const member = resolveCppQualifiedNamespaceMember(imp.targetRaw, imp.importedName, parsedFiles, scopes);
208
- if (member === undefined)
208
+ if (member === undefined || member === 'ambiguous')
209
209
  continue;
210
210
  if (seenUsing.has(member.nodeId))
211
211
  continue;
@@ -542,10 +542,11 @@ export interface ScopeResolver {
542
542
  *
543
543
  * Receiver-bound-calls invokes this hook AFTER Case 1 (namespace
544
544
  * imports) and AFTER Case 2 (class-name receiver) fail to resolve.
545
- * Returns the target def, or `undefined` to fall through to the
546
- * remaining cases.
545
+ * Returns the target def, `'ambiguous'` when multiple inline-namespace
546
+ * children declare the same name (suppresses edge emission), or
547
+ * `undefined` to fall through to the remaining cases.
547
548
  */
548
- readonly resolveQualifiedReceiverMember?: (receiverName: string, memberName: string, callerScope: ScopeId, scopes: ScopeResolutionIndexes, parsedFiles: readonly ParsedFile[]) => SymbolDefinition | undefined;
549
+ readonly resolveQualifiedReceiverMember?: (receiverName: string, memberName: string, callerScope: ScopeId, scopes: ScopeResolutionIndexes, parsedFiles: readonly ParsedFile[]) => SymbolDefinition | 'ambiguous' | undefined;
549
550
  /**
550
551
  * Enable the receiver-bound Case 0.5 fallback for explicit `this`
551
552
  * receivers (`this->m()` / `this.m()`) that resolves against the
@@ -324,6 +324,12 @@ export function emitReceiverBoundCalls(graph, scopes, parsedFiles, nodeLookup, h
324
324
  // class with the same simple name.
325
325
  if (provider.resolveQualifiedReceiverMember !== undefined) {
326
326
  const memberDef = provider.resolveQualifiedReceiverMember(receiverName, memberName, site.inScope, scopes, parsedFiles);
327
+ if (memberDef === 'ambiguous') {
328
+ // Same-name ambiguity across inline-namespace children (#1564):
329
+ // suppress edge emission, mark site handled.
330
+ handledSites.add(siteKey);
331
+ continue;
332
+ }
327
333
  if (memberDef !== undefined) {
328
334
  const ok = tryEmitEdge(graph, scopes, nodeLookup, site, memberDef, memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global', seen, 0.85, collapse);
329
335
  if (ok)
@@ -55,8 +55,14 @@ export declare const computeChunkHash: (entries: Array<{
55
55
  */
56
56
  export declare const loadParseCache: (storagePath: string) => Promise<ParseCache>;
57
57
  /**
58
- * Persist the cache to disk atomically (write-and-rename) so a crash
59
- * mid-write doesn't leave a corrupt file.
58
+ * Persist the cache to disk using a temp directory + rename.
59
+ *
60
+ * Writes shards under `${cacheDir}.tmp`, then removes the old `cacheDir` and
61
+ * renames the temp directory into place. There is a crash window after
62
+ * `rm(cacheDir)` and before `rename(tmpDir, cacheDir)` where no cache exists;
63
+ * that is acceptable — `loadParseCache` yields empty and the next run
64
+ * reparses. This is not a single atomic swap of the whole tree, but avoids
65
+ * leaving a half-written shard set visible to readers.
60
66
  */
61
67
  export declare const saveParseCache: (storagePath: string, cache: ParseCache) => Promise<void>;
62
68
  /**
@@ -69,7 +69,12 @@ const GITNEXUS_PKG_VERSION = (() => {
69
69
  return '0.0.0-unknown';
70
70
  })();
71
71
  export const PARSE_CACHE_VERSION = `${SCHEMA_BUMP}+${GITNEXUS_PKG_VERSION}`;
72
- const CACHE_FILENAME = 'parse-cache.json';
72
+ const LEGACY_CACHE_FILENAME = 'parse-cache.json';
73
+ const CACHE_DIRNAME = 'parse-cache';
74
+ const CACHE_INDEX_FILENAME = 'index.json';
75
+ /** Keys on disk always come from `computeChunkHash` — 64-char lowercase hex. */
76
+ const CHUNK_CACHE_KEY_HEX_RE = /^[a-f0-9]{64}$/;
77
+ const isValidChunkCacheKey = (chunkHash) => CHUNK_CACHE_KEY_HEX_RE.test(chunkHash);
73
78
  /** SHA-256 hex of a single string or buffer. */
74
79
  const sha256Hex = (input) => createHash('sha256')
75
80
  .update(typeof input === 'string' ? Buffer.from(input) : input)
@@ -116,12 +121,12 @@ const mapReviver = (_key, value) => {
116
121
  }
117
122
  return value;
118
123
  };
119
- /**
120
- * Load the parse cache. Returns an empty cache on any failure (missing
121
- * file, corrupt JSON, version mismatch). Never throws on a normal load.
122
- */
123
- export const loadParseCache = async (storagePath) => {
124
- const cachePath = path.join(storagePath, CACHE_FILENAME);
124
+ const getLegacyCachePath = (storagePath) => path.join(storagePath, LEGACY_CACHE_FILENAME);
125
+ const getCacheDirPath = (storagePath) => path.join(storagePath, CACHE_DIRNAME);
126
+ const getCacheIndexPath = (storagePath) => path.join(getCacheDirPath(storagePath), CACHE_INDEX_FILENAME);
127
+ const getCacheChunkPath = (storagePath, chunkHash) => path.join(getCacheDirPath(storagePath), `${chunkHash}.json`);
128
+ const loadLegacyParseCache = async (storagePath) => {
129
+ const cachePath = getLegacyCachePath(storagePath);
125
130
  try {
126
131
  const raw = await fs.readFile(cachePath, 'utf-8');
127
132
  const data = JSON.parse(raw, mapReviver);
@@ -143,22 +148,88 @@ export const loadParseCache = async (storagePath) => {
143
148
  return emptyCache();
144
149
  }
145
150
  };
151
+ const loadShardedParseCache = async (storagePath) => {
152
+ const indexPath = getCacheIndexPath(storagePath);
153
+ try {
154
+ const raw = await fs.readFile(indexPath, 'utf-8');
155
+ const data = JSON.parse(raw);
156
+ if (typeof data !== 'object' ||
157
+ data === null ||
158
+ data.version !== PARSE_CACHE_VERSION ||
159
+ !Array.isArray(data.keys)) {
160
+ return emptyCache();
161
+ }
162
+ const entries = new Map();
163
+ for (const chunkHash of data.keys) {
164
+ if (typeof chunkHash !== 'string' || !isValidChunkCacheKey(chunkHash))
165
+ continue;
166
+ try {
167
+ const chunkRaw = await fs.readFile(getCacheChunkPath(storagePath, chunkHash), 'utf-8');
168
+ const chunkData = JSON.parse(chunkRaw, mapReviver);
169
+ if (Array.isArray(chunkData))
170
+ entries.set(chunkHash, chunkData);
171
+ }
172
+ catch {
173
+ /* skip corrupt or missing shard */
174
+ }
175
+ }
176
+ return { version: PARSE_CACHE_VERSION, entries, usedKeys: new Set() };
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ };
182
+ /**
183
+ * Load the parse cache. Returns an empty cache on any failure (missing
184
+ * file, corrupt JSON, version mismatch). Never throws on a normal load.
185
+ */
186
+ export const loadParseCache = async (storagePath) => {
187
+ const sharded = await loadShardedParseCache(storagePath);
188
+ if (sharded)
189
+ return sharded;
190
+ return loadLegacyParseCache(storagePath);
191
+ };
146
192
  /**
147
- * Persist the cache to disk atomically (write-and-rename) so a crash
148
- * mid-write doesn't leave a corrupt file.
193
+ * Persist the cache to disk using a temp directory + rename.
194
+ *
195
+ * Writes shards under `${cacheDir}.tmp`, then removes the old `cacheDir` and
196
+ * renames the temp directory into place. There is a crash window after
197
+ * `rm(cacheDir)` and before `rename(tmpDir, cacheDir)` where no cache exists;
198
+ * that is acceptable — `loadParseCache` yields empty and the next run
199
+ * reparses. This is not a single atomic swap of the whole tree, but avoids
200
+ * leaving a half-written shard set visible to readers.
149
201
  */
150
202
  export const saveParseCache = async (storagePath, cache) => {
151
203
  await fs.mkdir(storagePath, { recursive: true });
152
- const cachePath = path.join(storagePath, CACHE_FILENAME);
153
- const tmpPath = `${cachePath}.tmp`;
154
- const out = {
204
+ const cacheDir = getCacheDirPath(storagePath);
205
+ const tmpDir = `${cacheDir}.tmp`;
206
+ await fs.rm(tmpDir, { recursive: true, force: true });
207
+ await fs.mkdir(tmpDir, { recursive: true });
208
+ const keys = [];
209
+ for (const [chunkHash, chunkResults] of cache.entries) {
210
+ if (!isValidChunkCacheKey(chunkHash))
211
+ continue;
212
+ let payload;
213
+ try {
214
+ payload = JSON.stringify(chunkResults, mapReplacer);
215
+ }
216
+ catch {
217
+ // Extremely dense chunks could theoretically exceed string limits; skip
218
+ // rather than failing the entire save (orchestrator catches save errors).
219
+ continue;
220
+ }
221
+ keys.push(chunkHash);
222
+ const chunkPath = path.join(tmpDir, `${chunkHash}.json`);
223
+ await fs.writeFile(chunkPath, payload, 'utf-8');
224
+ }
225
+ const index = {
155
226
  version: cache.version,
156
- entries: Object.fromEntries(cache.entries),
227
+ keys,
157
228
  };
158
- // Compact JSON; this file can be tens of MB on a large repo and pretty-
159
- // printing roughly doubles size for no value.
160
- await fs.writeFile(tmpPath, JSON.stringify(out, mapReplacer), 'utf-8');
161
- await fs.rename(tmpPath, cachePath);
229
+ await fs.writeFile(path.join(tmpDir, CACHE_INDEX_FILENAME), JSON.stringify(index), 'utf-8');
230
+ await fs.rm(cacheDir, { recursive: true, force: true });
231
+ await fs.rename(tmpDir, cacheDir);
232
+ await fs.rm(getLegacyCachePath(storagePath), { force: true });
162
233
  };
163
234
  /**
164
235
  * Drop entries whose hashes are not in `usedHashes`. Called at the end
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.5-rc.44",
3
+ "version": "1.6.5-rc.46",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",