diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 3c82658f1..54353db41 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -129,6 +129,7 @@ export type { RegistryProviders, OwnerScopedContributor, ArityVerdict, + ConstraintContext, } from './scope-resolution/registries/context.js'; // Scope tree spine + position lookup (RFC §2.2 + §3.1; Ring 2 SHARED #912) diff --git a/gitnexus-shared/src/scope-resolution/registries/context.ts b/gitnexus-shared/src/scope-resolution/registries/context.ts index 9adbbda2e..539242a0e 100644 --- a/gitnexus-shared/src/scope-resolution/registries/context.ts +++ b/gitnexus-shared/src/scope-resolution/registries/context.ts @@ -30,10 +30,43 @@ export interface RegistryProviders { * when absent, every candidate receives `'unknown'` (neutral signal). */ arityCompatibility?(callsite: Callsite, def: SymbolDefinition): ArityVerdict; + + /** + * Language-specific constraint compatibility between a callsite and a + * candidate `def`. Mirrors `arityCompatibility` and shares its three-valued + * verdict shape; the third value `'unknown'` MUST keep the candidate + * (monotonicity: adding a predicate can only narrow correctly, never + * produce a wrong edge). Consulted by `narrowOverloadCandidates` after + * arity + type filters when a candidate carries `templateConstraints`. + * + * Optional; when absent the constraint filter is a pass-through. Languages + * with no constrained-overload semantics leave this undefined. + */ + constraintCompatibility?( + callsite: Callsite, + def: SymbolDefinition, + ctx: ConstraintContext, + ): ArityVerdict; } export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible'; +/** + * Context threaded into `constraintCompatibility`. Kept minimal in the + * Tier-A scope (only `argumentTypes`, riding here until a separate + * `Callsite`-widening refactor moves them onto the call site directly). + * Future Tier-B graph-aware predicates (`is_base_of_v`, etc.) will widen + * this interface with `lookupTypeByName` and similar helpers. + */ +export interface ConstraintContext { + /** + * Per-slot argument types at the call site, normalized per the language + * adapter. Empty string means unknown. Same convention as + * `narrowOverloadCandidates`' `argTypes` parameter. + */ + readonly argumentTypes?: readonly string[]; +} + // ─── Owner-scoped contributor (concrete shape for `RegistryContributor`) ──── /** diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index 7f9840f5c..8a5448014 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -32,6 +32,13 @@ export interface SymbolDefinition { declaredType?: string; /** Generic/template specialization arguments for class-like symbols (e.g. ['User'], ['T*']). */ templateArguments?: string[]; + /** Per-language constraint payload for template / generic overloads + * (e.g. C++ `enable_if_t` predicate trees, C++20 `requires` clauses). + * Opaque to shared code — the producing language adapter owns the shape + * and is the only consumer. Read via the optional + * `ScopeResolver.constraintCompatibility` hook during overload narrowing. + * Absent for symbols that have no constraints (the common case). */ + templateConstraints?: unknown; /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ ownerId?: string; } diff --git a/gitnexus/.claude/settings.local.json b/gitnexus/.claude/settings.local.json index d49edfeac..bc1362ef4 100644 --- a/gitnexus/.claude/settings.local.json +++ b/gitnexus/.claude/settings.local.json @@ -1,5 +1,18 @@ { "permissions": { - "allow": ["mcp__plugin_claude-mem_mcp-search__get_observations"] - } + "allow": [ + "mcp__plugin_claude-mem_mcp-search__get_observations", + "Skill(gitnexus-exploring)", + "Bash(npx gitnexus *)", + "mcp__obsidian-memory__search_nodes", + "mcp__obsidian-memory__add_observations", + "WebSearch", + "WebFetch(domain:cppreference.net)", + "Bash(xargs grep -l \"templateArguments\\\\|parameterTypes\")", + "Bash(gh issue *)", + "Bash(gh pr *)" + ] + }, + "enableAllProjectMcpServers": true, + "enabledMcpjsonServers": ["gitnexus"] } diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index e139cf5f3..058710b2b 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -210,6 +210,37 @@ interface LanguageProviderConfig { ancestorNode: SyntaxNode, ) => { funcName: string; label: NodeLabel } | null; + // ── Template constraint extraction (SFINAE / `requires`) ──────────── + /** + * Extract a per-language template-constraint payload for a templated + * function / method definition. Used by `parsing-processor` to + * disambiguate same-name same-arity overloads whose distinguishing + * signal is their template constraints rather than their parameter + * types — the canonical C++ SFINAE case (issue #1579): + * + * template, int> = 0> + * void process(T); // overload A + * + * template, int> = 0> + * void process(T); // overload B + * + * Both overloads' `parameterTypes` collapse to `['T']`, so without a + * constraint fingerprint in the graph node ID they merge into one + * Function node and the resolver only ever sees one candidate to + * narrow. The hook's return value is stamped onto the node's ID via + * `templateConstraintsIdTag()` AND stored on the node's + * `templateConstraints` property so `resolveDefGraphId` can look up + * the right overload by re-hashing the def's constraints at resolve + * time. + * + * Returns the opaque payload (any JSON-serializable shape — the + * producing adapter owns it; shared code MUST NOT inspect) or + * `undefined` when no constraints exist / the node isn't a templated + * function. Languages without SFINAE / concept semantics leave this + * undefined and the disambiguation is a pass-through. + */ + readonly extractTemplateConstraints?: (definitionNode: SyntaxNode) => unknown; + // ── Labels ──────────────────────────────────────────────────────── /** Override the default node label for definition.function captures. * Return null to skip (C/C++ duplicate), a different label to reclassify diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index c010e0e15..453baca20 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -64,6 +64,7 @@ import { cppImportOwningScope, cppReceiverBinding, } from './cpp/index.js'; +import { extractCppTemplateConstraints } from './cpp/constraint-extractor.js'; const C_BUILT_INS: ReadonlySet = new Set([ 'printf', @@ -463,6 +464,7 @@ export const cppProvider = defineLanguage({ heritageExtractor: createHeritageExtractor(SupportedLanguages.CPlusPlus), labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, + extractTemplateConstraints: extractCppTemplateConstraintsForProvider, // ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ────────── emitScopeCaptures: emitCppScopeCaptures, @@ -474,3 +476,46 @@ export const cppProvider = defineLanguage({ arityCompatibility: cppArityCompatibility, // mergeBindings + resolveImportTarget live on ScopeResolver (see cpp/scope-resolver.ts). }); + +/** + * LanguageProvider hook: walk from a function definition node up to its + * enclosing `template_declaration` and extract the SFINAE / `requires`- + * clause constraint payload. Used by `parsing-processor` to fingerprint + * the graph node ID so two SFINAE overloads with identical + * `parameterTypes` get distinct nodes (issue #1579). + * + * Returns `undefined` for non-templated functions and for templated + * functions whose constraints the extractor can't model — both cases + * result in no constraint suffix on the node ID. + */ +function extractCppTemplateConstraintsForProvider(definitionNode: SyntaxNode): unknown { + // Walk up to the enclosing template_declaration. Bound the walk so we + // can't accidentally land on a far-ancestor template_declaration that + // wraps an unrelated function. + let cur: SyntaxNode | null = definitionNode.parent; + let hops = 8; + let templateDecl: SyntaxNode | null = null; + while (cur !== null && hops-- > 0) { + if (cur.type === 'template_declaration') { + templateDecl = cur; + break; + } + if (cur.type === 'translation_unit') break; + cur = cur.parent; + } + if (templateDecl === null) return undefined; + + // Find the function_declarator inside the function definition so the + // extractor can map template params to function-argument indices. + let declarator: SyntaxNode | null = definitionNode.childForFieldName('declarator'); + let walk = 8; + while (declarator !== null && walk-- > 0) { + if (declarator.type === 'function_declarator') break; + if (declarator.type === 'pointer_declarator' || declarator.type === 'reference_declarator') { + declarator = declarator.childForFieldName('declarator'); + continue; + } + break; + } + return extractCppTemplateConstraints(templateDecl, declarator); +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts index fb47d3122..3c632b4a9 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts @@ -121,7 +121,7 @@ export function computeCppCallArity(node: SyntaxNode): number { * argument types (e.g. `inferCppLiteralType` returns `'string'` for * string literals, not `'std::string'`). */ -function normalizeCppParamType(raw: string): string { +export function normalizeCppParamType(raw: string): string { let t = raw.trim(); // Strip const, volatile, etc. t = t.replace(/\b(const|volatile|restrict|mutable|constexpr)\b/g, '').trim(); diff --git a/gitnexus/src/core/ingestion/languages/cpp/arity.ts b/gitnexus/src/core/ingestion/languages/cpp/arity.ts index e13fa6a3a..998bff455 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/arity.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/arity.ts @@ -8,7 +8,10 @@ import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; * - Default parameters (requiredParameterCount < parameterCount) * - Variadic functions (C-style `...`) * - Parameter packs (V1: treated as variadic) - * - Templates (V1: generic-ignored, arity check on non-template params) + * - Templates: arity check on non-template params; SFINAE / `requires` + * constraints are filtered separately via `constraintCompatibility` + * (see `constraint-filter.ts` and issue #1579). Type-argument generic + * substitution (`List` ≡ `List`) remains out of V1 scope. * * Verdict: * - 'compatible': callsite.arity fits within [required, total] range diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 5c74950bb..48dc4ec32 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -14,6 +14,7 @@ import { markCppAnonymousNamespaceRange, markFileLocal } from './file-local-link import { markCppDependentBase } from './two-phase-lookup.js'; import { markCppAdlSiteArgs, markCppAdlSiteNoAdl, type CppAdlArgInfo } from './adl.js'; import { markCppInlineNamespaceRange } from './inline-namespaces.js'; +import { extractCppTemplateConstraints } from './constraint-extractor.js'; export function emitCppScopeCaptures( sourceText: string, @@ -130,6 +131,24 @@ export function emitCppScopeCaptures( markFileLocal(filePath, nameText); } } + + // SFINAE / `requires`-clause aware constraints for overload + // narrowing (issue #1579). Walk from the enclosing + // `template_declaration` — not the inner `function_definition` — + // so inline method templates (`template<...> class C { template<...> void f(); }`) + // pick up the correct outer constraint scope. + const templateDecl = findEnclosingTemplateDeclaration(fnNode); + if (templateDecl !== null) { + const funcDeclarator = findFunctionDeclarator(fnNode); + const constraints = extractCppTemplateConstraints(templateDecl, funcDeclarator); + if (constraints !== undefined) { + grouped['@declaration.template-constraints'] = syntheticCapture( + '@declaration.template-constraints', + fnNode, + JSON.stringify(constraints), + ); + } + } } } @@ -552,6 +571,52 @@ function extractBaseLookupName(baseNode: SyntaxNode): string { return ''; } +/** + * Walk parent chain from a function_definition / declaration / field_declaration + * to find the enclosing `template_declaration`. Returns null when the function + * isn't templated. The walk only ascends through wrapper nodes the C++ + * grammar inserts between `template_declaration` and the function — direct + * parent in the common case, two hops for member templates whose outer + * class is also templated (we return the INNERMOST template_declaration, + * which carries this function's own template parameters). + */ +function findEnclosingTemplateDeclaration(fnNode: SyntaxNode): SyntaxNode | null { + let cur: SyntaxNode | null = fnNode.parent; + // Cap the walk — `template_declaration` is typically the immediate parent + // or one wrapper away. Anything deeper is an inline-method-in-template + // shape and we still want the innermost templates_declaration whose body + // wraps `fnNode`. + let hops = 8; + while (cur !== null && hops-- > 0) { + if (cur.type === 'template_declaration') return cur; + // Don't ascend past structural boundaries that should reset template scope. + if (cur.type === 'translation_unit') return null; + cur = cur.parent; + } + return null; +} + +/** + * Locate the `function_declarator` AST node within a function definition + * or declaration. Unwraps pointer/reference declarator wrappers. Returns + * null when no function_declarator is found (e.g. variable declaration + * mis-classified upstream). + */ +function findFunctionDeclarator(fnNode: SyntaxNode): SyntaxNode | null { + const direct = fnNode.childForFieldName('declarator'); + let cur: SyntaxNode | null = direct; + let hops = 8; + while (cur !== null && hops-- > 0) { + if (cur.type === 'function_declarator') return cur; + if (cur.type === 'pointer_declarator' || cur.type === 'reference_declarator') { + cur = cur.childForFieldName('declarator'); + continue; + } + break; + } + return findFirstDescendantOfType(fnNode, 'function_declarator'); +} + /** Find the first direct child matching one of the given types. */ function findChildOfType(node: SyntaxNode, types: readonly string[]): SyntaxNode | null { for (let i = 0; i < node.childCount; i++) { @@ -655,6 +720,15 @@ function inferCppLiteralType(node: SyntaxNode): string { * - `int n = ...` → 'int' * - `const int n = ...` → 'int' * Returns empty string if no declaration found or type is auto/placeholder. + * + * Limitation: only `declaration` siblings inside the enclosing + * `compound_statement` are inspected. Function parameters live in the + * `function_declarator`'s `parameter_list` and are NOT resolved here, so + * `void run(int n) { process(n); }` + * infers `''` for `n` and the constraint filter falls through to + * `'unknown'` → ambiguity suppression → 0 CALLS edges. This is a + * "degrade not lie" gap (no wrong edges, just missing ones); extending + * the scan to `parameter_list` is tracked under #1579 as a follow-up. */ function lookupDeclaredTypeForIdentifier(identNode: SyntaxNode): string { const varName = identNode.text; diff --git a/gitnexus/src/core/ingestion/languages/cpp/constraint-extractor.ts b/gitnexus/src/core/ingestion/languages/cpp/constraint-extractor.ts new file mode 100644 index 000000000..7ce193787 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/constraint-extractor.ts @@ -0,0 +1,335 @@ +/** + * Extract C++ template constraint expressions for SFINAE-aware overload + * narrowing (issue #1579). Recognizes 3 AST shapes: + * + * F1 — unqualified non-type template param default: + * `template = 0> void f(T);` + * F2 — `std::`-qualified variant (canonical ticket form): + * `template = 0> void f(T);` + * F4 — C++20 leading requires-clause: + * `template requires P void f(T);` + * + * Deferred (return `{kind:'unknown'}`): + * F3 — void-default `typename = enable_if_t

` (cppref labels this + * `/* WRONG *\/` because adjacent overloads collapse to redeclarations) + * F5 — trailing requires (`void f(T) requires P;`) + * `requires_expression` blocks (`requires { typename T::U; }`) + * `decltype(...)`, fold-expressions, user-defined `_v` aliases. + * + * The output payload is opaque to shared code — only + * `constraint-filter.ts` consumes it. See ISO `[temp.constr.normal]` / + * `` for the + * normalization the Kleene 3-valued evaluator implements. + */ + +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +export type ConstraintExpr = + | { readonly kind: 'atomic'; readonly name: string; readonly args: readonly string[] } + | { readonly kind: 'and'; readonly children: readonly ConstraintExpr[] } + | { readonly kind: 'or'; readonly children: readonly ConstraintExpr[] } + | { readonly kind: 'not'; readonly child: ConstraintExpr } + | { readonly kind: 'unknown' }; + +export interface CppConstraintPayload { + /** Ordered template parameter names (type-params only — non-type defaults + * carrying enable_if predicates are folded into `expr`). */ + readonly templateParams: readonly string[]; + /** + * Mapping from each template parameter name to the call-site argument + * index where its deduced type lives. Computed by scanning the function's + * parameter list for the first parameter whose type is the bare template + * parameter name (or template-typed by it). Missing entries → 'unknown' + * verdict at evaluation time. + */ + readonly paramArgIndex: { readonly [paramName: string]: number }; + /** Root constraint expression. When multiple constraints (multiple + * enable_if defaults, requires clause, etc.) are present they are + * implicitly conjoined under a top-level `and` node. */ + readonly expr: ConstraintExpr; +} + +/** + * Walk a `template_declaration` AST node and extract its constraint + * payload. Caller is responsible for passing the OUTER `template_declaration` + * — for class-member template functions, that means the enclosing + * template_declaration of the class OR of the method, whichever + * directly precedes the function definition. + * + * Returns `undefined` when the template_declaration declares no + * constraints worth tracking (no enable_if default, no requires clause). + * Returns a payload whose `expr.kind === 'unknown'` when constraints are + * present but the extractor cannot model them — monotonicity guarantees + * the filter keeps the candidate in that case. + */ +export function extractCppTemplateConstraints( + templateDecl: SyntaxNode, + funcDeclarator: SyntaxNode | null, +): CppConstraintPayload | undefined { + const paramList = childOfType(templateDecl, 'template_parameter_list'); + if (paramList === null) return undefined; + + const templateParams: string[] = []; + const exprs: ConstraintExpr[] = []; + + for (let i = 0; i < paramList.namedChildCount; i++) { + const param = paramList.namedChild(i); + if (param === null) continue; + if ( + param.type === 'type_parameter_declaration' || + param.type === 'optional_type_parameter_declaration' || + param.type === 'variadic_type_parameter_declaration' + ) { + const id = firstDescendantOfType(param, 'type_identifier'); + if (id !== null) templateParams.push(id.text); + continue; + } + // Non-type parameter — F1 / F2 default-value carries the enable_if + // predicate. Shape: `optional_parameter_declaration` with field + // `default_value`, whose value is a `template_type` named + // `enable_if_t` (F1) or a qualified version (F2). + if (param.type === 'optional_parameter_declaration') { + const defaultVal = param.childForFieldName('default_value'); + const typeNode = param.childForFieldName('type'); + const candidate = extractEnableIfPredicate(typeNode); + if (candidate !== undefined) { + exprs.push(candidate); + } else if (defaultVal !== null) { + // Default-value-as-predicate not yet supported. Bail conservatively. + exprs.push({ kind: 'unknown' }); + } + } + } + + // F4 — C++20 leading `requires` clause. Tree-sitter-cpp exposes it as a + // `requires_clause` child of `template_declaration` (sibling of the + // template_parameter_list). + const requiresClause = childOfType(templateDecl, 'requires_clause'); + if (requiresClause !== null) { + const parsed = parseRequiresClause(requiresClause); + if (parsed !== undefined) exprs.push(parsed); + } + + if (templateParams.length === 0 && exprs.length === 0) return undefined; + + const paramArgIndex = buildParamArgIndex(templateParams, funcDeclarator); + const expr: ConstraintExpr = + exprs.length === 0 + ? { kind: 'unknown' } + : exprs.length === 1 + ? exprs[0] + : { kind: 'and', children: exprs }; + + return { templateParams, paramArgIndex, expr }; +} + +/** + * Inspect a non-type template parameter's declared type to see whether + * it's `enable_if_t` (F1) or `std::enable_if_t` (F2). When + * matched, extract the predicate `P` and return it as a `ConstraintExpr`. + * + * Returns undefined when the parameter's type is not enable_if (so the + * caller can decide whether to bail or ignore). + */ +function extractEnableIfPredicate(typeNode: SyntaxNode | null): ConstraintExpr | undefined { + if (typeNode === null) return undefined; + // Unwrap a type_descriptor wrapper (when present). + let t: SyntaxNode | null = typeNode; + if (t.type === 'type_descriptor') { + t = t.childForFieldName('type') ?? firstDescendantOfType(t, 'template_type'); + } + // F2 shape: tree-sitter-cpp models `std::enable_if_t<...>` as + // `qualified_identifier` whose `name` field is the `template_type`. + // F1 shape (unqualified `enable_if_t<...>`) is `template_type` directly. + if (t !== null && t.type === 'qualified_identifier') { + const inner = t.childForFieldName('name') ?? firstDescendantOfType(t, 'template_type'); + if (inner !== null && inner.type === 'template_type') { + t = inner; + } + } + if (t === null || t.type !== 'template_type') return undefined; + + const nameNode = t.childForFieldName('name'); + if (nameNode === null) return undefined; + const tail = stripQualifiedPrefix(nameNode.text); + if (tail !== 'enable_if_t' && tail !== 'enable_if') return undefined; + + // Predicate is the first template argument of enable_if_t. + const argList = t.childForFieldName('arguments') ?? childOfType(t, 'template_argument_list'); + if (argList === null) return { kind: 'unknown' }; + for (let i = 0; i < argList.namedChildCount; i++) { + const arg = argList.namedChild(i); + if (arg === null) continue; + if (arg.type !== 'type_descriptor') continue; + const inner = arg.childForFieldName('type') ?? arg.namedChild(0); + if (inner === null) continue; + return parseAtomicOrBoolean(inner); + } + return { kind: 'unknown' }; +} + +/** Parse a requires-clause body. The body is a binary or unary expression + * over atomic predicates (variable templates like `is_integral_v`). */ +function parseRequiresClause(requiresClause: SyntaxNode): ConstraintExpr | undefined { + // tree-sitter-cpp exposes the expression as a named child or via a + // `constraint` field. Probe both. + let expr: SyntaxNode | null = requiresClause.childForFieldName('constraint'); + if (expr === null) { + for (let i = 0; i < requiresClause.namedChildCount; i++) { + const c = requiresClause.namedChild(i); + if (c === null) continue; + // Skip the `requires` keyword token. + if (c.type === 'requires') continue; + expr = c; + break; + } + } + if (expr === null) return undefined; + return parseAtomicOrBoolean(expr); +} + +/** + * Recursively parse a constraint sub-expression. Recognizes: + * - `template_type` / `template_function` named `_v` → atomic + * - binary_expression with `&&` / `||` → conjunction / disjunction + * - unary_expression with `!` → negation + * - parenthesized_expression → unwrap + * - anything else → `{kind:'unknown'}` (monotonicity-safe) + * + * `requires_expression` blocks intentionally fall through to 'unknown' + * — they need substitution semantics we don't model in V1. + */ +function parseAtomicOrBoolean(node: SyntaxNode): ConstraintExpr { + // Unwrap parentheses. + if (node.type === 'parenthesized_expression') { + const inner = node.namedChild(0); + return inner === null ? { kind: 'unknown' } : parseAtomicOrBoolean(inner); + } + // Boolean composition. + if (node.type === 'binary_expression') { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + const opNode = node.childForFieldName('operator'); + if (left !== null && right !== null && opNode !== null) { + const op = opNode.text; + const l = parseAtomicOrBoolean(left); + const r = parseAtomicOrBoolean(right); + if (op === '&&') return { kind: 'and', children: [l, r] }; + if (op === '||') return { kind: 'or', children: [l, r] }; + } + return { kind: 'unknown' }; + } + if (node.type === 'unary_expression') { + const opNode = node.childForFieldName('operator') ?? node.namedChild(0); + const arg = node.childForFieldName('argument') ?? node.namedChild(1) ?? node.namedChild(0); + if (opNode !== null && opNode.text === '!' && arg !== null && arg !== opNode) { + return { kind: 'not', child: parseAtomicOrBoolean(arg) }; + } + return { kind: 'unknown' }; + } + // Atomic predicate — `template_type` is the typical shape for variable + // templates like `is_integral_v`. Some grammar variants surface it as + // `template_function` or via a `qualified_identifier` wrapper. + if (node.type === 'template_type' || node.type === 'template_function') { + return parseAtomicTemplate(node); + } + if (node.type === 'qualified_identifier') { + // `std::is_integral_v` shape (without template_type wrapping). + const inner = node.childForFieldName('name'); + if (inner !== null && (inner.type === 'template_type' || inner.type === 'template_function')) { + return parseAtomicTemplate(inner); + } + return { kind: 'unknown' }; + } + // `requires { typename T::U; }` blocks and decltype: out of V1 scope. + return { kind: 'unknown' }; +} + +function parseAtomicTemplate(t: SyntaxNode): ConstraintExpr { + const nameNode = t.childForFieldName('name'); + if (nameNode === null) return { kind: 'unknown' }; + const name = stripQualifiedPrefix(nameNode.text); + const argList = t.childForFieldName('arguments') ?? childOfType(t, 'template_argument_list'); + const args: string[] = []; + if (argList !== null) { + for (let i = 0; i < argList.namedChildCount; i++) { + const arg = argList.namedChild(i); + if (arg === null) continue; + if (arg.type !== 'type_descriptor') continue; + const inner = arg.childForFieldName('type') ?? arg.namedChild(0); + if (inner === null) continue; + // For Tier-A predicates the args are bare template-parameter names + // (`T`, `U`). Anything more elaborate is bailed via 'unknown' at the + // top level if needed; here we just record the textual identifier. + const id = + inner.type === 'type_identifier' ? inner : firstDescendantOfType(inner, 'type_identifier'); + args.push(id !== null ? id.text : inner.text); + } + } + return { kind: 'atomic', name, args }; +} + +/** Build a `paramName → call-site argument index` map by scanning the + * function's parameter list for parameters typed by each template param. */ +function buildParamArgIndex( + templateParams: readonly string[], + funcDeclarator: SyntaxNode | null, +): { [paramName: string]: number } { + const out: { [paramName: string]: number } = {}; + if (funcDeclarator === null || templateParams.length === 0) return out; + const paramList = funcDeclarator.childForFieldName('parameters'); + if (paramList === null) return out; + + let argIdx = 0; + for (let i = 0; i < paramList.childCount; i++) { + const p = paramList.child(i); + if (p === null) continue; + if ( + p.type !== 'parameter_declaration' && + p.type !== 'optional_parameter_declaration' && + p.type !== 'variadic_parameter_declaration' + ) { + continue; + } + const typeNode = p.childForFieldName('type'); + if (typeNode !== null) { + const tname = bareTypeIdentifier(typeNode); + if (tname !== null && templateParams.includes(tname) && !(tname in out)) { + out[tname] = argIdx; + } + } + argIdx++; + } + return out; +} + +function bareTypeIdentifier(typeNode: SyntaxNode): string | null { + if (typeNode.type === 'type_identifier') return typeNode.text; + // Allow `T const`, `T&`, `T*` shapes — the inner type_identifier still wins. + const id = firstDescendantOfType(typeNode, 'type_identifier'); + return id !== null ? id.text : null; +} + +function stripQualifiedPrefix(text: string): string { + const idx = text.lastIndexOf('::'); + return idx >= 0 ? text.slice(idx + 2) : text; +} + +function childOfType(node: SyntaxNode, type: string): SyntaxNode | null { + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c !== null && c.type === type) return c; + } + return null; +} + +function firstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | null { + if (node.type === type) return node; + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c === null) continue; + const hit = firstDescendantOfType(c, type); + if (hit !== null) return hit; + } + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/constraint-filter.ts b/gitnexus/src/core/ingestion/languages/cpp/constraint-filter.ts new file mode 100644 index 000000000..a5f760daa --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/constraint-filter.ts @@ -0,0 +1,147 @@ +/** + * Kleene 3-valued evaluator + curated 4-predicate registry + + * `cppConstraintCompatibility` hook export for SFINAE / `requires`-clause + * filtering (issue #1579). + * + * Semantics: + * - `'incompatible'` → predicate provably fails for these argumentTypes + * (ISO `[temp.constr.atomic]` "not satisfied") + * - `'compatible'` → predicate provably holds + * - `'unknown'` → cannot decide (missing arg-type info, predicate + * not in registry, AST shape bailed during extraction). The shared + * filter keeps the candidate on `'unknown'` — monotonicity guarantee. + * + * Kleene rules (extension of ISO's 2-valued short-circuit conjunction in + * ``): + * AND: incompatible if any child incompatible; compatible iff all + * children compatible; otherwise unknown. + * OR: compatible if any child compatible; incompatible iff all + * children incompatible; otherwise unknown. + * NOT: flip compatible↔incompatible; pass through unknown. + */ + +import type { ArityVerdict, Callsite, ConstraintContext, SymbolDefinition } from 'gitnexus-shared'; +import { classifyType, type TypeClass } from './type-classifier.js'; +import type { ConstraintExpr, CppConstraintPayload } from './constraint-extractor.js'; + +type AtomicEvaluator = (argClasses: readonly TypeClass[]) => ArityVerdict; + +/** + * Curated Tier-A predicate registry — the four canonical + * `` variable templates whose truth tables are closed-form + * over our coarse `TypeClass` enum. + * + * Deferred predicates that need a cv/ref/pointer sidecar on + * `normalizeCppParamType` (today the normalizer strips those markers + * before storage) live in #1579 as one-line follow-up adds. + */ +// ISO `` treats `bool`, `char`, and the signed/unsigned char +// variants as integral types (§21.3.4 Table 48), so `is_integral_v` +// and `is_integral_v` must both yield `true`. We keep the `TypeClass` +// enum precise (separate `'bool'` / `'char'` buckets) so that +// `is_same_v` still resolves to `'incompatible'`; the integral- +// family widening lives here in the predicate evaluators instead. +function isIntegralClass(c: TypeClass | undefined): boolean { + return c === 'integral' || c === 'bool' || c === 'char'; +} + +const REGISTRY = new Map([ + ['is_integral_v', (cls) => verdictFromBool(isIntegralClass(cls[0]), cls)], + ['is_floating_point_v', (cls) => verdictFromBool(cls[0] === 'floating', cls)], + [ + 'is_arithmetic_v', + (cls) => verdictFromBool(isIntegralClass(cls[0]) || cls[0] === 'floating', cls), + ], + // NOTE: cv-qualifiers are stripped by `normalizeCppParamType` before the + // type token reaches `classifyType`, so `is_same_v` returns + // `'compatible'` instead of the ISO-correct `false`. Tracked under the + // cv-sidecar refactor in #1579's "Out of scope" list; until that lands + // this approximation matches the common `is_same_v` + // dispatch idiom and silently degrades on cv-distinct compares. + [ + 'is_same_v', + (cls) => { + if (cls.length < 2 || cls[0] === 'unknown' || cls[1] === 'unknown') return 'unknown'; + return cls[0] === cls[1] ? 'compatible' : 'incompatible'; + }, + ], +]); + +function verdictFromBool(predicate: boolean, cls: readonly TypeClass[]): ArityVerdict { + if (cls[0] === 'unknown') return 'unknown'; + return predicate ? 'compatible' : 'incompatible'; +} + +/** Public surface — registered as `ScopeResolver.constraintCompatibility`. */ +export function cppConstraintCompatibility( + _callsite: Callsite, + def: SymbolDefinition, + ctx: ConstraintContext, +): ArityVerdict { + const payload = def.templateConstraints as CppConstraintPayload | undefined; + if (payload === undefined) return 'unknown'; + return evaluate(payload.expr, payload, ctx); +} + +function evaluate( + expr: ConstraintExpr, + payload: CppConstraintPayload, + ctx: ConstraintContext, +): ArityVerdict { + switch (expr.kind) { + case 'unknown': + return 'unknown'; + case 'atomic': { + const evaluator = REGISTRY.get(expr.name); + if (evaluator === undefined) return 'unknown'; + const classes = expr.args.map((paramName) => { + const argIdx = payload.paramArgIndex[paramName]; + if (argIdx === undefined) return 'unknown' as TypeClass; + const token = ctx.argumentTypes?.[argIdx]; + if (token === undefined || token === '') return 'unknown' as TypeClass; + return classifyType(token); + }); + return evaluator(classes); + } + case 'and': { + let result: ArityVerdict = 'compatible'; + for (const child of expr.children) { + const v = evaluate(child, payload, ctx); + if (v === 'incompatible') return 'incompatible'; + if (v === 'unknown') result = 'unknown'; + } + return result; + } + case 'or': { + let result: ArityVerdict = 'incompatible'; + for (const child of expr.children) { + const v = evaluate(child, payload, ctx); + if (v === 'compatible') return 'compatible'; + if (v === 'unknown') result = 'unknown'; + } + return result; + } + case 'not': { + const v = evaluate(expr.child, payload, ctx); + if (v === 'compatible') return 'incompatible'; + if (v === 'incompatible') return 'compatible'; + return 'unknown'; + } + } +} + +/** Exposed for unit tests — lets `cpp-constraint.test.ts` assert + * `expect(getRegistrySize()).toBe(4)` without exporting the Map itself. */ +export function getRegistrySize(): number { + return REGISTRY.size; +} + +/** Exposed for unit tests covering the Kleene 3-valued truth table + * directly, without an AST round-trip. */ +export function evaluateForTest( + expr: ConstraintExpr, + payload: CppConstraintPayload, + ctx: ConstraintContext, +): ArityVerdict { + return evaluate(expr, payload, ctx); +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 4e226bbac..4a1f343d1 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -33,6 +33,7 @@ import { resolveCppQualifiedNamespaceMember, } from './inline-namespaces.js'; import { populateCppRangeBindings } from './range-bindings.js'; +import { cppConstraintCompatibility } from './constraint-filter.js'; /** * C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by @@ -85,6 +86,12 @@ export const cppScopeResolver: ScopeResolver = { // (def, callsite). ScopeResolver contract is (callsite, def). arityCompatibility: (callsite, def) => cppArityCompatibility(def, callsite), + // SFINAE / `requires`-clause aware overload filter (issue #1579). + // Drops candidates whose template constraints (`enable_if_t`, + // C++20 `requires P`) provably fail at the call site. Three-valued — + // `'unknown'` keeps the candidate, preserving "degrade not lie". + constraintCompatibility: cppConstraintCompatibility, + buildMro: (graph, parsedFiles, nodeLookup) => buildMro(graph, parsedFiles, nodeLookup, defaultLinearize), diff --git a/gitnexus/src/core/ingestion/languages/cpp/type-classifier.ts b/gitnexus/src/core/ingestion/languages/cpp/type-classifier.ts new file mode 100644 index 000000000..d26435abf --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/type-classifier.ts @@ -0,0 +1,59 @@ +/** + * Coarse-grained type classifier for C++ constraint evaluation + * (``, + * ``). + * + * Maps a normalized type token (as produced by `normalizeCppParamType` / + * the call-site inference in `captures.ts`) to one of the categories + * the `` predicate registry uses for SFINAE filtering. + * + * Intentionally coarse: cv / pointer / reference qualifiers are stripped + * upstream by `normalizeCppParamType`. Tier-A predicates + * (`is_integral_v`, `is_floating_point_v`, `is_arithmetic_v`, `is_same_v`) + * are insensitive to those modifiers per ISO `` semantics + * ("including any cv-qualified variants"). + */ + +export type TypeClass = + | 'integral' + | 'floating' + | 'bool' + | 'char' + | 'string' + | 'null' + | 'class' + | 'unknown'; + +/** + * Classify a normalized C++ type token. The mapping mirrors the literal- + * inference table in `captures.ts:inferCppLiteralType` plus the std:: + * normalization in `arity-metadata.ts:normalizeCppParamType`. + * + * Caller note: token must already be normalized (no `const`, no `&` / `*`, + * no `std::` prefix). Tokens passed via `ConstraintContext.argumentTypes` + * coming from `inferCppCallArgTypes` satisfy this. + */ +export function classifyType(token: string): TypeClass { + if (token.length === 0) return 'unknown'; + switch (token) { + case 'int': + return 'integral'; + case 'double': + case 'float': + return 'floating'; + case 'bool': + return 'bool'; + case 'char': + return 'char'; + case 'string': + return 'string'; + case 'null': + return 'null'; + default: + // After normalization, anything that isn't a recognized primitive + // is assumed to be a class-like type. The Tier-A predicate registry + // doesn't introspect class types — `is_integral_v` etc. simply + // returns `false` for `'class'`, matching ISO behavior. + return 'class'; + } +} diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index f88e78ed9..56a47aa36 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -30,7 +30,11 @@ import { constTagForId, buildCollisionGroups, } from './utils/method-props.js'; -import { extractTemplateArguments, templateArgumentsIdTag } from './utils/template-arguments.js'; +import { + extractTemplateArguments, + templateArgumentsIdTag, + templateConstraintsIdTag, +} from './utils/template-arguments.js'; import type { LanguageProvider } from './language-provider.js'; import type { ParsedFile } from 'gitnexus-shared'; import { WorkerPool } from './workers/worker-pool.js'; @@ -650,9 +654,38 @@ const processParsingSequential = async ( classTemplateArguments.length > 0 ? templateArgumentsIdTag(classTemplateArguments) : ''; + // SFINAE / `requires`-clause aware ID disambiguation (issue #1579). + // Function-template overloads with identical parameterTypes but + // mutually-exclusive constraints (e.g. `enable_if_t>` + // vs `enable_if_t>`) need distinct graph + // nodes so the constraint-filter step in `narrowOverloadCandidates` + // has two candidates to narrow between. Without this tag they + // collapse to a single Function node and the SFINAE call resolves + // to only one edge regardless of which overload's constraint holds. + // The provider hook is the right invocation point — parsing-processor + // sees raw tree-sitter matches without the `@`-prefixed synthetic + // captures `scope-extractor` consumes, so we delegate extraction to + // the language adapter (C++ implements this; other languages opt out). + let parsedTemplateConstraints: unknown = undefined; + let constraintsTag = ''; + if ( + (nodeLabel === 'Function' || nodeLabel === 'Method') && + provider.extractTemplateConstraints !== undefined && + definitionNode !== null + ) { + try { + parsedTemplateConstraints = provider.extractTemplateConstraints(definitionNode); + if (parsedTemplateConstraints !== undefined) { + constraintsTag = templateConstraintsIdTag(parsedTemplateConstraints); + } + } catch { + parsedTemplateConstraints = undefined; + constraintsTag = ''; + } + } const nodeId = generateId( nodeLabel, - `${file.path}:${qualifiedName}${classTemplateTag}${arityTag}`, + `${file.path}:${qualifiedName}${classTemplateTag}${arityTag}${constraintsTag}`, ); const classNodeForSymbol = definitionNodeForRange || definitionNode || nameNode; const qualifiedTypeName = @@ -689,6 +722,9 @@ const processParsingSequential = async ( ...(classTemplateArguments !== undefined && classTemplateArguments.length > 0 ? { templateArguments: classTemplateArguments } : {}), + ...(parsedTemplateConstraints !== undefined + ? { templateConstraints: parsedTemplateConstraints } + : {}), ...(frameworkHint ? { astFrameworkMultiplier: frameworkHint.entryPointMultiplier, diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 44088b49f..fe9045a8d 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -547,6 +547,7 @@ function buildDefFromDeclarationMatch( const parameterTypes = parseJsonStringArrayCapture(match['@declaration.parameter-types']); const declaredType = match['@declaration.field-type']?.text; const returnType = match['@declaration.return-type']?.text; + const templateConstraints = parseJsonCapture(match['@declaration.template-constraints']); return { nodeId: makeDefId(filePath, anchor.range, type, nameCap.text), @@ -559,9 +560,23 @@ function buildDefFromDeclarationMatch( ...(declaredType !== undefined ? { declaredType } : {}), ...(returnType !== undefined ? { returnType } : {}), ...(templateArguments !== undefined ? { templateArguments } : {}), + ...(templateConstraints !== undefined ? { templateConstraints } : {}), }; } +/** Parse an opaque JSON payload synthesized by per-language captures + * (e.g. C++ `@declaration.template-constraints`). Producer owns the + * shape; shared code threads it through as `unknown` per the + * `SymbolDefinition.templateConstraints` contract. */ +function parseJsonCapture(cap: { readonly text: string } | undefined): unknown { + if (cap === undefined) return undefined; + try { + return JSON.parse(cap.text); + } catch { + return undefined; + } +} + function parseIntCapture(cap: { readonly text: string } | undefined): number | undefined { if (cap === undefined) return undefined; const n = Number.parseInt(cap.text, 10); @@ -977,6 +992,7 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@declaration.parameter-count', '@declaration.required-parameter-count', '@declaration.parameter-types', + '@declaration.template-constraints', ]); /** 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 c6f494368..752d4e4b4 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -254,6 +254,7 @@ import type { BindingRef, Callsite, + ConstraintContext, ParsedFile, ScopeId, SupportedLanguages, @@ -279,6 +280,10 @@ export type LinearizeStrategy = ( /** Result of `ScopeResolver.arityCompatibility` — mirrors `RegistryProviders.arityCompatibility`. */ export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible'; +/** Re-exported for ScopeResolver consumers — same shape as + * `RegistryProviders.constraintCompatibility`'s third parameter. */ +export type { ConstraintContext } from 'gitnexus-shared'; + export interface ScopeResolver { /** Identity for telemetry + per-language flag check. */ readonly language: SupportedLanguages; @@ -374,6 +379,28 @@ export interface ScopeResolver { */ arityCompatibility(callsite: Callsite, def: SymbolDefinition): ArityVerdict; + /** + * Per-language constraint compatibility between a callsite and a + * candidate `def` that carries `templateConstraints` metadata. + * Mirrors `arityCompatibility` semantics: the three-valued verdict + * MUST treat `'unknown'` as keep-candidate (monotonicity — adding + * a predicate can only narrow correctly, never produce a wrong + * edge). Consulted by `narrowOverloadCandidates` after the arity + * and parameter-type filters. + * + * Optional. Languages without constrained-overload semantics + * (SFINAE, `requires` clauses, trait bounds, conditional types) + * leave this undefined and the constraint filter is a pass-through. + * + * C++ is the first consumer; see `languages/cpp/constraint-filter.ts` + * for the Tier-A predicate registry and Kleene 3-valued evaluator. + */ + readonly constraintCompatibility?: ( + callsite: Callsite, + def: SymbolDefinition, + ctx: ConstraintContext, + ) => ArityVerdict; + // ─── Per-language strategies ─────────────────────────────────────────────── /** 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 adc32bbd3..8a1bf5a0a 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -21,6 +21,7 @@ import type { NodeLabel, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { generateId } from '../../../../lib/utils.js'; import { qualifiedKey, simpleKey, type GraphNodeLookup } from '../graph-bridge/node-lookup.js'; +import { templateConstraintsIdTag } from '../../utils/template-arguments.js'; /** * Labels that may legitimately ANCHOR a CALLS/ACCESSES edge as the * source ("caller"). A Variable / Property can be the TARGET of an @@ -76,12 +77,31 @@ export function resolveDefGraphId( type?: NodeLabel; parameterTypes?: readonly string[]; templateArguments?: readonly string[]; + templateConstraints?: unknown; }, nodeLookup: GraphNodeLookup, ): string | undefined { const qn = def.qualifiedName; if (qn === undefined || qn.length === 0) return undefined; if (def.type !== undefined) { + // SFINAE / `requires`-clause disambiguation (issue #1579) — try the + // constraint-fingerprinted key FIRST. Two function-template overloads + // with identical `parameterTypes` but mutually-exclusive SFINAE + // constraints route to their distinct graph nodes via this key. + // Must run before the parameter-types key because both overloads + // share the latter. + if ( + (def.type === 'Function' || def.type === 'Method') && + def.templateConstraints !== undefined + ) { + const cKey = qualifiedKey( + filePath, + def.type, + `${qn}${templateConstraintsIdTag(def.templateConstraints)}`, + ); + const cHit = nodeLookup.get(cKey); + if (cHit !== undefined) return cHit; + } // Overload disambiguation: when the def carries parameter types, // try the parameter-typed key first so same-name same-arity // overloads route to their distinct graph nodes. diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index d712c29e3..fd8c3cf23 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -20,6 +20,7 @@ import type { NodeLabel } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; +import { templateConstraintsIdTag } from '../../utils/template-arguments.js'; export type GraphNodeLookup = ReadonlyMap; @@ -97,6 +98,21 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { // Each overload is unique — set unconditionally. lookup.set(pKey, node.id); } + // SFINAE / `requires`-clause disambiguation (issue #1579) — register + // a constraint-fingerprinted key so resolveDefGraphId can locate the + // correct overload by hashing the def's `templateConstraints`. Mirrors + // the parameter-types key but keys on the opaque constraint payload + // instead, separating two `process` overloads whose + // `parameterTypes=['T']` would otherwise collide. + const tConstraints = (props as { templateConstraints?: unknown }).templateConstraints; + if (tConstraints !== undefined && (node.label === 'Function' || node.label === 'Method')) { + const cKey = qualifiedKey( + props.filePath, + node.label, + `${qualified}${templateConstraintsIdTag(tConstraints)}`, + ); + lookup.set(cKey, node.id); + } if ( (node.label === 'Class' || node.label === 'Struct' || 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 2be4a6809..a3bc1e4ec 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 @@ -23,6 +23,7 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe import type { SemanticModel } from '../../model/semantic-model.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; +import type { ScopeResolver } from '../contract/scope-resolver.js'; import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js'; import { findAllCallableBindingsInScope, @@ -66,6 +67,12 @@ export function emitFreeCallFallback( parsedFiles: readonly ParsedFile[], ) => readonly SymbolDefinition[] | undefined; readonly conversionRankFn?: ConversionRankFn; + /** Optional per-language constraint hook threaded into + * `narrowOverloadCandidates`. Drops candidates whose template + * constraints (e.g. C++ `enable_if_t`, C++20 `requires`) provably + * fail at the call site. Three-valued; `'unknown'` keeps the + * candidate (monotonicity). */ + readonly constraintCompatibility?: ScopeResolver['constraintCompatibility']; } = {}, ): number { let emitted = 0; @@ -93,13 +100,10 @@ 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, - options.conversionRankFn, - ); + fnDef = pickImplicitThisOverload(site, scopes, workspaceIndex, model, { + conversionRankFn: options.conversionRankFn, + constraintCompatibility: options.constraintCompatibility, + }); } // Scope-chain callable lookup. First-match preserves scope-chain // precedence (local shadows import). When a conversion-rank function @@ -121,7 +125,10 @@ export function emitFreeCallFallback( allCallables, site.arity, site.argumentTypes, - options.conversionRankFn, + { + conversionRankFn: options.conversionRankFn, + constraintCompatibility: options.constraintCompatibility, + }, ); if (narrowed.length === 1) { fnDef = narrowed[0]; @@ -166,37 +173,45 @@ export function emitFreeCallFallback( parsedFiles, ); - // 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. + const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`; if (adl === undefined || adl.length === 0) { - if (ordinary.length <= 1 || options.conversionRankFn === undefined) { + // No ADL contribution. Default behavior: `ordinary[0]` — + // scope-chain walk preserves local-shadows-import precedence. + // + // Narrowing kicks in when either disambiguation signal is + // present: any candidate carries `templateConstraints` + // (SFINAE / `requires`-clause guarded templates, #1579), OR + // a conversion-rank function is provided (#1606 / #1578). + // Both hooks are threaded into `narrowOverloadCandidates` + // via the unified `OverloadNarrowingHookCtx`. + const hasConstraints = ordinary.some((d) => d.templateConstraints !== undefined); + const canNarrow = hasConstraints || options.conversionRankFn !== undefined; + if (ordinary.length <= 1 || !canNarrow) { fnDef = ordinary[0]; } else { - const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`; - const narrowed = narrowOverloadCandidates( - ordinary, - site.arity, - site.argumentTypes, - options.conversionRankFn, - ); + const narrowed = narrowOverloadCandidates(ordinary, site.arity, site.argumentTypes, { + conversionRankFn: options.conversionRankFn, + constraintCompatibility: options.constraintCompatibility, + }); 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. + } else if (narrowed.length === 0) { + handledSites.add(siteKey); + continue; + } else { + // >1 survivors: same-file → suppress (true overloads, + // "degrade not lie" — no edge beats a wrong one, and + // SFINAE-ambiguous calls land here). Cross-file → + // first-match (shadowing semantics). 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 + fnDef = ordinary[0]; } } } else { - const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`; const merged: SymbolDefinition[] = []; const seenMerge = new Set(); const push = (defs: readonly SymbolDefinition[]): void => { @@ -209,12 +224,10 @@ export function emitFreeCallFallback( push(ordinary); push(adl); - const narrowed = narrowOverloadCandidates( - merged, - site.arity, - site.argumentTypes, - options.conversionRankFn, - ); + const narrowed = narrowOverloadCandidates(merged, site.arity, site.argumentTypes, { + conversionRankFn: options.conversionRankFn, + constraintCompatibility: options.constraintCompatibility, + }); if (narrowed.length === 1) { fnDef = narrowed[0]; } else if (narrowed.length === 0) { @@ -335,7 +348,9 @@ function pickUniqueGlobalCallable( // 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); + const narrowed = narrowOverloadCandidates(scopeDefs, callArity, callArgTypes, { + conversionRankFn, + }); if (narrowed.length === 1) return narrowed[0]; } @@ -373,7 +388,9 @@ function pickUniqueGlobalCallable( } // Same argument-type + conversion-rank narrowing for the model pool. if (defs.length > 1) { - const narrowed = narrowOverloadCandidates(defs, callArity, callArgTypes, conversionRankFn); + const narrowed = narrowOverloadCandidates(defs, callArity, callArgTypes, { + conversionRankFn, + }); if (narrowed.length === 1) return narrowed[0]; } @@ -449,7 +466,10 @@ export function pickImplicitThisOverload( scopes: ScopeResolutionIndexes, workspaceIndex: WorkspaceResolutionIndex, model: SemanticModel, - conversionRankFn?: ConversionRankFn, + hookCtx?: { + readonly conversionRankFn?: ConversionRankFn; + readonly constraintCompatibility?: ScopeResolver['constraintCompatibility']; + }, ): SymbolDefinition | undefined { // Find the enclosing Class scope by walking parents. let curId: ScopeId | null = site.inScope; @@ -477,12 +497,10 @@ 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, - conversionRankFn, - ); + const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, { + conversionRankFn: hookCtx?.conversionRankFn, + constraintCompatibility: hookCtx?.constraintCompatibility, + }); if (candidates.length !== 1) return undefined; return candidates[0]; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts index 5c9338f40..564fe2200 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts @@ -25,15 +25,20 @@ * 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. + * `conversionRankFn` is provided (via `hookCtx`), 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. + * 4c. Final per-candidate constraint filter (SFINAE / `requires`). + * When `constraintCompatibility` is provided via `hookCtx`, drop + * candidates whose template constraints provably fail at the + * call site. Three-valued; `'unknown'` keeps the candidate + * (monotonicity). * 5. Empty input returns empty output. */ -import type { SymbolDefinition } from 'gitnexus-shared'; +import type { ArityVerdict, Callsite, ConstraintContext, SymbolDefinition } from 'gitnexus-shared'; /** * Per-slot conversion-rank function. Returns a numeric cost for @@ -48,11 +53,34 @@ import type { SymbolDefinition } from 'gitnexus-shared'; */ export type ConversionRankFn = (argType: string, paramType: string) => number; +/** + * Optional hook bundle for narrowing extension points. Threaded in + * from `pickOverload` / `pickImplicitThisOverload` so per-language + * narrowing can layer in conversion-rank scoring (#1606) and + * constraint filtering (#1579) without changing the call signature + * at every site. Each hook is independently optional — leaving both + * undefined preserves the legacy arity + exact-type behavior. + */ +export interface OverloadNarrowingHookCtx { + /** Conversion-rank scoring fallback (step 4b). Engages when the + * exact-type filter rejects every candidate. */ + readonly conversionRankFn?: ConversionRankFn; + /** Constraint filter (step 4c). Drops candidates whose template + * guards (SFINAE `enable_if_t`, C++20 `requires`, future Rust + * trait bounds, etc.) provably fail at the call site. Three-valued + * — `'unknown'` keeps the candidate (monotonicity). */ + readonly constraintCompatibility?: ( + callsite: Callsite, + def: SymbolDefinition, + ctx: ConstraintContext, + ) => ArityVerdict; +} + export function narrowOverloadCandidates( overloads: readonly SymbolDefinition[], argCount: number | undefined, argTypes: readonly string[] | undefined, - conversionRankFn?: ConversionRankFn, + hookCtx?: OverloadNarrowingHookCtx, ): readonly SymbolDefinition[] { if (overloads.length === 0) return []; @@ -93,6 +121,7 @@ export function narrowOverloadCandidates( const candidates: readonly SymbolDefinition[] = arityMatches.length > 0 ? arityMatches : anyUnknownBounds ? overloads : []; + let result: readonly SymbolDefinition[] = candidates; if (argTypes !== undefined && argTypes.length > 0) { const typed = candidates.filter((d) => { const params = d.parameterTypes; @@ -103,21 +132,45 @@ 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; + if (typed.length > 0) { + result = typed; + } else if (hookCtx?.conversionRankFn !== undefined) { + // ── Conversion-rank scoring (step 4b) ────────────────────────── + // The exact-type filter rejected every candidate. 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. When + // ranking also yields empty, fall through to the arity-filtered + // `candidates` set — matches pre-#1606 behavior. + const ranked = rankByConversion(candidates, argTypes, hookCtx.conversionRankFn); + if (ranked.length > 0) result = ranked; } } - return candidates; + // Constraint filter (step 4c; Tier-A — SFINAE / `requires` clauses). + // Runs after arity, exact-type, and conversion-rank filters so the + // hook only sees candidates already viable on the other axes. + // Three-valued: `'compatible'` and `'unknown'` keep the candidate + // (monotonicity — adding a predicate must never cause a wrong edge); + // only `'incompatible'` drops it. Candidates without + // `templateConstraints` are always kept. + // + // No fallback to the unconstrained set when this filter empties the + // candidate list: a fully-`'incompatible'` verdict is authoritative. + // The downstream `OVERLOAD_AMBIGUOUS` sentinel still guards the empty + // case, so a buggy hook that wrongly returns `'incompatible'` for + // every candidate degrades to today's "suppress edge" behavior rather + // than emitting a wrong edge. + if (hookCtx?.constraintCompatibility !== undefined && argCount !== undefined) { + const callsite: Callsite = { arity: argCount }; + const ctx: ConstraintContext = argTypes !== undefined ? { argumentTypes: argTypes } : {}; + result = result.filter((def) => { + if (def.templateConstraints === undefined) return true; + return hookCtx.constraintCompatibility!(callsite, def, ctx) !== 'incompatible'; + }); + } + + return result; } /** diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index ffc29d176..939f2d88e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -74,6 +74,7 @@ type ReceiverBoundProviderSubset = Pick< | 'resolveQualifiedReceiverMember' | 'resolveThisViaEnclosingClass' | 'conversionRankFn' + | 'constraintCompatibility' >; function normalizeTemplateArgToken(value: string): string { @@ -344,7 +345,10 @@ export function emitReceiverBoundCalls( methodOverloads, site.arity, site.argumentTypes, - provider.conversionRankFn, + { + conversionRankFn: provider.conversionRankFn, + constraintCompatibility: provider.constraintCompatibility, + }, ); if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) { ambiguous = true; @@ -648,13 +652,7 @@ export function emitReceiverBoundCalls( let memberDef: SymbolDefinition | undefined; let ambiguous = false; for (const ownerId of chain) { - const picked = pickOverload( - ownerId, - memberName, - site, - model, - provider.conversionRankFn, - ); + const picked = pickOverload(ownerId, memberName, site, model, provider); if (picked === OVERLOAD_AMBIGUOUS) { ambiguous = true; break; @@ -722,7 +720,7 @@ function pickOverload( memberName: string, site: ParsedFile['referenceSites'][number], model: SemanticModel, - conversionRankFn?: (argType: string, paramType: string) => number, + provider: ReceiverBoundProviderSubset, ): SymbolDefinition | typeof OVERLOAD_AMBIGUOUS | undefined { const overloads = model.methods.lookupAllByOwner(ownerId, memberName); if (overloads.length === 0) { @@ -733,12 +731,10 @@ function pickOverload( } if (overloads.length === 1) return overloads[0]; - const candidates = narrowOverloadCandidates( - overloads, - site.arity, - site.argumentTypes, - conversionRankFn, - ); + const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, { + conversionRankFn: provider.conversionRankFn, + constraintCompatibility: provider.constraintCompatibility, + }); // 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. diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 5493368da..0b9948368 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -383,6 +383,7 @@ export function runScopeResolution( isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller, resolveAdlCandidates: provider.resolveAdlCandidates, conversionRankFn: provider.conversionRankFn, + constraintCompatibility: provider.constraintCompatibility, }, ); const { emitted, skipped } = emitReferencesViaLookup( diff --git a/gitnexus/src/core/ingestion/utils/template-arguments.ts b/gitnexus/src/core/ingestion/utils/template-arguments.ts index e1c6e3463..a808c9ae8 100644 --- a/gitnexus/src/core/ingestion/utils/template-arguments.ts +++ b/gitnexus/src/core/ingestion/utils/template-arguments.ts @@ -55,3 +55,34 @@ export function templateArgumentsIdTag(templateArguments?: readonly string[]): s if (templateArguments === undefined || templateArguments.length === 0) return ''; return `~${templateArguments.join(',')}`; } + +/** + * Stable short hash for the opaque `SymbolDefinition.templateConstraints` + * payload (issue #1579). Two function-template overloads with identical + * `parameterTypes` but mutually-exclusive SFINAE constraints + * (`enable_if_t>` vs `enable_if_t>`) + * must produce distinct graph node IDs so the constraint-filter step + * has two candidates to narrow between. Without this they collapse to + * a single Function node and the SFINAE golden case can only emit one + * edge regardless of resolver fixes. + * + * FNV-1a 32-bit, base36 encoded. Deterministic; non-cryptographic — the + * tag's job is collision-avoidance among same-name overloads in one + * file, not security. + */ +export function constraintsHash(jsonText: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < jsonText.length; i++) { + h ^= jsonText.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(36); +} + +/** Build the `~c:` ID suffix from an opaque constraint payload. + * Returns empty string when the payload is absent so callers can + * string-concatenate unconditionally. */ +export function templateConstraintsIdTag(payload: unknown): string { + if (payload === undefined || payload === null) return ''; + return `~c:${constraintsHash(JSON.stringify(payload))}`; +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-arity-survives-unknown/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-arity-survives-unknown/main.cpp new file mode 100644 index 000000000..41a88e385 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-arity-survives-unknown/main.cpp @@ -0,0 +1,23 @@ +// Filter ordering: arity gate runs BEFORE constraint filter, so a +// bad-arity candidate is dropped even when its constraint would have +// returned 'unknown' (and thus kept it). Asserts exactly 1 CALLS edge +// to the good overload — guards the filter-step ordering invariant. +#include + +template +constexpr bool MyCustomTrait_v = true; + +template, int> = 0> +void process(T value) { + (void)value; +} + +template, int> = 0> +void process(T value, T other) { + (void)value; + (void)other; +} + +void run() { + process(42); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-golden/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-golden/main.cpp new file mode 100644 index 000000000..1380c58b2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-golden/main.cpp @@ -0,0 +1,21 @@ +// SFINAE golden case (issue #1579). +// Two `process` overloads guarded by mutually-exclusive enable_if_t +// predicates. ISO C++: process(42) → integral overload (line 7); +// process(3.14) → floating overload (line 12). V1 pre-fix: ambiguous, +// 0 CALLS edges. With constraintCompatibility wired up: 2 edges. +#include + +template, int> = 0> +void process(T value) { + (void)value; +} + +template, int> = 0> +void process(T value) { + (void)value; +} + +void run() { + process(42); + process(3.14); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-requires-clause/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-requires-clause/main.cpp new file mode 100644 index 000000000..a3f97a68a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-requires-clause/main.cpp @@ -0,0 +1,20 @@ +// SFINAE via C++20 `requires` clause (F4 AST shape from #1579). +// Same logical disambiguation as cpp-sfinae-golden — proves the +// constraint-extractor recognizes the requires-clause shape, not just +// `enable_if_t<>` defaults. +#include + +template requires std::is_integral_v +void process(T value) { + (void)value; +} + +template requires std::is_floating_point_v +void process(T value) { + (void)value; +} + +void run() { + process(42); + process(3.14); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-unknown-predicate/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-unknown-predicate/main.cpp new file mode 100644 index 000000000..08bcbbde6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-unknown-predicate/main.cpp @@ -0,0 +1,27 @@ +// Monotonicity contract: unknown predicates keep both candidates. +// `MyCustomTrait_v` is NOT in the Tier-A registry, so both overloads' +// constraint check returns 'unknown' → both survive narrowing → fall +// through to `isOverloadAmbiguousAfterNormalization` (both have +// parameterTypes=['T']) → edge suppressed. +// +// Asserts CALLS.length === 0 — adding a predicate must never produce a +// wrong edge; the worst case is the pre-existing "degrade not lie" +// suppression. +#include + +template +constexpr bool MyCustomTrait_v = true; + +template, int> = 0> +void process(T value) { + (void)value; +} + +template, int> = 0> +void process(T value) { + (void)value; +} + +void run() { + process(42); +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 8e2eaf37f..722916d43 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -3095,3 +3095,104 @@ describe('C++ Phase 5 U1×U3×U5 — qualified outer::v1::Base::f() inside te expect(freeCalls[0].rel.reason).toBe('import-resolved'); }); }); + +// --------------------------------------------------------------------------- +// SFINAE / concept-constrained candidate filtering (issue #1579) +// Pre-fix: `enable_if_t` / `requires` guarded overloads collapse into a +// false multi-candidate ambiguity → suppressed edge. With +// constraintCompatibility wired up the integral / floating overloads +// disambiguate cleanly. +// --------------------------------------------------------------------------- + +describe('C++ SFINAE filter — golden case (enable_if_t guarded free function templates)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-sfinae-golden'), () => {}); + }, 60000); + + it('enable_if_t> overload binds only on integral call sites', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'run' && c.target === 'process', + ); + expect(calls.length).toBe(2); + // Distinct targets — the integral and floating overloads disambiguate + // via constraintCompatibility, not collapsing to one arbitrary pick. + const targetIds = new Set(calls.map((c) => c.rel.targetId)); + expect(targetIds.size).toBe(2); + }); + + it('enable_if_t> overload binds only on floating call sites', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'run' && c.target === 'process', + ); + // Disambiguate-by-startLine — integral overload (earlier line) vs + // floating overload (later line). Both must be reachable as targets. + const targetStartLines = calls + .map((c) => result.graph.getNode(c.rel.targetId)) + .filter((n): n is NonNullable => n !== undefined) + .map((n) => (n.properties as { startLine?: number }).startLine) + .filter((x): x is number => typeof x === 'number') + .sort((a, b) => a - b); + expect(targetStartLines.length).toBe(2); + expect(targetStartLines[0]).toBeLessThan(targetStartLines[1]); + }); +}); + +describe('C++ SFINAE filter — C++20 requires-clause shape', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-sfinae-requires-clause'), () => {}); + }, 60000); + + it('requires-clause overloads disambiguate same as enable_if_t (F4 AST shape)', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'run' && c.target === 'process', + ); + expect(calls.length).toBe(2); + const targetIds = new Set(calls.map((c) => c.rel.targetId)); + expect(targetIds.size).toBe(2); + }); +}); + +describe('C++ SFINAE filter — unknown predicate keeps both candidates (monotonicity contract)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-sfinae-unknown-predicate'), + () => {}, + ); + }, 60000); + + it('emits zero CALLS edges when predicate is outside the Tier-A registry', () => { + // `MyCustomTrait_v` is not registered; both overloads' constraint + // check returns 'unknown' → both kept → OVERLOAD_AMBIGUOUS suppression + // by `isOverloadAmbiguousAfterNormalization` (both have parameterTypes=['T']). + // Asserts the monotonicity guarantee: adding a predicate must never + // produce a wrong edge. + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'run' && c.target === 'process', + ); + expect(calls.length).toBe(0); + }); +}); + +describe('C++ SFINAE filter — arity gate runs before constraint filter', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-sfinae-arity-survives-unknown'), + () => {}, + ); + }, 60000); + + it('emits exactly 1 CALLS edge to the arity-matching overload (bad-arity dropped before constraint check)', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'run' && c.target === 'process', + ); + expect(calls.length).toBe(1); + }); +}); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index bc18b0374..b4eec6f33 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -175,10 +175,11 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly::g_unqualified() -> f() does NOT bind to Base::f', 'Derived::g_this() -> this->f() resolves to Base::f (1 edge)', 'Derived::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. + // Conversion-rank scoring (#1578 / #1606) 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', @@ -188,6 +189,17 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly` overloads + // guarded by mutually-exclusive `enable_if_t` predicates collapse + // into false multi-candidate ambiguity → 0 CALLS edges. The + // registry-primary path filters via `constraintCompatibility` and + // emits exactly 2 edges (one per ISO-resolved overload). Scope- + // resolver-only correctness win; backporting requires a constexpr + // evaluation engine in the legacy DAG. + 'enable_if_t> overload binds only on integral call sites', + 'enable_if_t> overload binds only on floating call sites', + 'requires-clause overloads disambiguate same as enable_if_t (F4 AST shape)', // 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 diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-constraint.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-constraint.test.ts new file mode 100644 index 000000000..2edc7b74b --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-constraint.test.ts @@ -0,0 +1,263 @@ +/** + * Unit tests for the C++ SFINAE / `requires`-clause constraint pipeline + * (issue #1579). Three sections per the plan: + * 1. Extractor — F1, F2, F4 shapes plus an unknown-bail row. + * 2. Kleene 3-valued evaluator — AND / OR / NOT truth-table rows. + * 3. Predicate registry — `is_integral_v`, `is_floating_point_v`, + * `is_arithmetic_v`, `is_same_v` × representative type tokens; + * surface-size assertion guards the registry shape. + */ + +import { describe, it, expect } from 'vitest'; +import { emitCppScopeCaptures } from '../../../../src/core/ingestion/languages/cpp/captures.js'; +import type { + ConstraintExpr, + CppConstraintPayload, +} from '../../../../src/core/ingestion/languages/cpp/constraint-extractor.js'; +import { + cppConstraintCompatibility, + evaluateForTest, + getRegistrySize, +} from '../../../../src/core/ingestion/languages/cpp/constraint-filter.js'; +import type { ArityVerdict, SymbolDefinition } from 'gitnexus-shared'; + +function templateConstraintsFor(src: string): CppConstraintPayload | undefined { + const matches = emitCppScopeCaptures(src, 'test.cpp'); + for (const m of matches) { + const cap = m['@declaration.template-constraints']; + if (cap !== undefined) return JSON.parse(cap.text) as CppConstraintPayload; + } + return undefined; +} + +// ─── Section 1: Extractor ───────────────────────────────────────────────── + +describe('extractCppTemplateConstraints — AST shapes', () => { + it('F1 — unqualified enable_if_t = 0 default parameter', () => { + // Genuinely unqualified form — no `std::` prefix on `enable_if_t`, + // which exercises the `template_type`-direct branch in the extractor + // independently of the `qualified_identifier` unwrap covered by F2. + const payload = templateConstraintsFor(` + #include + using std::enable_if_t; + using std::is_integral_v; + template, int> = 0> + void process(T value); + `); + expect(payload).toBeDefined(); + expect(payload!.templateParams).toContain('T'); + expect(payload!.paramArgIndex).toEqual({ T: 0 }); + expect(payload!.expr.kind).toBe('atomic'); + if (payload!.expr.kind === 'atomic') { + expect(payload!.expr.name).toBe('is_integral_v'); + expect(payload!.expr.args).toEqual(['T']); + } + }); + + it('F2 — std::-qualified enable_if_t (canonical ticket form)', () => { + const payload = templateConstraintsFor(` + #include + template, int> = 0> + void process(T value); + `); + expect(payload).toBeDefined(); + if (payload!.expr.kind === 'atomic') { + // Qualified prefix stripped — registry lookup keys on the bare name. + expect(payload!.expr.name).toBe('is_floating_point_v'); + expect(payload!.expr.args).toEqual(['T']); + } else { + throw new Error(`expected atomic, got ${payload!.expr.kind}`); + } + }); + + it('F4 — C++20 leading requires-clause', () => { + const payload = templateConstraintsFor(` + #include + template requires std::is_integral_v + void process(T value); + `); + expect(payload).toBeDefined(); + if (payload!.expr.kind === 'atomic') { + expect(payload!.expr.name).toBe('is_integral_v'); + expect(payload!.expr.args).toEqual(['T']); + } else { + throw new Error(`expected atomic, got ${payload!.expr.kind}`); + } + }); + + it('unknown-bail row — non-template constraint payload returns unknown', () => { + // Use a predicate name the registry doesn't recognize, plus an + // unsupported boolean composition shape (decltype). Even if the + // extractor produces an `unknown` node here, monotonicity guarantees + // the candidate is kept at evaluation time. + const payload = templateConstraintsFor(` + #include + template())::value, int> = 0> + void process(T value); + `); + // Extractor MAY succeed with kind: 'unknown' or return undefined — + // either is acceptable; the monotonicity invariant is what matters. + if (payload !== undefined) { + // Walk the expression tree: every leaf must be either an atomic + // outside the registry or an 'unknown' node — never a wrongly-typed + // boolean compose hiding an unrecognized shape. + const reachableKinds = collectKinds(payload.expr); + expect(reachableKinds.has('unknown')).toBe(true); + } + }); +}); + +function collectKinds(expr: ConstraintExpr): Set { + const out = new Set([expr.kind]); + if (expr.kind === 'and' || expr.kind === 'or') { + for (const c of expr.children) for (const k of collectKinds(c)) out.add(k); + } else if (expr.kind === 'not') { + for (const k of collectKinds(expr.child)) out.add(k); + } + return out; +} + +// ─── Section 2: Kleene 3-valued evaluator ────────────────────────────────── + +describe('evaluate — Kleene 3-valued truth table', () => { + const payload: CppConstraintPayload = { + templateParams: ['T'], + paramArgIndex: { T: 0 }, + expr: { kind: 'unknown' }, // unused; we pass expr to evaluate directly + }; + const ctx = { argumentTypes: ['int'] as const }; + + const atomic = (verdict: ArityVerdict): ConstraintExpr => { + // Inject a verdict via a synthetic registry-miss-or-hit: use is_integral_v + // on T at argIdx 0 ('int') for compatible, is_floating_point_v for + // incompatible, and an unknown predicate for unknown. + if (verdict === 'compatible') return { kind: 'atomic', name: 'is_integral_v', args: ['T'] }; + if (verdict === 'incompatible') + return { kind: 'atomic', name: 'is_floating_point_v', args: ['T'] }; + return { kind: 'atomic', name: '__not_in_registry__', args: ['T'] }; + }; + + it('AND: incompatible if any child incompatible', () => { + const expr: ConstraintExpr = { + kind: 'and', + children: [atomic('compatible'), atomic('incompatible')], + }; + expect(evaluateForTest(expr, payload, ctx)).toBe('incompatible'); + }); + + it('AND: compatible iff all children compatible', () => { + const expr: ConstraintExpr = { + kind: 'and', + children: [atomic('compatible'), atomic('compatible')], + }; + expect(evaluateForTest(expr, payload, ctx)).toBe('compatible'); + }); + + it('AND: unknown when no incompatible but at least one unknown', () => { + const expr: ConstraintExpr = { + kind: 'and', + children: [atomic('compatible'), atomic('unknown')], + }; + expect(evaluateForTest(expr, payload, ctx)).toBe('unknown'); + }); + + it('OR: compatible if any child compatible', () => { + const expr: ConstraintExpr = { + kind: 'or', + children: [atomic('incompatible'), atomic('compatible')], + }; + expect(evaluateForTest(expr, payload, ctx)).toBe('compatible'); + }); + + it('OR: incompatible iff all children incompatible', () => { + const expr: ConstraintExpr = { + kind: 'or', + children: [atomic('incompatible'), atomic('incompatible')], + }; + expect(evaluateForTest(expr, payload, ctx)).toBe('incompatible'); + }); + + it('OR: unknown when no compatible but at least one unknown', () => { + const expr: ConstraintExpr = { + kind: 'or', + children: [atomic('incompatible'), atomic('unknown')], + }; + expect(evaluateForTest(expr, payload, ctx)).toBe('unknown'); + }); + + it('NOT: flips compatible ↔ incompatible, passes through unknown', () => { + expect(evaluateForTest({ kind: 'not', child: atomic('compatible') }, payload, ctx)).toBe( + 'incompatible', + ); + expect(evaluateForTest({ kind: 'not', child: atomic('incompatible') }, payload, ctx)).toBe( + 'compatible', + ); + expect(evaluateForTest({ kind: 'not', child: atomic('unknown') }, payload, ctx)).toBe( + 'unknown', + ); + }); +}); + +// ─── Section 3: Predicate registry ───────────────────────────────────────── + +describe('Tier-A predicate registry', () => { + it('registry size is exactly 4 (surface-guard against accidental adds)', () => { + expect(getRegistrySize()).toBe(4); + }); + + function verdict(name: string, args: string[], argumentTypes: readonly string[]): ArityVerdict { + const payload: CppConstraintPayload = { + templateParams: args, + paramArgIndex: Object.fromEntries(args.map((a, i) => [a, i])), + expr: { kind: 'atomic', name, args }, + }; + const def: SymbolDefinition = { + nodeId: 'x', + filePath: 'x.cpp', + type: 'Function', + templateConstraints: payload, + }; + return cppConstraintCompatibility({ arity: argumentTypes.length }, def, { argumentTypes }); + } + + it('is_integral_v matches int, rejects double, unknown for blank', () => { + expect(verdict('is_integral_v', ['T'], ['int'])).toBe('compatible'); + expect(verdict('is_integral_v', ['T'], ['double'])).toBe('incompatible'); + expect(verdict('is_integral_v', ['T'], [''])).toBe('unknown'); + }); + + it('is_integral_v accepts bool and char per ISO ``', () => { + // ISO §21.3.4 Table 48: bool and char are integral types. + expect(verdict('is_integral_v', ['T'], ['bool'])).toBe('compatible'); + expect(verdict('is_integral_v', ['T'], ['char'])).toBe('compatible'); + }); + + it('is_floating_point_v matches double, rejects int, unknown for blank', () => { + expect(verdict('is_floating_point_v', ['T'], ['double'])).toBe('compatible'); + expect(verdict('is_floating_point_v', ['T'], ['int'])).toBe('incompatible'); + expect(verdict('is_floating_point_v', ['T'], [''])).toBe('unknown'); + }); + + it('is_arithmetic_v matches both int and double (integral ∨ floating)', () => { + expect(verdict('is_arithmetic_v', ['T'], ['int'])).toBe('compatible'); + expect(verdict('is_arithmetic_v', ['T'], ['double'])).toBe('compatible'); + expect(verdict('is_arithmetic_v', ['T'], ['bool'])).toBe('compatible'); + expect(verdict('is_arithmetic_v', ['T'], ['char'])).toBe('compatible'); + expect(verdict('is_arithmetic_v', ['T'], ['MyClass'])).toBe('incompatible'); + }); + + it('is_same_v matches same tokens, rejects different, unknown on blanks', () => { + expect(verdict('is_same_v', ['A', 'B'], ['int', 'int'])).toBe('compatible'); + expect(verdict('is_same_v', ['A', 'B'], ['int', 'double'])).toBe('incompatible'); + expect(verdict('is_same_v', ['A', 'B'], ['int', ''])).toBe('unknown'); + // Regression guard: even though `is_integral_v` now treats `bool` and + // `char` as integral, `is_same_v` must keep them distinct from `int` + // (precise `TypeClass` enum — widening lives only in the registry). + expect(verdict('is_same_v', ['A', 'B'], ['bool', 'int'])).toBe('incompatible'); + expect(verdict('is_same_v', ['A', 'B'], ['char', 'int'])).toBe('incompatible'); + }); + + it('unregistered predicate yields unknown (monotonicity)', () => { + expect(verdict('__not_in_registry__', ['T'], ['int'])).toBe('unknown'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts b/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts index 9a14fdddd..e4231355c 100644 --- a/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts +++ b/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts @@ -142,3 +142,60 @@ describe('narrowOverloadCandidates — type narrowing', () => { expect(result.map((d) => d.nodeId)).toEqual(['m:int']); }); }); + +describe('narrowOverloadCandidates — constraint filter monotonicity (issue #1579)', () => { + // Language-agnostic contract: when `constraintCompatibility` returns + // 'unknown' for every candidate, the filter must keep every candidate. + // Adding a predicate to the registry can only narrow correctly, never + // produce a wrong edge — this guarantees the worst-case behavior is + // today's "degrade not lie" suppression, not a regression. + const a = mkDef({ + nodeId: 'a', + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['T'], + templateConstraints: { dummy: true }, + }); + const b = mkDef({ + nodeId: 'b', + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['T'], + templateConstraints: { dummy: true }, + }); + + it('keeps every candidate when constraintCompatibility returns unknown for all', () => { + const result = narrowOverloadCandidates([a, b], 1, ['int'], { + constraintCompatibility: () => 'unknown', + }); + expect(result.map((d) => d.nodeId).sort()).toEqual(['a', 'b']); + }); + + it('drops only candidates the hook explicitly marks incompatible', () => { + const result = narrowOverloadCandidates([a, b], 1, ['int'], { + constraintCompatibility: (_callsite, def) => + def.nodeId === 'a' ? 'incompatible' : 'compatible', + }); + expect(result.map((d) => d.nodeId)).toEqual(['b']); + }); + + it('skips the constraint filter when hookCtx is omitted (pre-#1579 behavior preserved)', () => { + const result = narrowOverloadCandidates([a, b], 1, ['int']); + expect(result.map((d) => d.nodeId).sort()).toEqual(['a', 'b']); + }); + + it('skips the constraint filter for candidates without templateConstraints', () => { + const plain = mkDef({ + nodeId: 'plain', + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['T'], + }); + // Even though the hook would return 'incompatible' for everything, the + // candidate has no templateConstraints so the filter doesn't consult it. + const result = narrowOverloadCandidates([plain], 1, ['int'], { + constraintCompatibility: () => 'incompatible', + }); + expect(result.map((d) => d.nodeId)).toEqual(['plain']); + }); +});