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']);
+ });
+});