mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
42d4fcaf6f
commit
a4dfebd073
31 changed files with 1578 additions and 85 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`) ────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
147
gitnexus/src/core/ingestion/languages/cpp/constraint-filter.ts
Normal file
147
gitnexus/src/core/ingestion/languages/cpp/constraint-filter.ts
Normal 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 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
|
||||
* `<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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
59
gitnexus/src/core/ingestion/languages/cpp/type-classifier.ts
Normal file
59
gitnexus/src/core/ingestion/languages/cpp/type-classifier.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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' ||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -383,6 +383,7 @@ export function runScopeResolution(
|
|||
isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller,
|
||||
resolveAdlCandidates: provider.resolveAdlCandidates,
|
||||
conversionRankFn: provider.conversionRankFn,
|
||||
constraintCompatibility: provider.constraintCompatibility,
|
||||
},
|
||||
);
|
||||
const { emitted, skipped } = emitReferencesViaLookup(
|
||||
|
|
|
|||
|
|
@ -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))}`;
|
||||
}
|
||||
|
|
|
|||
23
gitnexus/test/fixtures/lang-resolution/cpp-sfinae-arity-survives-unknown/main.cpp
vendored
Normal file
23
gitnexus/test/fixtures/lang-resolution/cpp-sfinae-arity-survives-unknown/main.cpp
vendored
Normal 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);
|
||||
}
|
||||
21
gitnexus/test/fixtures/lang-resolution/cpp-sfinae-golden/main.cpp
vendored
Normal file
21
gitnexus/test/fixtures/lang-resolution/cpp-sfinae-golden/main.cpp
vendored
Normal 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);
|
||||
}
|
||||
20
gitnexus/test/fixtures/lang-resolution/cpp-sfinae-requires-clause/main.cpp
vendored
Normal file
20
gitnexus/test/fixtures/lang-resolution/cpp-sfinae-requires-clause/main.cpp
vendored
Normal 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);
|
||||
}
|
||||
27
gitnexus/test/fixtures/lang-resolution/cpp-sfinae-unknown-predicate/main.cpp
vendored
Normal file
27
gitnexus/test/fixtures/lang-resolution/cpp-sfinae-unknown-predicate/main.cpp
vendored
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
263
gitnexus/test/unit/scope-resolution/cpp/cpp-constraint.test.ts
Normal file
263
gitnexus/test/unit/scope-resolution/cpp/cpp-constraint.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue