feat(cpp): sfinae filter (#1623)

* feat(cpp): SFINAE-aware overload filter — drops candidates whose enable_if_t / requires constraints fail (#1579)

* fix(cpp):  SFINAE follow-ups for is_integral_v/is_arithmetic_v bool and char support, an unqualified F1 test fixture, and parameter-lookup gap documentation (#1579) -> claude feedback

* revert: reverting all changes to .md files
This commit is contained in:
Zander Raycraft 2026-05-16 14:23:13 -05:00 committed by GitHub
parent 42d4fcaf6f
commit a4dfebd073
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1578 additions and 85 deletions

View file

@ -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)

View file

@ -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`) ────
/**

View file

@ -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<P, 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;
}

View file

@ -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"]
}

View file

@ -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<class T, std::enable_if_t<is_integral_v<T>, int> = 0>
* void process(T); // overload A
*
* template<class T, std::enable_if_t<is_floating_point_v<T>, 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

View file

@ -64,6 +64,7 @@ import {
cppImportOwningScope,
cppReceiverBinding,
} from './cpp/index.js';
import { extractCppTemplateConstraints } from './cpp/constraint-extractor.js';
const C_BUILT_INS: ReadonlySet<string> = 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);
}

View file

@ -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();

View file

@ -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<T>` `List<U>`) remains out of V1 scope.
*
* Verdict:
* - 'compatible': callsite.arity fits within [required, total] range

View file

@ -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;

View file

@ -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<class T, enable_if_t<P, int> = 0> void f(T);`
* F2 `std::`-qualified variant (canonical ticket form):
* `template<class T, std::enable_if_t<P, int> = 0> void f(T);`
* F4 C++20 leading requires-clause:
* `template<class T> requires P void f(T);`
*
* Deferred (return `{kind:'unknown'}`):
* F3 void-default `typename = enable_if_t<P>` (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]` /
* `<https://en.cppreference.com/w/cpp/language/constraints>` 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<P, T>` (F1) or `std::enable_if_t<P, 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<T>`). */
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 `<predicate>_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<T>`. 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<T>` 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;
}

View file

@ -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
* `<https://en.cppreference.com/w/cpp/language/constraints>`):
* 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 compatibleincompatible; 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
* `<type_traits>` 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 `<type_traits>` treats `bool`, `char`, and the signed/unsigned char
// variants as integral types (§21.3.4 Table 48), so `is_integral_v<bool>`
// and `is_integral_v<char>` must both yield `true`. We keep the `TypeClass`
// enum precise (separate `'bool'` / `'char'` buckets) so that
// `is_same_v<bool, int>` 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<string, AtomicEvaluator>([
['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<const T, T>` 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<T, ConcreteType>`
// 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);
}

View file

@ -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<P, 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),

View file

@ -0,0 +1,59 @@
/**
* Coarse-grained type classifier for C++ constraint evaluation
* (`<https://en.cppreference.com/w/cpp/types/is_integral>`,
* `<https://en.cppreference.com/w/cpp/types/is_floating_point>`).
*
* Maps a normalized type token (as produced by `normalizeCppParamType` /
* the call-site inference in `captures.ts`) to one of the categories
* the `<type_traits>` 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 `<type_traits>` 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';
}
}

View file

@ -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<is_integral_v<T>>`
// vs `enable_if_t<is_floating_point_v<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,

View file

@ -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<string> = new Set<string>([
'@declaration.parameter-count',
'@declaration.required-parameter-count',
'@declaration.parameter-types',
'@declaration.template-constraints',
]);
/**

View file

@ -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 ───────────────────────────────────────────────
/**

View file

@ -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.

View file

@ -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<string, string>;
@ -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<T>` 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' ||

View file

@ -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<string>();
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];
}

View file

@ -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;
}
/**

View file

@ -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.

View file

@ -383,6 +383,7 @@ export function runScopeResolution(
isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller,
resolveAdlCandidates: provider.resolveAdlCandidates,
conversionRankFn: provider.conversionRankFn,
constraintCompatibility: provider.constraintCompatibility,
},
);
const { emitted, skipped } = emitReferencesViaLookup(

View file

@ -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<is_integral_v<T>>` vs `enable_if_t<is_floating_point_v<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:<hash>` 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))}`;
}

View file

@ -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 <type_traits>
template<class T>
constexpr bool MyCustomTrait_v = true;
template<class T, std::enable_if_t<MyCustomTrait_v<T>, int> = 0>
void process(T value) {
(void)value;
}
template<class T, std::enable_if_t<MyCustomTrait_v<T>, int> = 0>
void process(T value, T other) {
(void)value;
(void)other;
}
void run() {
process(42);
}

View file

@ -0,0 +1,21 @@
// SFINAE golden case (issue #1579).
// Two `process<T>` 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 <type_traits>
template<class T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
void process(T value) {
(void)value;
}
template<class T, std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
void process(T value) {
(void)value;
}
void run() {
process(42);
process(3.14);
}

View file

@ -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 <type_traits>
template<class T> requires std::is_integral_v<T>
void process(T value) {
(void)value;
}
template<class T> requires std::is_floating_point_v<T>
void process(T value) {
(void)value;
}
void run() {
process(42);
process(3.14);
}

View file

@ -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 <type_traits>
template<class T>
constexpr bool MyCustomTrait_v = true;
template<class T, std::enable_if_t<MyCustomTrait_v<T>, int> = 0>
void process(T value) {
(void)value;
}
template<class T, std::enable_if_t<!MyCustomTrait_v<T>, int> = 0>
void process(T value) {
(void)value;
}
void run() {
process(42);
}

View file

@ -3095,3 +3095,104 @@ describe('C++ Phase 5 U1×U3×U5 — qualified outer::v1::Base<T>::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<is_integral_v<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<is_floating_point_v<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<typeof n> => 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);
});
});

View file

@ -175,10 +175,11 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
'Derived<T>::g_unqualified() -> f() does NOT bind to Base<T>::f',
'Derived<T>::g_this() -> this->f() resolves to Base<T>::f (1 edge)',
'Derived<T>::g() -> this->f() emits zero CALLS edges when only hidden derived overload is arity-incompatible',
// Conversion-rank scoring (#1578) disambiguates `f(int)` vs `f(double)`
// by ranking exact match over standard conversion. The legacy DAG has no
// conversion-rank scoring; it either picks arbitrarily or leaves the call
// unresolved. Scope-resolver-only correctness win.
// 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<Record<string, Readonly
// Multi-arg incomparable overloads: pairwise dominance check finds
// neither h(int,int) nor h(double,double) dominates. Scope-resolver-only.
'h(42, 2.5) emits zero CALLS edges — incomparable multi-arg overloads, ambiguous',
// The legacy DAG path lacks the SFINAE / `requires`-clause aware
// overload filter (issue #1579). The two `process<T>` 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<is_integral_v<T>> overload binds only on integral call sites',
'enable_if_t<is_floating_point_v<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

View file

@ -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<P, int> = 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 <type_traits>
using std::enable_if_t;
using std::is_integral_v;
template<class T, enable_if_t<is_integral_v<T>, 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 <type_traits>
template<class T, std::enable_if_t<std::is_floating_point_v<T>, 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 <type_traits>
template<class T> requires std::is_integral_v<T>
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 <type_traits>
template<class T, std::enable_if_t<decltype(some_check<T>())::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<ConstraintExpr['kind']> {
const out = new Set<ConstraintExpr['kind']>([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 `<type_traits>`', () => {
// 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');
});
});

View file

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