mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-22 00:31:17 +00:00
Merge branch 'main' into feat/wiki
This commit is contained in:
commit
b8a479cf73
14 changed files with 1442 additions and 31 deletions
47
gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts
Normal file
47
gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* C++ conversion-rank scoring for overload resolution (#1578).
|
||||
*
|
||||
* Operates on **normalized** type strings (output of
|
||||
* `normalizeCppParamType` in `arity-metadata.ts`). After normalization:
|
||||
* - int/long/short/unsigned → 'int'
|
||||
* - float/double → 'double'
|
||||
* - char → 'char', bool → 'bool'
|
||||
*
|
||||
* Because the normalizer collapses promotion pairs (int↔long,
|
||||
* float↔double) to the same string, those promotions are invisible at
|
||||
* this layer — they appear as exact matches (rank 0).
|
||||
*
|
||||
* Post-normalization ranking:
|
||||
* - rank 0 — exact (same normalized type)
|
||||
* - rank 1 — integral promotion (char→int, bool→int)
|
||||
* - rank 2 — standard arithmetic conversion (int↔double, char→double,
|
||||
* bool→double)
|
||||
* - Infinity — mismatch (string↔int, user types, pointers, etc.)
|
||||
*
|
||||
* This function is intentionally C++-specific (issue #1578 pitfall:
|
||||
* keep conversion-rank tables out of shared overload-narrowing). Other
|
||||
* languages may define their own `ConversionRankFn` in the future.
|
||||
*/
|
||||
|
||||
/** Set of normalized arithmetic types that support implicit conversion. */
|
||||
const ARITHMETIC = new Set(['int', 'double', 'char', 'bool']);
|
||||
|
||||
/** Integral promotion targets: char→int and bool→int are rank 1. */
|
||||
const INTEGRAL_PROMOTION = new Map([
|
||||
['char', 'int'],
|
||||
['bool', 'int'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Return the conversion rank from `argType` to `paramType`.
|
||||
*
|
||||
* @returns 0 for exact match, 1 for integral promotion (char/bool→int),
|
||||
* 2 for standard arithmetic conversion, Infinity for mismatch.
|
||||
*/
|
||||
export function cppConversionRank(argType: string, paramType: string): number {
|
||||
if (argType === paramType) return 0;
|
||||
// Integral promotions: char→int, bool→int (ISO C++ [conv.prom])
|
||||
if (INTEGRAL_PROMOTION.get(argType) === paramType) return 1;
|
||||
if (ARITHMETIC.has(argType) && ARITHMETIC.has(paramType)) return 2;
|
||||
return Infinity;
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.
|
|||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { cppProvider } from '../c-cpp.js';
|
||||
import { cppArityCompatibility } from './arity.js';
|
||||
import { cppConversionRank } from './conversion-rank.js';
|
||||
import { cppMergeBindings } from './merge-bindings.js';
|
||||
import { resolveCppImportTarget } from './import-target.js';
|
||||
import { scanCppHeaderFiles } from './header-scan.js';
|
||||
|
|
@ -169,6 +170,10 @@ export const cppScopeResolver: ScopeResolver = {
|
|||
propagatesReturnTypesAcrossImports: true,
|
||||
// C++ #include brings in all symbols — enable global free call fallback
|
||||
allowGlobalFreeCallFallback: true,
|
||||
// C++ standard-conversion-sequence ranking for overload resolution (#1578).
|
||||
// Disambiguates `f(int)` vs `f(double)` called with `f(2.5)` by scoring
|
||||
// each candidate's conversion cost; exact match wins over standard conversion.
|
||||
conversionRankFn: cppConversionRank,
|
||||
// Range-for element type inference: for (auto& user : users) → bind user to User
|
||||
populateRangeBindings: populateCppRangeBindings,
|
||||
// C++ method return-type bindings need to be visible from module scope
|
||||
|
|
|
|||
|
|
@ -264,6 +264,7 @@ import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
|||
import { LanguageProvider } from '../../language-provider.js';
|
||||
import { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { SemanticModel } from '../../model/semantic-model.js';
|
||||
import type { ConversionRankFn } from '../passes/overload-narrowing.js';
|
||||
|
||||
/** A LinearizeStrategy receives the full ancestor map so C3-style
|
||||
* algorithms (which need to merge each parent's MRO) can implement
|
||||
|
|
@ -533,6 +534,20 @@ export interface ScopeResolver {
|
|||
*/
|
||||
readonly allowGlobalFreeCallFallback?: boolean;
|
||||
|
||||
/**
|
||||
* Optional per-slot conversion-rank function for overload resolution.
|
||||
* When provided, `narrowOverloadCandidates` uses ranked scoring as a
|
||||
* fallback when the exact-type filter produces no match. The function
|
||||
* returns a numeric cost (0 = exact, 1 = promotion, 2 = standard
|
||||
* conversion, Infinity = incompatible) for converting an argument
|
||||
* type to a parameter type.
|
||||
*
|
||||
* The conversion-rank table is language-specific (issue #1578 pitfall:
|
||||
* keep it out of shared overload-narrowing). C++ provides
|
||||
* `cppConversionRank`; other languages define their own if needed.
|
||||
*/
|
||||
readonly conversionRankFn?: ConversionRankFn;
|
||||
|
||||
/**
|
||||
* Optional predicate to identify definitions with file-local linkage
|
||||
* (e.g. C `static` functions). When provided, `pickUniqueGlobalCallable`
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import type { WorkspaceResolutionIndex } from '../workspace-index.js';
|
|||
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
||||
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
|
||||
import {
|
||||
findAllCallableBindingsInScope,
|
||||
findCallableBindingInScope,
|
||||
findCallableBindingsAndAdlBlocker,
|
||||
findClassBindingInScope,
|
||||
|
|
@ -32,6 +33,7 @@ import {
|
|||
import {
|
||||
isOverloadAmbiguousAfterNormalization,
|
||||
narrowOverloadCandidates,
|
||||
type ConversionRankFn,
|
||||
} from './overload-narrowing.js';
|
||||
|
||||
export function emitFreeCallFallback(
|
||||
|
|
@ -63,6 +65,7 @@ export function emitFreeCallFallback(
|
|||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
) => readonly SymbolDefinition[] | undefined;
|
||||
readonly conversionRankFn?: ConversionRankFn;
|
||||
} = {},
|
||||
): number {
|
||||
let emitted = 0;
|
||||
|
|
@ -90,16 +93,59 @@ export function emitFreeCallFallback(
|
|||
// the same name in a single class, choose the best match by
|
||||
// arity + argument types.
|
||||
if (fnDef === undefined) {
|
||||
fnDef = pickImplicitThisOverload(site, scopes, workspaceIndex, model);
|
||||
fnDef = pickImplicitThisOverload(
|
||||
site,
|
||||
scopes,
|
||||
workspaceIndex,
|
||||
model,
|
||||
options.conversionRankFn,
|
||||
);
|
||||
}
|
||||
// Scope-chain callable lookup. First-match preserves scope-chain
|
||||
// precedence (local shadows import). When a conversion-rank function
|
||||
// is available AND the binding scope contains multiple overloads,
|
||||
// refine with `narrowOverloadCandidates` to pick the best overload
|
||||
// by argument types (#1578). The first-match result is kept as a
|
||||
// fallback when narrowing is indeterminate.
|
||||
if (fnDef === undefined) {
|
||||
if (options.resolveAdlCandidates === undefined) {
|
||||
// Non-ADL path: first-match preserves scope-chain precedence
|
||||
// (local shadows import). When a conversion-rank function is
|
||||
// available AND the binding scope contains multiple overloads,
|
||||
// refine with narrowOverloadCandidates (#1578).
|
||||
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
|
||||
if (fnDef !== undefined && options.conversionRankFn !== undefined) {
|
||||
const allCallables = findAllCallableBindingsInScope(site.inScope, site.name, scopes);
|
||||
if (allCallables.length > 1) {
|
||||
const narrowed = narrowOverloadCandidates(
|
||||
allCallables,
|
||||
site.arity,
|
||||
site.argumentTypes,
|
||||
options.conversionRankFn,
|
||||
);
|
||||
if (narrowed.length === 1) {
|
||||
fnDef = narrowed[0];
|
||||
} else if (narrowed.length > 1) {
|
||||
// Multiple survivors after conversion-rank scoring.
|
||||
// Suppress when all candidates share the same file (true
|
||||
// overloads) — mirrors ADL merged-candidate path behavior.
|
||||
// Cross-file candidates are shadowing; keep first-match.
|
||||
const sameFile = narrowed.every((d) => d.filePath === narrowed[0]!.filePath);
|
||||
if (sameFile) {
|
||||
handledSites.add(
|
||||
`${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// narrowed.length === 0: keep the first-match fnDef —
|
||||
// preserves local-shadows-import.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ISO C++ `[basic.lookup.unqual]` §7: ADL is suppressed when
|
||||
// ordinary lookup finds a non-function name (variable, class, enum)
|
||||
// or a block-scope function declaration (not via using-declaration)
|
||||
// at the nearest scope where the name exists.
|
||||
// ADL path: ISO C++ `[basic.lookup.unqual]` §7 — ADL is suppressed
|
||||
// when ordinary lookup finds a non-function name or a block-scope
|
||||
// function declaration.
|
||||
const {
|
||||
callables: ordinary,
|
||||
nonCallableFound,
|
||||
|
|
@ -120,43 +166,67 @@ export function emitFreeCallFallback(
|
|||
parsedFiles,
|
||||
);
|
||||
|
||||
// Preserve existing ordinary-lookup behavior when ADL contributed
|
||||
// no candidates.
|
||||
// When ADL contributed no candidates, narrow ordinary candidates
|
||||
// with conversion-rank scoring when multiple overloads exist.
|
||||
// Single candidate or empty falls through to first-match.
|
||||
if (adl === undefined || adl.length === 0) {
|
||||
fnDef = ordinary[0];
|
||||
if (ordinary.length <= 1 || options.conversionRankFn === undefined) {
|
||||
fnDef = ordinary[0];
|
||||
} else {
|
||||
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
|
||||
const narrowed = narrowOverloadCandidates(
|
||||
ordinary,
|
||||
site.arity,
|
||||
site.argumentTypes,
|
||||
options.conversionRankFn,
|
||||
);
|
||||
if (narrowed.length === 1) {
|
||||
fnDef = narrowed[0];
|
||||
} else if (narrowed.length > 1) {
|
||||
// Multiple survivors — suppress when same-file (true
|
||||
// overloads), mirrors ADL merged-candidate behavior.
|
||||
const sameFile = narrowed.every((d) => d.filePath === narrowed[0]!.filePath);
|
||||
if (sameFile) {
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
}
|
||||
fnDef = ordinary[0]; // cross-file shadowing → first-match
|
||||
} else {
|
||||
fnDef = ordinary[0]; // narrowed empty → first-match
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
|
||||
const merged: SymbolDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
const seenMerge = new Set<string>();
|
||||
const push = (defs: readonly SymbolDefinition[]): void => {
|
||||
for (const d of defs) {
|
||||
if (seen.has(d.nodeId)) continue;
|
||||
seen.add(d.nodeId);
|
||||
if (seenMerge.has(d.nodeId)) continue;
|
||||
seenMerge.add(d.nodeId);
|
||||
merged.push(d);
|
||||
}
|
||||
};
|
||||
push(ordinary);
|
||||
push(adl);
|
||||
|
||||
const narrowed = narrowOverloadCandidates(merged, site.arity, site.argumentTypes);
|
||||
const narrowed = narrowOverloadCandidates(
|
||||
merged,
|
||||
site.arity,
|
||||
site.argumentTypes,
|
||||
options.conversionRankFn,
|
||||
);
|
||||
if (narrowed.length === 1) {
|
||||
fnDef = narrowed[0];
|
||||
} else if (narrowed.length === 0) {
|
||||
// ADL contributed candidates, but none survived arity/type
|
||||
// narrowing. Treat as handled to avoid global-name fallback
|
||||
// binding to the same mismatched symbol by simple-name
|
||||
// uniqueness.
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
} else if (narrowed.length > 1) {
|
||||
// Suppress ambiguous overload calls (emit zero edges) when
|
||||
// merged ordinary+ADL candidate sets cannot be disambiguated.
|
||||
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) {
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
}
|
||||
// Multiple survivors remain but no conversion-ranking step
|
||||
// exists yet; suppress instead of picking arbitrarily.
|
||||
// Multiple survivors remain after conversion-rank scoring;
|
||||
// suppress instead of picking arbitrarily.
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -184,6 +254,8 @@ export function emitFreeCallFallback(
|
|||
scopes,
|
||||
})
|
||||
: undefined,
|
||||
site.argumentTypes,
|
||||
options.conversionRankFn,
|
||||
);
|
||||
}
|
||||
if (fnDef === undefined) continue;
|
||||
|
|
@ -222,6 +294,8 @@ function pickUniqueGlobalCallable(
|
|||
isFileLocalDef?: (def: SymbolDefinition) => boolean,
|
||||
callArity?: number,
|
||||
isCallerVisible?: (candidate: SymbolDefinition) => boolean,
|
||||
callArgTypes?: readonly string[],
|
||||
conversionRankFn?: ConversionRankFn,
|
||||
): SymbolDefinition | undefined {
|
||||
const scopeDefs: SymbolDefinition[] = [];
|
||||
const scopeSeen = new Set<string>();
|
||||
|
|
@ -256,6 +330,14 @@ function pickUniqueGlobalCallable(
|
|||
const arityMatch = narrowByArity(scopeDefs, callArity);
|
||||
if (arityMatch !== undefined) return arityMatch;
|
||||
}
|
||||
// When arity narrowing left >1 candidate, try overload narrowing with
|
||||
// argument types + conversion ranking (#1578). This picks the unique
|
||||
// best-rank candidate when exact-type or conversion-rank scoring can
|
||||
// disambiguate (e.g., `f(int)` vs `f(double)` called with `f(2.5)`).
|
||||
if (scopeDefs.length > 1) {
|
||||
const narrowed = narrowOverloadCandidates(scopeDefs, callArity, callArgTypes, conversionRankFn);
|
||||
if (narrowed.length === 1) return narrowed[0];
|
||||
}
|
||||
|
||||
const defs: SymbolDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -289,6 +371,11 @@ function pickUniqueGlobalCallable(
|
|||
const arityMatch = narrowByArity(defs, callArity);
|
||||
if (arityMatch !== undefined) return arityMatch;
|
||||
}
|
||||
// Same argument-type + conversion-rank narrowing for the model pool.
|
||||
if (defs.length > 1) {
|
||||
const narrowed = narrowOverloadCandidates(defs, callArity, callArgTypes, conversionRankFn);
|
||||
if (narrowed.length === 1) return narrowed[0];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -362,6 +449,7 @@ export function pickImplicitThisOverload(
|
|||
scopes: ScopeResolutionIndexes,
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
model: SemanticModel,
|
||||
conversionRankFn?: ConversionRankFn,
|
||||
): SymbolDefinition | undefined {
|
||||
// Find the enclosing Class scope by walking parents.
|
||||
let curId: ScopeId | null = site.inScope;
|
||||
|
|
@ -389,7 +477,12 @@ export function pickImplicitThisOverload(
|
|||
// ambiguous narrowing (multiple compatible candidates with no
|
||||
// disambiguating signal) leaves the call unresolved rather than
|
||||
// routing to an arbitrary first overload by registration order.
|
||||
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes);
|
||||
const candidates = narrowOverloadCandidates(
|
||||
overloads,
|
||||
site.arity,
|
||||
site.argumentTypes,
|
||||
conversionRankFn,
|
||||
);
|
||||
if (candidates.length !== 1) return undefined;
|
||||
return candidates[0];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,15 +24,35 @@
|
|||
* equality. An empty string in `argTypes[i]` means "unknown" and
|
||||
* counts as a match. Mismatches disqualify. A non-empty typed
|
||||
* result wins; otherwise return the arity-filtered candidates.
|
||||
* 4b. When the exact-type filter from step 4 returns empty AND a
|
||||
* `conversionRankFn` is provided, rank candidates via pairwise
|
||||
* dominance comparison (ISO C++ [over.ics.rank]): F1 beats F2
|
||||
* only when F1 is not worse for every arg and better for at
|
||||
* least one. Non-dominated candidates are returned; multiple
|
||||
* survivors are genuinely ambiguous.
|
||||
* 5. Empty input returns empty output.
|
||||
*/
|
||||
|
||||
import type { SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
/**
|
||||
* Per-slot conversion-rank function. Returns a numeric cost for
|
||||
* converting `argType` to `paramType`:
|
||||
* - 0 = exact match (no conversion)
|
||||
* - 1 = promotion (e.g. char→int, bool→int in C++)
|
||||
* - 2 = standard conversion (e.g. int→double)
|
||||
* - Infinity = incompatible types
|
||||
*
|
||||
* Each language provides its own implementation. The function operates
|
||||
* on normalized type strings (output of the language's type normalizer).
|
||||
*/
|
||||
export type ConversionRankFn = (argType: string, paramType: string) => number;
|
||||
|
||||
export function narrowOverloadCandidates(
|
||||
overloads: readonly SymbolDefinition[],
|
||||
argCount: number | undefined,
|
||||
argTypes: readonly string[] | undefined,
|
||||
conversionRankFn?: ConversionRankFn,
|
||||
): readonly SymbolDefinition[] {
|
||||
if (overloads.length === 0) return [];
|
||||
|
||||
|
|
@ -84,11 +104,98 @@ export function narrowOverloadCandidates(
|
|||
return true;
|
||||
});
|
||||
if (typed.length > 0) return typed;
|
||||
|
||||
// ── Conversion-rank scoring (step 4b) ──────────────────────────
|
||||
// The exact-type filter above rejected every candidate. When a
|
||||
// per-language conversion-rank function is available, rank via
|
||||
// pairwise dominance: F1 beats F2 only when F1 is not worse for
|
||||
// every arg and better for at least one. Non-dominated candidates
|
||||
// are returned; multiple survivors are genuinely ambiguous.
|
||||
if (conversionRankFn !== undefined) {
|
||||
const ranked = rankByConversion(candidates, argTypes, conversionRankFn);
|
||||
if (ranked.length > 0) return ranked;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairwise dominance comparison (ISO C++ [over.ics.rank]).
|
||||
*
|
||||
* F1 is a better match than F2 when F1's conversion rank is **not
|
||||
* worse** for every argument AND **strictly better** for at least one.
|
||||
* Candidates dominated by any other viable candidate are removed.
|
||||
* If more than one non-dominated candidate remains, they are genuinely
|
||||
* ambiguous — callers suppress the edge rather than picking arbitrarily.
|
||||
*
|
||||
* Candidates with at least one `Infinity`-ranked slot (incompatible
|
||||
* type) are excluded before pairwise comparison begins.
|
||||
*/
|
||||
function rankByConversion(
|
||||
candidates: readonly SymbolDefinition[],
|
||||
argTypes: readonly string[],
|
||||
rankFn: ConversionRankFn,
|
||||
): readonly SymbolDefinition[] {
|
||||
// Step 1: compute per-slot ranks and exclude non-viable candidates.
|
||||
const viable: Array<{ def: SymbolDefinition; ranks: number[] }> = [];
|
||||
for (const d of candidates) {
|
||||
const params = d.parameterTypes;
|
||||
if (params === undefined) continue;
|
||||
const ranks: number[] = [];
|
||||
let ok = true;
|
||||
for (let i = 0; i < argTypes.length && i < params.length; i++) {
|
||||
if (argTypes[i] === '') {
|
||||
ranks.push(0); // unknown arg → any-match (rank 0)
|
||||
continue;
|
||||
}
|
||||
const r = rankFn(argTypes[i], params[i]);
|
||||
if (!isFinite(r)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
ranks.push(r);
|
||||
}
|
||||
if (!ok) continue;
|
||||
viable.push({ def: d, ranks });
|
||||
}
|
||||
if (viable.length <= 1) return viable.map((v) => v.def);
|
||||
|
||||
// Step 2: pairwise dominance — remove candidates dominated by any other.
|
||||
const dominated = new Set<number>();
|
||||
for (let i = 0; i < viable.length; i++) {
|
||||
if (dominated.has(i)) continue;
|
||||
for (let j = i + 1; j < viable.length; j++) {
|
||||
if (dominated.has(j)) continue;
|
||||
const cmp = pairwiseCompare(viable[i].ranks, viable[j].ranks);
|
||||
if (cmp < 0)
|
||||
dominated.add(j); // i dominates j
|
||||
else if (cmp > 0) dominated.add(i); // j dominates i
|
||||
}
|
||||
}
|
||||
return viable.filter((_, idx) => !dominated.has(idx)).map((v) => v.def);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two per-slot rank vectors.
|
||||
* Returns -1 if `a` dominates `b` (not worse everywhere, better somewhere),
|
||||
* +1 if `b` dominates `a`,
|
||||
* 0 if neither dominates (incomparable or equal).
|
||||
*/
|
||||
function pairwiseCompare(a: readonly number[], b: readonly number[]): -1 | 0 | 1 {
|
||||
let aBetter = false;
|
||||
let bBetter = false;
|
||||
const len = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (a[i] < b[i]) aBetter = true;
|
||||
else if (b[i] < a[i]) bBetter = true;
|
||||
if (aBetter && bBetter) return 0; // incomparable — early exit
|
||||
}
|
||||
if (aBetter && !bBetter) return -1;
|
||||
if (bBetter && !aBetter) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect when >1 candidate share identical `parameterTypes` after the
|
||||
* per-language normalizer has collapsed distinct underlying types. This
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ type ReceiverBoundProviderSubset = Pick<
|
|||
| 'hoistTypeBindingsToModule'
|
||||
| 'resolveQualifiedReceiverMember'
|
||||
| 'resolveThisViaEnclosingClass'
|
||||
| 'conversionRankFn'
|
||||
>;
|
||||
|
||||
function normalizeTemplateArgToken(value: string): string {
|
||||
|
|
@ -343,6 +344,7 @@ export function emitReceiverBoundCalls(
|
|||
methodOverloads,
|
||||
site.arity,
|
||||
site.argumentTypes,
|
||||
provider.conversionRankFn,
|
||||
);
|
||||
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) {
|
||||
ambiguous = true;
|
||||
|
|
@ -356,6 +358,12 @@ export function emitReceiverBoundCalls(
|
|||
hiddenByName = true;
|
||||
break;
|
||||
}
|
||||
// Multiple tied survivors with distinct param types (e.g.
|
||||
// h(int,double) vs h(double,int) both scoring 2) → ambiguous.
|
||||
if (narrowed.length > 1) {
|
||||
ambiguous = true;
|
||||
break;
|
||||
}
|
||||
memberDef = narrowed[0] ?? methodOverloads[0];
|
||||
break;
|
||||
}
|
||||
|
|
@ -640,7 +648,13 @@ export function emitReceiverBoundCalls(
|
|||
let memberDef: SymbolDefinition | undefined;
|
||||
let ambiguous = false;
|
||||
for (const ownerId of chain) {
|
||||
const picked = pickOverload(ownerId, memberName, site, model);
|
||||
const picked = pickOverload(
|
||||
ownerId,
|
||||
memberName,
|
||||
site,
|
||||
model,
|
||||
provider.conversionRankFn,
|
||||
);
|
||||
if (picked === OVERLOAD_AMBIGUOUS) {
|
||||
ambiguous = true;
|
||||
break;
|
||||
|
|
@ -708,6 +722,7 @@ function pickOverload(
|
|||
memberName: string,
|
||||
site: ParsedFile['referenceSites'][number],
|
||||
model: SemanticModel,
|
||||
conversionRankFn?: (argType: string, paramType: string) => number,
|
||||
): SymbolDefinition | typeof OVERLOAD_AMBIGUOUS | undefined {
|
||||
const overloads = model.methods.lookupAllByOwner(ownerId, memberName);
|
||||
if (overloads.length === 0) {
|
||||
|
|
@ -718,7 +733,12 @@ function pickOverload(
|
|||
}
|
||||
if (overloads.length === 1) return overloads[0];
|
||||
|
||||
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes);
|
||||
const candidates = narrowOverloadCandidates(
|
||||
overloads,
|
||||
site.arity,
|
||||
site.argumentTypes,
|
||||
conversionRankFn,
|
||||
);
|
||||
// When narrowing leaves >1 candidate that share identical normalized
|
||||
// parameter-types (e.g., C++ `f(int)` vs `f(long)` both collapsed to
|
||||
// `['int']` by `normalizeCppParamType`), suppress the edge entirely.
|
||||
|
|
@ -726,6 +746,11 @@ function pickOverload(
|
|||
// would arbitrarily pick a candidate and lie about the call's target.
|
||||
// PR #1520 review follow-up plan U2 / Claude review Finding 5.
|
||||
if (isOverloadAmbiguousAfterNormalization(candidates, site.arity)) return OVERLOAD_AMBIGUOUS;
|
||||
// When conversion-rank scoring leaves >1 tied candidate with distinct
|
||||
// parameter types (e.g. h(int,double) vs h(double,int) both scoring 2),
|
||||
// suppress rather than picking arbitrarily — C++ would call this
|
||||
// ambiguous. Mirrors ADL merged-candidate suppression behavior.
|
||||
if (candidates.length > 1) return OVERLOAD_AMBIGUOUS;
|
||||
return candidates[0] ?? overloads[0];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -382,6 +382,7 @@ export function runScopeResolution(
|
|||
isFileLocalDef: provider.isFileLocalDef,
|
||||
isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller,
|
||||
resolveAdlCandidates: provider.resolveAdlCandidates,
|
||||
conversionRankFn: provider.conversionRankFn,
|
||||
},
|
||||
);
|
||||
const { emitted, skipped } = emitReferencesViaLookup(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import fs from 'fs/promises';
|
||||
import { createReadStream, createWriteStream } from 'fs';
|
||||
import { createReadStream, createWriteStream, constants as fsConstants } from 'fs';
|
||||
import { createInterface } from 'readline';
|
||||
import { once } from 'events';
|
||||
import { finished } from 'stream/promises';
|
||||
|
|
@ -201,6 +201,163 @@ export const isReadOnlyDbError = (err: unknown): boolean => {
|
|||
return /read-only database/i.test(msg);
|
||||
};
|
||||
|
||||
const isMissingFileError = (err: unknown): boolean => {
|
||||
const errno = err as NodeJS.ErrnoException;
|
||||
return errno?.code === 'ENOENT';
|
||||
};
|
||||
|
||||
const extractErrnoCode = (err: unknown): string | undefined => {
|
||||
const errno = err as NodeJS.ErrnoException;
|
||||
return errno?.code;
|
||||
};
|
||||
|
||||
const MAX_LOGGED_ERROR_MESSAGE_LENGTH = 160;
|
||||
|
||||
const summarizeError = (err: unknown): string =>
|
||||
(err instanceof Error ? err.message : String(err)).slice(0, MAX_LOGGED_ERROR_MESSAGE_LENGTH);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-process init lock
|
||||
//
|
||||
// Prevents a TOCTOU race in orphan sidecar cleanup: between checking that
|
||||
// the main DB file is missing and unlinking sidecars, another process could
|
||||
// create a fresh DB. The lock file (`${dbPath}.init.lock`) is created with
|
||||
// O_CREAT | O_EXCL (atomic create-or-fail) and contains the owning PID +
|
||||
// timestamp so stale locks from crashed processes can be reclaimed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maximum age (ms) before an init lock is considered stale. */
|
||||
const INIT_LOCK_STALE_MS = 30_000;
|
||||
/** Maximum attempts to acquire the init lock before giving up. */
|
||||
const INIT_LOCK_MAX_ATTEMPTS = 6;
|
||||
/** Delay between lock-acquisition retries (ms). */
|
||||
const INIT_LOCK_RETRY_DELAY_MS = 500;
|
||||
|
||||
const initLockPath = (dbPath: string): string => `${dbPath}.init.lock`;
|
||||
|
||||
/**
|
||||
* Returns true when the process identified by `pid` is still running.
|
||||
* Uses `process.kill(pid, 0)` which sends signal 0 (a no-op probe) —
|
||||
* it throws ESRCH when the process does not exist.
|
||||
*/
|
||||
const isProcessAlive = (pid: number): boolean => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Try to break a stale lock whose owning process has exited.
|
||||
* Returns `true` if the stale lock was removed (caller should retry acquire).
|
||||
* Returns `false` if the lock is still valid (another live process owns it).
|
||||
*/
|
||||
const tryBreakStaleLock = async (lockPath: string): Promise<boolean> => {
|
||||
try {
|
||||
const content = await fs.readFile(lockPath, 'utf-8');
|
||||
const parsed = JSON.parse(content) as { pid?: number; ts?: number };
|
||||
|
||||
// If the owning process is still alive AND the lock is not stale, don't break.
|
||||
if (typeof parsed.pid === 'number' && isProcessAlive(parsed.pid)) {
|
||||
// Even a live process's lock can be stale if it's been held too long
|
||||
// (e.g. the process is hung). Check the timestamp.
|
||||
if (typeof parsed.ts === 'number' && Date.now() - parsed.ts < INIT_LOCK_STALE_MS) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// PID is gone or lock exceeded INIT_LOCK_STALE_MS — reclaim it.
|
||||
await fs.unlink(lockPath);
|
||||
logger.warn(
|
||||
`GitNexus: removed stale init lock (pid=${parsed.pid ?? '?'}, age=${typeof parsed.ts === 'number' ? `${Date.now() - parsed.ts}ms` : '?'})`,
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Lock file disappeared between our read and unlink, or is unreadable.
|
||||
// Either way, let the caller retry the acquire.
|
||||
if (isMissingFileError(err)) return true;
|
||||
// Permission error or corrupt content — log and let caller retry.
|
||||
const code = extractErrnoCode(err);
|
||||
logger.warn(
|
||||
`GitNexus: unable to inspect init lock (${code ?? 'UNKNOWN'}): ${summarizeError(err)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Acquire a cross-process init lock for `dbPath`.
|
||||
* Uses `O_CREAT | O_EXCL` for atomic create-or-fail semantics.
|
||||
*
|
||||
* Returns a release function that removes the lock file. The release
|
||||
* function is idempotent and safe to call even if the lock was already
|
||||
* cleaned up externally.
|
||||
*
|
||||
* Throws if the lock cannot be acquired after `INIT_LOCK_MAX_ATTEMPTS`.
|
||||
*/
|
||||
export const acquireInitLock = async (dbPath: string): Promise<() => Promise<void>> => {
|
||||
const lockPath = initLockPath(dbPath);
|
||||
const payload = JSON.stringify({ pid: process.pid, ts: Date.now() });
|
||||
|
||||
// Ensure the parent directory exists before creating the lock file.
|
||||
// On a fresh repo the `.gitnexus/` directory may not exist yet, and
|
||||
// fs.open with O_CREAT | O_EXCL would fail with ENOENT.
|
||||
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
||||
|
||||
for (let attempt = 1; attempt <= INIT_LOCK_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const handle = await fs.open(
|
||||
lockPath,
|
||||
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
|
||||
);
|
||||
await handle.writeFile(payload);
|
||||
await handle.close();
|
||||
|
||||
// Return the idempotent release function
|
||||
return async () => {
|
||||
try {
|
||||
await fs.unlink(lockPath);
|
||||
} catch (err) {
|
||||
if (!isMissingFileError(err)) {
|
||||
const code = extractErrnoCode(err);
|
||||
logger.warn(
|
||||
`GitNexus: failed to release init lock (${code ?? 'UNKNOWN'}): ${summarizeError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code !== 'EEXIST') {
|
||||
throw err; // Unexpected error — propagate immediately
|
||||
}
|
||||
|
||||
// Lock file exists — check if it's stale
|
||||
const broken = await tryBreakStaleLock(lockPath);
|
||||
if (broken && attempt < INIT_LOCK_MAX_ATTEMPTS) {
|
||||
continue; // Stale lock removed — retry immediately
|
||||
}
|
||||
|
||||
if (attempt === INIT_LOCK_MAX_ATTEMPTS) {
|
||||
throw new Error(
|
||||
`GitNexus: unable to acquire init lock after ${INIT_LOCK_MAX_ATTEMPTS} attempts — ` +
|
||||
`another gitnexus process may be initializing the same database (${lockPath})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Live process holds the lock — wait and retry
|
||||
await new Promise((resolve) => setTimeout(resolve, INIT_LOCK_RETRY_DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable — loop always throws or returns
|
||||
throw new Error('GitNexus: init lock acquisition failed unexpectedly');
|
||||
};
|
||||
|
||||
/** Exported for testing — returns the lock file path for a given dbPath. */
|
||||
export const _initLockPathForTest = initLockPath;
|
||||
|
||||
const runWithSessionLock = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const previous = sessionLock;
|
||||
let release: (() => void) | null = null;
|
||||
|
|
@ -364,17 +521,64 @@ const doInitLbug = async (dbPath: string) => {
|
|||
await fs.rm(dbPath, { recursive: true, force: true });
|
||||
}
|
||||
// If it's a file, assume it's an existing LadybugDB database - LadybugDB will open it
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (!isMissingFileError(err)) {
|
||||
throw err;
|
||||
}
|
||||
// Path doesn't exist, which is what LadybugDB wants for a new database
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const parentDir = path.dirname(dbPath);
|
||||
await fs.mkdir(parentDir, { recursive: true });
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-process critical section: acquire init lock, clean orphan sidecars,
|
||||
// and open the database. The lock prevents a TOCTOU race where another
|
||||
// process could create a fresh DB between our access() check and the
|
||||
// unlink() of stale sidecars.
|
||||
// ---------------------------------------------------------------------------
|
||||
const releaseInitLock = await acquireInitLock(dbPath);
|
||||
try {
|
||||
// Crash-recovery cleanup: if the main DB file is missing, stale sidecars
|
||||
// from an interrupted run can block fresh opens indefinitely.
|
||||
try {
|
||||
await fs.access(dbPath);
|
||||
} catch (err) {
|
||||
if (isMissingFileError(err)) {
|
||||
// `.shadow` is documented by LadybugDB checkpointing and `.wal.checkpoint`
|
||||
// was observed in the #1618 crash loop that motivated this recovery path.
|
||||
const orphanSidecars = [`${dbPath}.shadow`, `${dbPath}.wal.checkpoint`];
|
||||
for (const sidecar of orphanSidecars) {
|
||||
try {
|
||||
await fs.unlink(sidecar);
|
||||
logger.warn(
|
||||
`GitNexus: removed orphan sidecar ${path.basename(sidecar)} (no main DB file present)`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isMissingFileError(err)) {
|
||||
continue;
|
||||
}
|
||||
const code = extractErrnoCode(err);
|
||||
logger.warn(
|
||||
`GitNexus: failed to remove orphan sidecar ${path.basename(sidecar)} (${code ?? 'UNKNOWN'}) while main DB file is missing; LadybugDB open may still fail: ${summarizeError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const code = extractErrnoCode(err);
|
||||
logger.warn(
|
||||
`GitNexus: unable to verify main DB file before orphan sidecar cleanup (${code ?? 'UNKNOWN'}); skipping cleanup: ${summarizeError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const opened = await openLbugConnection(lbug, dbPath);
|
||||
db = opened.db;
|
||||
conn = opened.conn;
|
||||
// Ensure parent directory exists
|
||||
const parentDir = path.dirname(dbPath);
|
||||
await fs.mkdir(parentDir, { recursive: true });
|
||||
|
||||
const opened = await openLbugConnection(lbug, dbPath);
|
||||
db = opened.db;
|
||||
conn = opened.conn;
|
||||
} finally {
|
||||
await releaseInitLock();
|
||||
}
|
||||
|
||||
for (const schemaQuery of SCHEMA_QUERIES) {
|
||||
try {
|
||||
|
|
|
|||
10
gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-rank/lib.cpp
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-rank/lib.cpp
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#include "lib.h"
|
||||
|
||||
void Service::f(int x) {}
|
||||
void Service::f(double x) {}
|
||||
void Service::g(int x) {}
|
||||
void Service::g(long x) {}
|
||||
void Service::h(int a, int b) {}
|
||||
void Service::h(double a, double b) {}
|
||||
void Service::p(int x) {}
|
||||
void Service::p(double x) {}
|
||||
33
gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-rank/lib.h
vendored
Normal file
33
gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-rank/lib.h
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#pragma once
|
||||
|
||||
class Service {
|
||||
public:
|
||||
// Variant 1 & 3: f(int) vs f(double)
|
||||
void f(int x);
|
||||
void f(double x);
|
||||
|
||||
// Variant 2: g(int) vs g(long) — both normalize to 'int'
|
||||
void g(int x);
|
||||
void g(long x);
|
||||
|
||||
// Variant 4: multi-arg tied total score
|
||||
void h(int a, int b);
|
||||
void h(double a, double b);
|
||||
|
||||
// Variant 5: char-literal promotion (exercises conversion ranker)
|
||||
void p(int x);
|
||||
void p(double x);
|
||||
|
||||
// Inline: call sites live inside the class scope so the scope-chain
|
||||
// walk finds the Class scope, enabling pickImplicitThisOverload to
|
||||
// resolve overloads against the declaration-side Method nodes (which
|
||||
// carry distinct parameterTypes and graph-node IDs).
|
||||
void run() {
|
||||
f(2.5); // Variant 1: double literal -> f(double) wins (exact > standard)
|
||||
f(42); // Variant 3: int literal -> f(int) wins (exact > standard)
|
||||
g(42); // Variant 2: int/long both normalize to 'int' -> ambiguous
|
||||
h(42, 2.5); // Variant 4: incomparable — neither dominates the other -> ambiguous
|
||||
h('a', 2.5);// Variant 6: asymmetric — h(int,int) better at arg0 (promotion), h(double,double) better at arg1 (exact) -> ambiguous
|
||||
p('a'); // Variant 5: char literal -> p(int) wins via promotion (rank 1 < rank 2)
|
||||
}
|
||||
};
|
||||
330
gitnexus/test/integration/lbug-orphan-sidecar-recovery.test.ts
Normal file
330
gitnexus/test/integration/lbug-orphan-sidecar-recovery.test.ts
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
/**
|
||||
* Integration test: orphan sidecar recovery in doInitLbug.
|
||||
*
|
||||
* Exercises the real `initLbug` → `doInitLbug` path against a native
|
||||
* LadybugDB instance. Creates actual orphan `.shadow` and
|
||||
* `.wal.checkpoint` files on disk (without a main DB file) and confirms
|
||||
* that `initLbug` cleans them up and opens a fresh database successfully.
|
||||
*
|
||||
* This complements the unit-level mocked coverage in
|
||||
* `lbug-checkpoint-lifecycle.test.ts` with a real-filesystem,
|
||||
* real-LadybugDB integration proof required by DoD §2.7.
|
||||
*/
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
/**
|
||||
* LadybugDB 0.16.0 has a known Windows-only regression: `Database.close()`
|
||||
* does not release the underlying file lock until the process exits, so any
|
||||
* `closeLbug()` followed by `initLbug(samePath)` in the same process raises
|
||||
* Win32 Error 33. Skip reopen-dependent tests on Windows.
|
||||
*/
|
||||
const itLbugReopen = process.platform === 'win32' ? it.skip : it;
|
||||
|
||||
describe('orphan sidecar recovery — native integration', () => {
|
||||
itLbugReopen(
|
||||
'initLbug recovers when both .shadow and .wal.checkpoint orphan sidecars are present without a main DB file',
|
||||
async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
const shadowPath = `${dbPath}.shadow`;
|
||||
const walCheckpointPath = `${dbPath}.wal.checkpoint`;
|
||||
|
||||
try {
|
||||
// Simulate crash-recovery state: orphan sidecars without main DB file
|
||||
await fs.writeFile(shadowPath, 'stale-shadow-data');
|
||||
await fs.writeFile(walCheckpointPath, 'stale-wal-checkpoint-data');
|
||||
|
||||
// Confirm precondition: main DB file does NOT exist, sidecars DO
|
||||
await expect(fs.access(dbPath)).rejects.toThrow();
|
||||
await expect(fs.access(shadowPath)).resolves.toBeUndefined();
|
||||
await expect(fs.access(walCheckpointPath)).resolves.toBeUndefined();
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
// initLbug should clean up orphan sidecars and open a fresh DB
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
// Verify the database is functional — execute a simple query
|
||||
const rows = await adapter.executeQuery('RETURN 1 AS result');
|
||||
expect(rows).toEqual([{ result: 1 }]);
|
||||
|
||||
// Verify orphan sidecars were removed
|
||||
await expect(fs.access(shadowPath)).rejects.toThrow();
|
||||
await expect(fs.access(walCheckpointPath)).rejects.toThrow();
|
||||
|
||||
await adapter.closeLbug();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
itLbugReopen(
|
||||
'initLbug recovers when only .shadow orphan sidecar is present (partial crash state)',
|
||||
async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
const shadowPath = `${dbPath}.shadow`;
|
||||
const walCheckpointPath = `${dbPath}.wal.checkpoint`;
|
||||
|
||||
try {
|
||||
// Only .shadow present — partial crash state
|
||||
await fs.writeFile(shadowPath, 'stale-shadow-data');
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
const rows = await adapter.executeQuery('RETURN 42 AS answer');
|
||||
expect(rows).toEqual([{ answer: 42 }]);
|
||||
|
||||
// .shadow cleaned, .wal.checkpoint was never present
|
||||
await expect(fs.access(shadowPath)).rejects.toThrow();
|
||||
await expect(fs.access(walCheckpointPath)).rejects.toThrow();
|
||||
|
||||
await adapter.closeLbug();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
itLbugReopen('initLbug succeeds on a clean path with no orphan sidecars (baseline)', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
const rows = await adapter.executeQuery('RETURN 1 AS ok');
|
||||
expect(rows).toEqual([{ ok: 1 }]);
|
||||
|
||||
await adapter.closeLbug();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen(
|
||||
'initLbug does not attempt orphan cleanup when the main DB file exists',
|
||||
async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
// Place a marker file with a non-sidecar extension next to the DB path.
|
||||
// Our cleanup only targets `.shadow` and `.wal.checkpoint` and only when
|
||||
// the main DB is missing. We verify the DB opens normally and the marker
|
||||
// remains — proving that init did not perform broad sibling file cleanup.
|
||||
const markerPath = `${dbPath}.test-marker`;
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
// Create a real DB file by initializing normally
|
||||
await adapter.initLbug(dbPath);
|
||||
await adapter.closeLbug();
|
||||
|
||||
// Plant marker file next to the existing DB
|
||||
await fs.writeFile(markerPath, 'should-survive');
|
||||
|
||||
// Re-init: main DB exists, so orphan cleanup should NOT fire
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
const rows = await adapter.executeQuery('RETURN 1 AS ok');
|
||||
expect(rows).toEqual([{ ok: 1 }]);
|
||||
|
||||
// Marker file survives — no broad cleanup happened
|
||||
const content = await fs.readFile(markerPath, 'utf-8');
|
||||
expect(content).toBe('should-survive');
|
||||
|
||||
await adapter.closeLbug();
|
||||
} finally {
|
||||
// Clean up marker file — best-effort; may already be absent
|
||||
await fs.unlink(markerPath).catch(() => {
|
||||
/* test cleanup only */
|
||||
});
|
||||
await tmp.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init lock — cross-process ownership contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('init lock — single-process ownership contract', () => {
|
||||
itLbugReopen('acquireInitLock succeeds when parent directory does not exist yet', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
// Use a nested path whose parent directory does NOT exist
|
||||
const dbPath = path.join(tmp.dbPath, 'nonexistent-subdir', 'lbug');
|
||||
const lockPath = `${dbPath}.init.lock`;
|
||||
|
||||
try {
|
||||
// Precondition: parent directory must not exist
|
||||
await expect(fs.access(path.dirname(dbPath))).rejects.toThrow();
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const release = await adapter.acquireInitLock(dbPath);
|
||||
|
||||
// Lock file should exist — parent dir was created automatically
|
||||
const content = await fs.readFile(lockPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.pid).toBe(process.pid);
|
||||
|
||||
await release();
|
||||
|
||||
// Lock file gone after release
|
||||
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen('acquireInitLock creates and releases lock file atomically', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
const lockPath = `${dbPath}.init.lock`;
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const release = await adapter.acquireInitLock(dbPath);
|
||||
|
||||
// Lock file should exist while held
|
||||
const content = await fs.readFile(lockPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.pid).toBe(process.pid);
|
||||
expect(typeof parsed.ts).toBe('number');
|
||||
|
||||
// Release the lock
|
||||
await release();
|
||||
|
||||
// Lock file should be gone after release
|
||||
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen('acquireInitLock blocks concurrent acquire from same process', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
const release1 = await adapter.acquireInitLock(dbPath);
|
||||
|
||||
// Second acquire should fail because the lock is held by this (alive) process.
|
||||
// The lock retry budget is small enough that this completes quickly.
|
||||
await expect(adapter.acquireInitLock(dbPath)).rejects.toThrow(/unable to acquire init lock/);
|
||||
|
||||
await release1();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen('acquireInitLock reclaims stale lock from dead process', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
const lockPath = `${dbPath}.init.lock`;
|
||||
|
||||
try {
|
||||
// PID far above any realistic range — guaranteed not running on any OS.
|
||||
const DEAD_PROCESS_PID = 2_000_000_000;
|
||||
await fs.writeFile(
|
||||
lockPath,
|
||||
JSON.stringify({ pid: DEAD_PROCESS_PID, ts: Date.now() - 60_000 }),
|
||||
);
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
// Should break the stale lock and acquire successfully
|
||||
const release = await adapter.acquireInitLock(dbPath);
|
||||
|
||||
// Verify we own the lock now
|
||||
const content = await fs.readFile(lockPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.pid).toBe(process.pid);
|
||||
|
||||
await release();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen('release is idempotent — calling twice does not throw', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const release = await adapter.acquireInitLock(dbPath);
|
||||
|
||||
await release();
|
||||
// Second release — lock file already gone, should not throw
|
||||
await release();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen(
|
||||
'initLbug cleans up lock file after successful init with orphan sidecars',
|
||||
async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
const lockPath = `${dbPath}.init.lock`;
|
||||
|
||||
try {
|
||||
// Plant orphan sidecars
|
||||
await fs.writeFile(`${dbPath}.shadow`, 'stale-shadow');
|
||||
await fs.writeFile(`${dbPath}.wal.checkpoint`, 'stale-wal');
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
// Lock file should be released after init completes
|
||||
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||
|
||||
// DB should be functional
|
||||
const rows = await adapter.executeQuery('RETURN 1 AS ok');
|
||||
expect(rows).toEqual([{ ok: 1 }]);
|
||||
|
||||
await adapter.closeLbug();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
itLbugReopen('initLbug cleans up lock file even when DB open fails', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
// Use an invalid path that will cause LadybugDB to fail
|
||||
const dbPath = path.join(tmp.dbPath, 'nonexistent-subdir', 'deep', 'lbug');
|
||||
const lockPath = `${dbPath}.init.lock`;
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
// initLbug should fail (parent dir structure may cause issues), but
|
||||
// we primarily care that the lock file is cleaned up even on failure.
|
||||
// Use a try/catch since the DB open may or may not fail depending
|
||||
// on how mkdir works.
|
||||
try {
|
||||
await adapter.initLbug(dbPath);
|
||||
await adapter.closeLbug();
|
||||
} catch {
|
||||
// Expected — DB open can fail for various reasons
|
||||
}
|
||||
|
||||
// Lock file should always be released, even on failure
|
||||
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1762,6 +1762,80 @@ describe('C++ ambiguous integer-width overloads', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C++ overload resolution: standard-conversion-sequence ranking (#1578)
|
||||
// Disambiguates overloads when exact normalized-type matching cannot,
|
||||
// by scoring each candidate's conversion cost. Exact match (rank 0) wins
|
||||
// over standard conversion (rank 2); same-rank ties still suppress.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('C++ overload resolution — conversion-rank disambiguation (#1578)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'cpp-overload-conversion-rank'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('f(2.5) resolves to f(double) — exact match beats standard conversion', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const fCalls = calls.filter((c) => c.source === 'run' && c.target === 'f');
|
||||
// Conversion-rank scoring picks f(double) as the unique best:
|
||||
// f(double) is exact match (rank 0), f(int) is standard conversion (rank 2).
|
||||
const fDoubleEdges = fCalls.filter((c) => {
|
||||
const tgt = result.graph.getNode(c.rel.targetId);
|
||||
return tgt?.properties.parameterTypes?.[0] === 'double';
|
||||
});
|
||||
expect(fDoubleEdges.length).toBe(1);
|
||||
});
|
||||
|
||||
it('f(42) resolves to f(int) — exact match beats standard conversion', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const fCalls = calls.filter((c) => c.source === 'run' && c.target === 'f');
|
||||
// f(int) is exact match (rank 0), f(double) is standard conversion (rank 2).
|
||||
const fIntEdges = fCalls.filter((c) => {
|
||||
const tgt = result.graph.getNode(c.rel.targetId);
|
||||
return tgt?.properties.parameterTypes?.[0] === 'int';
|
||||
});
|
||||
expect(fIntEdges.length).toBe(1);
|
||||
});
|
||||
|
||||
it('g(42) emits zero CALLS edges — int/long normalize to same type, ambiguous', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const gCalls = calls.filter((c) => c.source === 'run' && c.target === 'g');
|
||||
// g(int) and g(long) both normalize to parameterTypes=['int'],
|
||||
// so isOverloadAmbiguousAfterNormalization triggers suppression.
|
||||
expect(gCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("p('a') resolves to p(int) — char promotion (rank 1) beats char→double conversion (rank 2)", () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const pCalls = calls.filter((c) => c.source === 'run' && c.target === 'p');
|
||||
// p('a'): argType='char'. Exact-type filter misses both p(int) and
|
||||
// p(double), forcing the conversion ranker (step 4b). char→int is an
|
||||
// integral promotion (rank 1), char→double is a standard conversion
|
||||
// (rank 2). p(int) wins with the lower total cost.
|
||||
expect(pCalls.length).toBe(1);
|
||||
const tgt = result.graph.getNode(pCalls[0].rel.targetId);
|
||||
expect(tgt?.properties.parameterTypes?.[0]).toBe('int');
|
||||
});
|
||||
|
||||
it('h(42, 2.5) emits zero CALLS edges — incomparable multi-arg overloads, ambiguous', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const hCalls = calls.filter((c) => c.source === 'run' && c.target === 'h');
|
||||
// h(42, 2.5) + h('a', 2.5): both call sites produce incomparable
|
||||
// pairwise rankings. For h(42, 2.5) with argTypes=['int','double']:
|
||||
// h(int,int): [rank('int','int')=0, rank('double','int')=2]
|
||||
// h(double,double): [rank('int','double')=2, rank('double','double')=0]
|
||||
// h(int,int) better at arg0, h(double,double) better at arg1 → neither
|
||||
// dominates → ambiguous. Same pattern for h('a',2.5).
|
||||
// Contract: zero edges for ALL h() call sites combined (dedup).
|
||||
expect(hCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// U3: anonymous-namespace symbols MUST NOT leak across translation units
|
||||
// (full-pipeline integration test; unit-level coverage exists separately)
|
||||
|
|
|
|||
|
|
@ -175,6 +175,19 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
|||
'Derived<T>::g_unqualified() -> f() does NOT bind to Base<T>::f',
|
||||
'Derived<T>::g_this() -> this->f() resolves to Base<T>::f (1 edge)',
|
||||
'Derived<T>::g() -> this->f() emits zero CALLS edges when only hidden derived overload is arity-incompatible',
|
||||
// Conversion-rank scoring (#1578) disambiguates `f(int)` vs `f(double)`
|
||||
// by ranking exact match over standard conversion. The legacy DAG has no
|
||||
// conversion-rank scoring; it either picks arbitrarily or leaves the call
|
||||
// unresolved. Scope-resolver-only correctness win.
|
||||
'f(2.5) resolves to f(double) — exact match beats standard conversion',
|
||||
'f(42) resolves to f(int) — exact match beats standard conversion',
|
||||
'g(42) emits zero CALLS edges — int/long normalize to same type, ambiguous',
|
||||
// char-literal promotion exercises the conversion ranker (step 4b).
|
||||
// Legacy DAG has no conversion-rank scoring. Scope-resolver-only.
|
||||
"p('a') resolves to p(int) — char promotion (rank 1) beats char→double conversion (rank 2)",
|
||||
// Multi-arg incomparable overloads: pairwise dominance check finds
|
||||
// neither h(int,int) nor h(double,double) dominates. Scope-resolver-only.
|
||||
'h(42, 2.5) emits zero CALLS edges — incomparable multi-arg overloads, ambiguous',
|
||||
// The legacy DAG path has no inline-namespace same-name ambiguity
|
||||
// detection. When two inline children declare the same name, the
|
||||
// legacy path picks an arbitrary match. The scope-resolver returns
|
||||
|
|
|
|||
|
|
@ -1,13 +1,461 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const makeErrnoError = <TCode extends string>(code: TCode, message: string) =>
|
||||
Object.assign(new Error(message), { code });
|
||||
|
||||
/** Stub file handle returned by mocked `fs.open` for the init lock. */
|
||||
const makeOpenMock = () =>
|
||||
vi.fn(async () => ({
|
||||
writeFile: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
/** Standard `fs/promises` mock for tests that only need doInitLbug to succeed. */
|
||||
const mockFsForInit = (dbPath: string) => {
|
||||
const ENOENT_ERROR = makeErrnoError(
|
||||
'ENOENT',
|
||||
`ENOENT: no such file or directory, lstat '${dbPath}'`,
|
||||
);
|
||||
vi.doMock('fs/promises', () => ({
|
||||
default: {
|
||||
lstat: vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
}),
|
||||
access: vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
}),
|
||||
unlink: vi.fn(async () => {}),
|
||||
mkdir: vi.fn(async () => {}),
|
||||
open: makeOpenMock(),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
describe('lbug adapter CHECKPOINT lifecycle', () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock('fs/promises');
|
||||
vi.doUnmock('../../src/core/lbug/lbug-config.js');
|
||||
vi.doUnmock('../../src/core/lbug/extension-loader.js');
|
||||
vi.doUnmock('../../src/core/logger.js');
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('removes orphan sidecars when main DB file is missing before opening LadybugDB', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const dbPath = '/tmp/gitnexus-lbug-orphan-sidecar/lbug';
|
||||
const ENOENT_ERROR = makeErrnoError(
|
||||
'ENOENT',
|
||||
`ENOENT: no such file or directory, access '${dbPath}'`,
|
||||
);
|
||||
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
||||
const conn = {
|
||||
query: vi.fn(async () => queryResult),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
const db = { close: vi.fn(async () => {}) };
|
||||
|
||||
const unlinkMock = vi.fn(async () => {});
|
||||
const accessMock = vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
});
|
||||
|
||||
vi.doMock('fs/promises', () => ({
|
||||
default: {
|
||||
lstat: vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
}),
|
||||
access: accessMock,
|
||||
unlink: unlinkMock,
|
||||
mkdir: vi.fn(async () => {}),
|
||||
open: makeOpenMock(),
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
||||
isOpenRetryExhausted: vi.fn(() => false),
|
||||
waitForWindowsHandleRelease: vi.fn(async () => true),
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
||||
extensionManager: {
|
||||
ensure: vi.fn(async () => true),
|
||||
getCapabilities: vi.fn(() => []),
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
const warnMock = vi.fn();
|
||||
vi.doMock('../../src/core/logger.js', () => ({
|
||||
logger: {
|
||||
warn: warnMock,
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
expect(accessMock).toHaveBeenCalledWith(dbPath);
|
||||
// Unlink called for: .shadow sidecar, .wal.checkpoint sidecar, init lock release
|
||||
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.shadow`);
|
||||
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.wal.checkpoint`);
|
||||
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.init.lock`);
|
||||
expect(warnMock).toHaveBeenCalledTimes(2);
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
'GitNexus: removed orphan sidecar lbug.shadow (no main DB file present)',
|
||||
);
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
'GitNexus: removed orphan sidecar lbug.wal.checkpoint (no main DB file present)',
|
||||
);
|
||||
|
||||
await adapter.closeLbug();
|
||||
});
|
||||
|
||||
it('skips orphan sidecar cleanup when db access fails with non-ENOENT errors', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const dbPath = '/tmp/gitnexus-lbug-orphan-sidecar-eacces/lbug';
|
||||
const ENOENT_ERROR = makeErrnoError(
|
||||
'ENOENT',
|
||||
`ENOENT: no such file or directory, access '${dbPath}'`,
|
||||
);
|
||||
const EACCES_ERROR = makeErrnoError('EACCES', `EACCES: permission denied, access '${dbPath}'`);
|
||||
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
||||
const conn = {
|
||||
query: vi.fn(async () => queryResult),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
const db = { close: vi.fn(async () => {}) };
|
||||
const accessMock = vi.fn(async () => {
|
||||
throw EACCES_ERROR;
|
||||
});
|
||||
const unlinkMock = vi.fn(async () => {});
|
||||
|
||||
vi.doMock('fs/promises', () => ({
|
||||
default: {
|
||||
lstat: vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
}),
|
||||
access: accessMock,
|
||||
unlink: unlinkMock,
|
||||
mkdir: vi.fn(async () => {}),
|
||||
open: makeOpenMock(),
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
||||
isOpenRetryExhausted: vi.fn(() => false),
|
||||
waitForWindowsHandleRelease: vi.fn(async () => true),
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
||||
extensionManager: {
|
||||
ensure: vi.fn(async () => true),
|
||||
getCapabilities: vi.fn(() => []),
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
const warnMock = vi.fn();
|
||||
vi.doMock('../../src/core/logger.js', () => ({
|
||||
logger: {
|
||||
warn: warnMock,
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
expect(accessMock).toHaveBeenCalledWith(dbPath);
|
||||
// Only the init lock release calls unlink — sidecar cleanup was skipped
|
||||
expect(unlinkMock).toHaveBeenCalledTimes(1);
|
||||
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.init.lock`);
|
||||
expect(warnMock).toHaveBeenCalledTimes(1);
|
||||
expect(warnMock.mock.calls[0]?.[0]).toContain(
|
||||
'GitNexus: unable to verify main DB file before orphan sidecar cleanup (EACCES); skipping cleanup:',
|
||||
);
|
||||
|
||||
await adapter.closeLbug();
|
||||
});
|
||||
|
||||
it('does not remove sidecars when main db file is present', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const dbPath = '/tmp/gitnexus-lbug-present/lbug';
|
||||
const ENOENT_ERROR = makeErrnoError(
|
||||
'ENOENT',
|
||||
`ENOENT: no such file or directory, access '${dbPath}'`,
|
||||
);
|
||||
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
||||
const conn = {
|
||||
query: vi.fn(async () => queryResult),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
const db = { close: vi.fn(async () => {}) };
|
||||
const accessMock = vi.fn(async () => {});
|
||||
const unlinkMock = vi.fn(async () => {});
|
||||
|
||||
vi.doMock('fs/promises', () => ({
|
||||
default: {
|
||||
lstat: vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
}),
|
||||
access: accessMock,
|
||||
unlink: unlinkMock,
|
||||
mkdir: vi.fn(async () => {}),
|
||||
open: makeOpenMock(),
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
||||
isOpenRetryExhausted: vi.fn(() => false),
|
||||
waitForWindowsHandleRelease: vi.fn(async () => true),
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
||||
extensionManager: {
|
||||
ensure: vi.fn(async () => true),
|
||||
getCapabilities: vi.fn(() => []),
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
const warnMock = vi.fn();
|
||||
vi.doMock('../../src/core/logger.js', () => ({
|
||||
logger: {
|
||||
warn: warnMock,
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
expect(accessMock).toHaveBeenCalledWith(dbPath);
|
||||
// Only the init lock release calls unlink — no sidecar cleanup needed
|
||||
expect(unlinkMock).toHaveBeenCalledTimes(1);
|
||||
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.init.lock`);
|
||||
expect(warnMock).not.toHaveBeenCalled();
|
||||
|
||||
await adapter.closeLbug();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
code: 'EPERM',
|
||||
message: 'operation not permitted',
|
||||
dbPath: '/tmp/gitnexus-lbug-lstat-eperm/lbug',
|
||||
},
|
||||
{
|
||||
code: 'EACCES',
|
||||
message: 'permission denied',
|
||||
dbPath: '/tmp/gitnexus-lbug-lstat-eacces/lbug',
|
||||
},
|
||||
])('throws when db path lstat fails with non-ENOENT %s', async ({ code, message, dbPath }) => {
|
||||
vi.resetModules();
|
||||
|
||||
const LSTAT_ERROR = makeErrnoError(code, `${code}: ${message}, lstat '${dbPath}'`);
|
||||
const accessMock = vi.fn(async () => {});
|
||||
const unlinkMock = vi.fn(async () => {});
|
||||
|
||||
vi.doMock('fs/promises', () => ({
|
||||
default: {
|
||||
lstat: vi.fn(async () => {
|
||||
throw LSTAT_ERROR;
|
||||
}),
|
||||
access: accessMock,
|
||||
unlink: unlinkMock,
|
||||
mkdir: vi.fn(async () => {}),
|
||||
open: makeOpenMock(),
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => {
|
||||
throw new Error('should not be called');
|
||||
}),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
||||
isOpenRetryExhausted: vi.fn(() => false),
|
||||
waitForWindowsHandleRelease: vi.fn(async () => true),
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
||||
extensionManager: {
|
||||
ensure: vi.fn(async () => true),
|
||||
getCapabilities: vi.fn(() => []),
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../src/core/logger.js', () => ({
|
||||
logger: {
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await expect(adapter.initLbug(dbPath)).rejects.toThrow(new RegExp(message, 'i'));
|
||||
expect(accessMock).not.toHaveBeenCalled();
|
||||
expect(unlinkMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles partial orphan sidecar state and removes only present sidecars', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const dbPath = '/tmp/gitnexus-lbug-partial-sidecar/lbug';
|
||||
const ENOENT_ERROR = makeErrnoError(
|
||||
'ENOENT',
|
||||
`ENOENT: no such file or directory, access '${dbPath}'`,
|
||||
);
|
||||
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
||||
const conn = {
|
||||
query: vi.fn(async () => queryResult),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
const db = { close: vi.fn(async () => {}) };
|
||||
const accessMock = vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
});
|
||||
const unlinkMock = vi.fn(async (target: string) => {
|
||||
if (target.endsWith('.shadow')) throw ENOENT_ERROR;
|
||||
});
|
||||
|
||||
vi.doMock('fs/promises', () => ({
|
||||
default: {
|
||||
lstat: vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
}),
|
||||
access: accessMock,
|
||||
unlink: unlinkMock,
|
||||
mkdir: vi.fn(async () => {}),
|
||||
open: makeOpenMock(),
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
||||
isOpenRetryExhausted: vi.fn(() => false),
|
||||
waitForWindowsHandleRelease: vi.fn(async () => true),
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
||||
extensionManager: {
|
||||
ensure: vi.fn(async () => true),
|
||||
getCapabilities: vi.fn(() => []),
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
const warnMock = vi.fn();
|
||||
vi.doMock('../../src/core/logger.js', () => ({
|
||||
logger: {
|
||||
warn: warnMock,
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.shadow`);
|
||||
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.wal.checkpoint`);
|
||||
expect(warnMock).toHaveBeenCalledTimes(1);
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
'GitNexus: removed orphan sidecar lbug.wal.checkpoint (no main DB file present)',
|
||||
);
|
||||
|
||||
await adapter.closeLbug();
|
||||
});
|
||||
|
||||
it('proceeds to openLbugConnection when orphan sidecar unlink fails', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const dbPath = '/tmp/gitnexus-lbug-sidecar-unlink-fail/lbug';
|
||||
const ENOENT_ERROR = makeErrnoError(
|
||||
'ENOENT',
|
||||
`ENOENT: no such file or directory, access '${dbPath}'`,
|
||||
);
|
||||
const EPERM_ERROR = makeErrnoError(
|
||||
'EPERM',
|
||||
`EPERM: operation not permitted, unlink '${dbPath}.shadow'`,
|
||||
);
|
||||
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
||||
const conn = {
|
||||
query: vi.fn(async () => queryResult),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
const db = { close: vi.fn(async () => {}) };
|
||||
const accessMock = vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
});
|
||||
const unlinkMock = vi.fn(async () => {
|
||||
throw EPERM_ERROR;
|
||||
});
|
||||
|
||||
vi.doMock('fs/promises', () => ({
|
||||
default: {
|
||||
lstat: vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
}),
|
||||
access: accessMock,
|
||||
unlink: unlinkMock,
|
||||
mkdir: vi.fn(async () => {}),
|
||||
open: makeOpenMock(),
|
||||
},
|
||||
}));
|
||||
const openLbugConnectionMock = vi.fn(async () => ({ db, conn }));
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: openLbugConnectionMock,
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
||||
isOpenRetryExhausted: vi.fn(() => false),
|
||||
waitForWindowsHandleRelease: vi.fn(async () => true),
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
||||
extensionManager: {
|
||||
ensure: vi.fn(async () => true),
|
||||
getCapabilities: vi.fn(() => []),
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
const warnMock = vi.fn();
|
||||
vi.doMock('../../src/core/logger.js', () => ({
|
||||
logger: {
|
||||
warn: warnMock,
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
expect(unlinkMock).toHaveBeenCalledTimes(3);
|
||||
expect(warnMock).toHaveBeenCalledTimes(3);
|
||||
expect(warnMock.mock.calls[0]?.[0]).toContain(
|
||||
'GitNexus: failed to remove orphan sidecar lbug.shadow (EPERM) while main DB file is missing; LadybugDB open may still fail:',
|
||||
);
|
||||
expect(warnMock.mock.calls[1]?.[0]).toContain(
|
||||
'GitNexus: failed to remove orphan sidecar lbug.wal.checkpoint (EPERM) while main DB file is missing; LadybugDB open may still fail:',
|
||||
);
|
||||
expect(warnMock.mock.calls[2]?.[0]).toContain('GitNexus: failed to release init lock (EPERM)');
|
||||
expect(openLbugConnectionMock).toHaveBeenCalledWith(expect.anything(), dbPath);
|
||||
|
||||
await adapter.closeLbug();
|
||||
});
|
||||
|
||||
it('drains and closes CHECKPOINT result before closing connection and database handles', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
|
|
@ -43,6 +491,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
}),
|
||||
};
|
||||
|
||||
mockFsForInit('/tmp/gitnexus-lbug-checkpoint-lifecycle/lbug');
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
|
|
@ -104,6 +553,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
close: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
mockFsForInit('/tmp/gitnexus-lbug-query-lifecycle/lbug');
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
|
|
@ -158,6 +608,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
close: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
mockFsForInit('/tmp/gitnexus-lbug-sync-close-lifecycle/lbug');
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
|
|
@ -223,6 +674,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
close: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
mockFsForInit('/tmp/gitnexus-lbug-array-error-lifecycle/lbug');
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
|
|
@ -303,6 +755,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
close: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
mockFsForInit('/tmp/gitnexus-lbug-stream-lifecycle/lbug');
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
|
|
@ -383,6 +836,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
close: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
mockFsForInit('/tmp/gitnexus-lbug-stream-error-lifecycle/lbug');
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue