fix(cpp): complete scope-resolution parity

This commit is contained in:
HuangWenjie 2026-05-12 13:50:31 +08:00
parent fcab1e2e82
commit dc1db90525
34 changed files with 2950 additions and 1038 deletions

View file

@ -4,43 +4,22 @@ import type { GroupConfig, GroupManifestLink, ContractType, ContractRole } from
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
const VALID_CONTRACT_TYPES: ContractType[] = [
'http',
'grpc',
'thrift',
'topic',
'lib',
'custom',
'include',
];
const VALID_CONTRACT_TYPES: ContractType[] = ['http', 'grpc', 'topic', 'lib', 'custom', 'include'];
const VALID_ROLES: ContractRole[] = ['provider', 'consumer'];
// Defaults matter for backward compatibility: any group.yaml that omits a
// `detect.<type>` key inherits its value from this constant. Adding a new
// extractor that defaults to `true` silently changes the behavior of every
// existing group on the next sync. New extractors must default to `false`
// (opt-in) so operators consciously enable them via group.yaml.
//
// `includes`: opt-in. The C/C++ IncludeExtractor (PR #1156) ships disabled by
// default; enable with `detect.includes: true` for groups containing C/C++
// repos that need cross-repo header tracking.
const DEFAULT_DETECT = {
http: true,
grpc: true,
thrift: true,
topics: true,
shared_libs: true,
embedding_fallback: true,
includes: false,
workspace_deps: false,
includes: true,
};
const DEFAULT_MATCHING = {
bm25_threshold: 0.7,
embedding_threshold: 0.65,
max_candidates_per_step: 3,
exclude_links_paths: [] as string[],
exclude_links_param_only_paths: false,
};
export function parseGroupConfig(yamlContent: string): GroupConfig {

View file

@ -1,5 +1,4 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { glob } from 'glob';
import Parser from 'tree-sitter';
import C from 'tree-sitter-c';
@ -7,11 +6,7 @@ import Cpp from 'tree-sitter-cpp';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
import { buildSuffixIndex, type SuffixIndex } from '../../ingestion/import-resolvers/utils.js';
import { createIgnoreFilter } from '../../../config/ignore-service.js';
import { getMaxFileSizeBytes } from '../../ingestion/utils/max-file-size.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import { logger } from '../../logger.js';
import { buildSuffixIndex, suffixResolve, type SuffixIndex } from '../../ingestion/import-resolvers/utils.js';
/**
* Cross-repo C/C++ `#include` dependency extractor.
@ -33,11 +28,20 @@ import { logger } from '../../logger.js';
const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh']);
// Source = headers (provider-eligible) implementation files (.c/.cpp/.cc/.cxx).
// Spread keeps the subset relationship explicit so a future contributor adding
// a new header extension to HEADER_EXTENSIONS does not have to remember to
// also add it here.
const SOURCE_EXTENSIONS = new Set<string>([...HEADER_EXTENSIONS, '.c', '.cpp', '.cc', '.cxx']);
const HEADER_GLOB = '**/*.{h,hpp,hxx,hh}';
const SOURCE_GLOB = '**/*.{c,cpp,cc,cxx,h,hpp,hxx,hh}';
const STANDARD_IGNORES = [
'**/node_modules/**',
'**/.git/**',
'**/vendor/**',
'**/dist/**',
'**/build/**',
'**/.gitnexus/**',
'**/third_party/**',
'**/3rdparty/**',
'**/external/**',
];
const INCLUDE_QUERY_SRC = '(preproc_include path: (_) @import.source) @import';
@ -47,156 +51,37 @@ const INCLUDE_QUERY_SRC = '(preproc_include path: (_) @import.source) @import';
*/
const SYSTEM_HEADERS = new Set([
// C standard
'assert.h',
'complex.h',
'ctype.h',
'errno.h',
'fenv.h',
'float.h',
'inttypes.h',
'iso646.h',
'limits.h',
'locale.h',
'math.h',
'setjmp.h',
'signal.h',
'stdalign.h',
'stdarg.h',
'stdatomic.h',
'stdbool.h',
'stddef.h',
'stdint.h',
'stdio.h',
'stdlib.h',
'stdnoreturn.h',
'string.h',
'tgmath.h',
'threads.h',
'time.h',
'uchar.h',
'wchar.h',
'assert.h', 'complex.h', 'ctype.h', 'errno.h', 'fenv.h', 'float.h',
'inttypes.h', 'iso646.h', 'limits.h', 'locale.h', 'math.h', 'setjmp.h',
'signal.h', 'stdalign.h', 'stdarg.h', 'stdatomic.h', 'stdbool.h',
'stddef.h', 'stdint.h', 'stdio.h', 'stdlib.h', 'stdnoreturn.h',
'string.h', 'tgmath.h', 'threads.h', 'time.h', 'uchar.h', 'wchar.h',
'wctype.h',
// C++ standard (extensionless)
'algorithm',
'any',
'array',
'atomic',
'barrier',
'bit',
'bitset',
'cassert',
'cctype',
'cerrno',
'cfenv',
'cfloat',
'charconv',
'chrono',
'cinttypes',
'climits',
'clocale',
'cmath',
'codecvt',
'compare',
'complex',
'concepts',
'condition_variable',
'coroutine',
'csetjmp',
'csignal',
'cstdarg',
'cstddef',
'cstdint',
'cstdio',
'cstdlib',
'cstring',
'ctime',
'cuchar',
'cwchar',
'cwctype',
'deque',
'exception',
'execution',
'expected',
'filesystem',
'format',
'forward_list',
'fstream',
'functional',
'future',
'generator',
'initializer_list',
'iomanip',
'ios',
'iosfwd',
'iostream',
'istream',
'iterator',
'latch',
'limits',
'list',
'locale',
'map',
'mdspan',
'memory',
'memory_resource',
'mutex',
'new',
'numbers',
'numeric',
'optional',
'ostream',
'print',
'queue',
'random',
'ranges',
'ratio',
'regex',
'scoped_allocator',
'semaphore',
'set',
'shared_mutex',
'source_location',
'span',
'spanstream',
'sstream',
'stack',
'stacktrace',
'stdexcept',
'stdfloat',
'stop_token',
'streambuf',
'string',
'string_view',
'strstream',
'syncstream',
'system_error',
'thread',
'tuple',
'type_traits',
'typeindex',
'typeinfo',
'unordered_map',
'unordered_set',
'utility',
'valarray',
'variant',
'vector',
'version',
'algorithm', 'any', 'array', 'atomic', 'barrier', 'bit', 'bitset',
'cassert', 'cctype', 'cerrno', 'cfenv', 'cfloat', 'charconv', 'chrono',
'cinttypes', 'climits', 'clocale', 'cmath', 'codecvt', 'compare',
'complex', 'concepts', 'condition_variable', 'coroutine', 'csetjmp',
'csignal', 'cstdarg', 'cstddef', 'cstdint', 'cstdio', 'cstdlib',
'cstring', 'ctime', 'cuchar', 'cwchar', 'cwctype', 'deque', 'exception',
'execution', 'expected', 'filesystem', 'format', 'forward_list',
'fstream', 'functional', 'future', 'generator', 'initializer_list',
'iomanip', 'ios', 'iosfwd', 'iostream', 'istream', 'iterator', 'latch',
'limits', 'list', 'locale', 'map', 'mdspan', 'memory', 'memory_resource',
'mutex', 'new', 'numbers', 'numeric', 'optional', 'ostream', 'print',
'queue', 'random', 'ranges', 'ratio', 'regex', 'scoped_allocator',
'semaphore', 'set', 'shared_mutex', 'source_location', 'span',
'spanstream', 'sstream', 'stack', 'stacktrace', 'stdexcept', 'stdfloat',
'stop_token', 'streambuf', 'string', 'string_view', 'strstream',
'syncstream', 'system_error', 'thread', 'tuple', 'type_traits',
'typeindex', 'typeinfo', 'unordered_map', 'unordered_set', 'utility',
'valarray', 'variant', 'vector', 'version',
]);
/** Path prefixes that indicate system/kernel headers. */
const SYSTEM_PATH_PREFIXES = [
'sys/',
'net/',
'netinet/',
'arpa/',
'linux/',
'asm/',
'bits/',
'gnu/',
'mach/',
'machine/',
'xlocale/',
'sys/', 'net/', 'netinet/', 'arpa/', 'linux/', 'asm/', 'bits/', 'gnu/',
'mach/', 'machine/', 'xlocale/',
];
/** Regex fallback for files that exceed tree-sitter's 32 KB parse limit. */
@ -204,32 +89,12 @@ const INCLUDE_REGEX = /^[ \t]*#\s*include\s*"([^"]+)"/gm;
// ---------- helpers ----------
/**
* Normalize an include path to a canonical lowercase forward-slash form.
*
* IMPORTANT case-folding caveat (PR #1156 review finding #3):
* Header paths are lowercased so consumer `#include "Foo/Bar.h"` and
* provider file `Foo/Bar.h` normalize to the same contract-id. This is
* the right trade-off on case-insensitive filesystems (macOS, Windows)
* but on case-sensitive Linux filesystems two distinct headers `Foo.h`
* and `foo.h` in the same repo will collide onto the same provider
* contract-id; only one survives `dedupe()`. The gain (reliable
* cross-platform matching) outweighs the cost (extremely rare header
* casing collisions inside a single repo).
*/
function normalizeIncludePath(raw: string): string {
return raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+/g, '/').toLowerCase();
}
/**
* Strip C/C++ block comments from a source blob. Used only by the
* regex-fallback path to avoid emitting consumer contracts for
* commented-out #include directives. Line comments (`// …`) cannot hide
* #include directives because the regex anchors on start-of-line.
* See PR #1156 review finding #5.
*/
function stripBlockComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '');
return raw
.replace(/\\/g, '/')
.replace(/^\.\//, '')
.replace(/\/+/g, '/')
.toLowerCase();
}
function isAngleBracketInclude(rawNodeText: string): boolean {
@ -267,41 +132,11 @@ function getLanguageForFile(filePath: string): unknown | null {
}
}
/**
* Check whether an include path resolves to a file inside the local repo.
*
* Uses *exact full-path* matching on the suffix index we never accept a
* truncated suffix match. For `#include "foo/bar.h"` this checks:
* (a) a file whose path ends with the full `foo/bar.h`
* (b) if the include omitted the extension, a file whose path ends with
* the include + one of the C/C++ header extensions
*
* Returns `true` when a local file matches caller should suppress the
* cross-repo consumer contract.
*
* See PR #1156 review finding #4 (suffixResolve ambiguity).
*/
function isLocalInclude(cleaned: string, suffixIndex: SuffixIndex): boolean {
const candidates = [cleaned];
if (!/\.[a-zA-Z0-9]+$/.test(cleaned)) {
for (const ext of ['.h', '.hpp', '.hxx', '.hh']) candidates.push(cleaned + ext);
}
for (const c of candidates) {
if (suffixIndex.get(c) || suffixIndex.getInsensitive(c)) return true;
}
return false;
}
// ---------- main class ----------
export class IncludeExtractor implements ContractExtractor {
type = 'include' as const;
/**
* Always returns `true`. NOT called by `sync.ts`, which gates extraction via
* `config.detect.includes` instead (see `sync.ts:174`). Kept solely to satisfy
* the `ContractExtractor` interface so the type stays uniform across extractors.
*/
async canExtract(_repo: RepoHandle): Promise<boolean> {
return true;
}
@ -311,86 +146,24 @@ export class IncludeExtractor implements ContractExtractor {
repoPath: string,
_repo: RepoHandle,
): Promise<ExtractedContract[]> {
// 1. Build the local file list using the same discovery as ingestion
// (createIgnoreFilter + getMaxFileSizeBytes). This guarantees the
// universe of provider/consumer paths matches the universe of File
// nodes in the LadybugDB graph — so no cross-link points at a UID
// that group impact cannot fan out to.
// (PR #1156 Codex follow-up: discovery aligned with ingestion.)
const allFiles = await this.discoverIndexableFiles(repoPath);
// 1. Build the local file list (for suffix resolution)
const allFiles = await glob('**/*', {
cwd: repoPath,
ignore: STANDARD_IGNORES,
nodir: true,
});
const normalizedFiles = allFiles.map((f) => f.replace(/\\/g, '/'));
const suffixIndex = buildSuffixIndex(normalizedFiles, allFiles);
// 2. Provider: register all header files
const providers = await this.extractProviders(dbExecutor, repoPath, allFiles);
// 3. Consumer: filter the shared discovery list for source extensions
// and parse #include directives in those files.
const sourceFiles = allFiles.filter((f) =>
SOURCE_EXTENSIONS.has(path.extname(f).toLowerCase()),
);
const consumers = await this.extractConsumers(repoPath, sourceFiles, suffixIndex);
// 3. Consumer: find unresolved #include directives
const consumers = await this.extractConsumers(repoPath, normalizedFiles, allFiles, suffixIndex);
return this.dedupe([...providers, ...consumers]);
}
/**
* Discover repo-relative file paths using exactly the same rules the
* ingestion pipeline uses (`walkRepositoryPaths` in
* `gitnexus/src/core/ingestion/filesystem-walker.ts`):
* - `createIgnoreFilter` honors `.gitignore`, `.gitnexusignore`, the
* hardcoded ignore list, and `.gitnexusignore` last-match-wins
* negation.
* - `getMaxFileSizeBytes()` drops files larger than the cap so we
* never emit `File:<rel>` UIDs for files ingestion would skip.
*
* Uses sequential stat there is no `READ_CONCURRENCY` batching here
* because group sync runs at startup-time, not the ingestion hot path,
* and parallelism gains are not worth the import-graph weight.
*
* MAINTENANCE: if `walkRepositoryPaths` changes its glob options, ignore
* filter shape, or size-cap logic, mirror those changes here. The two
* implementations exist because the consumers need different return
* shapes (string[] vs ScannedFile[]) and different concurrency, but
* they MUST agree on which files are reachable that is what makes
* `File:<rel>` UIDs in cross-links correspond to graph File nodes.
*/
private async discoverIndexableFiles(repoPath: string): Promise<string[]> {
const ignoreFilter = await createIgnoreFilter(repoPath);
const maxFileSizeBytes = getMaxFileSizeBytes();
const candidates = await glob('**/*', {
cwd: repoPath,
nodir: true,
dot: false,
ignore: ignoreFilter,
});
const survivors: string[] = [];
for (const rel of candidates) {
try {
const stat = await fs.stat(path.join(repoPath, rel));
if (stat.size > maxFileSizeBytes) continue;
survivors.push(rel);
} catch (err) {
// ENOENT is the documented benign race (glob enumerated a file
// that was deleted before we stat'd it — same race
// walkRepositoryPaths absorbs via Promise.allSettled). Anything
// else (EACCES, EMFILE, EIO) deserves a warning so an operator
// can spot a permission/resource problem instead of silently
// shipping fewer contracts than expected.
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code !== 'ENOENT') {
logger.warn(
{ err: (err as Error).message, file: rel, repoPath },
'⚠️ IncludeExtractor: stat failed during discovery; skipping file',
);
}
}
}
return survivors;
}
// ---------- provider extraction ----------
private async extractProviders(
@ -400,59 +173,44 @@ export class IncludeExtractor implements ContractExtractor {
): Promise<ExtractedContract[]> {
// Strategy A: graph-assisted
if (dbExecutor) {
const graphProviders = await this.extractProvidersGraph(dbExecutor, repoPath);
const graphProviders = await this.extractProvidersGraph(dbExecutor);
if (graphProviders.length > 0) return graphProviders;
}
// Strategy B: filesystem fallback
return this.extractProvidersFallback(repoPath, allFiles);
}
private async extractProvidersGraph(
db: CypherExecutor,
repoPath: string,
): Promise<ExtractedContract[]> {
private async extractProvidersGraph(db: CypherExecutor): Promise<ExtractedContract[]> {
try {
const rows = await db(
`MATCH (f:File)
WHERE f.filePath =~ '.*\\\\.(h|hpp|hxx|hh)$'
RETURN f.filePath AS filePath, f.id AS fileId`,
);
// gitnexus analyze stores absolute paths in the File.filePath column.
// Provider contract IDs MUST be repo-relative — otherwise the consumer
// emits `include::map/base/view.h` and the provider emits
// `include::/abs/path/to/repo/map/base/view.h`, which never match
// through runExactMatch and the cross-link silently disappears.
// (PR #1156 follow-up review: graph provider absolute-path bug.)
const normalizedRepoPath = path.resolve(repoPath);
const out: ExtractedContract[] = [];
for (const r of rows) {
if (typeof r.filePath !== 'string' || !r.filePath) continue;
const absolute = r.filePath as string;
const rel = path.relative(normalizedRepoPath, absolute);
// Skip rows that resolve outside the repo (e.g., system headers
// somehow indexed, or stale absolute paths from a different machine).
// path.relative returns a `..`-prefixed path or an absolute path
// when the target is outside the base — both are wrong for our IDs.
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
const normalizedRel = rel.replace(/\\/g, '/');
out.push({
contractId: `include::${normalizeIncludePath(normalizedRel)}`,
type: 'include' as const,
role: 'provider' as const,
symbolUid: String(r.fileId ?? ''),
symbolRef: { filePath: normalizedRel, name: path.basename(normalizedRel) },
symbolName: path.basename(normalizedRel),
confidence: 1.0,
meta: { source: 'graph' },
return rows
.filter((r) => typeof r.filePath === 'string' && r.filePath)
.map((r) => {
const filePath = (r.filePath as string).replace(/\\/g, '/');
return {
contractId: `include::${normalizeIncludePath(filePath)}`,
type: 'include' as const,
role: 'provider' as const,
symbolUid: String(r.fileId ?? ''),
symbolRef: { filePath, name: path.basename(filePath) },
symbolName: path.basename(filePath),
confidence: 1.0,
meta: { source: 'graph' },
};
});
}
return out;
} catch {
return [];
}
}
private extractProvidersFallback(_repoPath: string, allFiles: string[]): ExtractedContract[] {
private extractProvidersFallback(
_repoPath: string,
allFiles: string[],
): ExtractedContract[] {
return allFiles
.filter((f) => isHeaderFile(f))
.map((f) => {
@ -474,9 +232,16 @@ export class IncludeExtractor implements ContractExtractor {
private async extractConsumers(
repoPath: string,
sourceFiles: string[],
normalizedFiles: string[],
allFiles: string[],
suffixIndex: SuffixIndex,
): Promise<ExtractedContract[]> {
const sourceFiles = await glob(SOURCE_GLOB, {
cwd: repoPath,
ignore: STANDARD_IGNORES,
nodir: true,
});
const parser = new Parser();
const out: ExtractedContract[] = [];
// Compile the include query once per grammar to avoid re-compilation per file
@ -499,14 +264,11 @@ export class IncludeExtractor implements ContractExtractor {
}
}
// Collect raw include paths: tree-sitter first, regex fallback for large files.
// `extractionSource` is stamped on each emitted consumer contract so
// regex-fallback contracts stay auditable post-hoc (PR #1156 review finding #6).
// Collect raw include paths: tree-sitter first, regex fallback for large files
let rawIncludes: string[];
let extractionSource: 'tree_sitter' | 'regex_fallback';
try {
parser.setLanguage(lang);
const tree = parseSourceSafe(parser, content);
const tree = parser.parse(content);
let matches: Parser.QueryMatch[];
try {
matches = query.matches(tree.rootNode);
@ -514,7 +276,6 @@ export class IncludeExtractor implements ContractExtractor {
matches = [];
}
rawIncludes = [];
extractionSource = 'tree_sitter';
for (const match of matches) {
const sourceNode = match.captures.find((c) => c.name === 'import.source');
if (!sourceNode) continue;
@ -524,15 +285,11 @@ export class IncludeExtractor implements ContractExtractor {
if (cleaned && cleaned.length <= 2048) rawIncludes.push(cleaned);
}
} catch {
// tree-sitter failed (e.g. file > 32 KB) — fall back to regex.
// Strip block comments first so we don't emit a consumer contract
// for a commented-out #include (PR #1156 review finding #5).
// tree-sitter failed (e.g. file > 32 KB) — fall back to regex
rawIncludes = [];
extractionSource = 'regex_fallback';
const scanTarget = stripBlockComments(content);
INCLUDE_REGEX.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = INCLUDE_REGEX.exec(scanTarget)) !== null) {
while ((m = INCLUDE_REGEX.exec(content)) !== null) {
if (m[1] && m[1].length <= 2048) rawIncludes.push(m[1]);
}
}
@ -541,38 +298,10 @@ export class IncludeExtractor implements ContractExtractor {
// Filter: skip known system headers and system path prefixes
if (isSystemHeader(cleaned)) continue;
// Skip relative-up includes: `#include "../include/foo.h"` is
// almost always an intra-repo reference. The suffix index is built
// from repo-relative paths, so isLocalInclude can never match
// `../foo.h`, and emitting it as a consumer contract just pollutes
// the registry with an entry no provider can ever satisfy.
// (PR #1156 follow-up review: `../` relative includes produce
// spurious consumer contracts.)
if (cleaned.startsWith('../') || cleaned.startsWith('..\\')) continue;
// Skip macro-style includes: `#include PLATFORM_HEADER` parses as an
// identifier under tree-sitter's `(_) @import.source` wildcard. The
// identifier text passes the strip/clean step unchanged, so without
// this guard we would emit `include::platform_header` as a consumer
// contract — and no provider in any repo will ever expose a contract
// for a macro identifier (no file is named `PLATFORM_HEADER`). The
// contract would sit permanently orphaned in the registry. Real
// header references always contain a path separator (`/`, `\`) or an
// extension dot (`foo.h`), so an absent both is a reliable signal we
// are looking at a macro identifier. (PR #1156 follow-up review:
// macro includes emit orphaned consumer contracts.)
if (!/[./\\]/.test(cleaned)) continue;
// Local resolution (PR #1156 review finding #4): only accept an
// exact-suffix match on the *full* include path. The generic
// suffixResolve() iterates all truncated suffixes, which would
// silently suppress a cross-repo `#include "map/base/view.h"`
// when the local repo has any `internal/view.h` — a realistic
// false-negative in large C++ codebases. Here we only resolve
// locally if a file path ends with the complete include string
// (optionally re-appending one of the C/C++ header extensions
// when the include already omits it).
if (isLocalInclude(cleaned, suffixIndex)) continue;
// Local resolution: try to resolve against this repo's own files
const pathParts = cleaned.split('/').filter(Boolean);
const resolved = suffixResolve(pathParts, normalizedFiles, allFiles, suffixIndex);
if (resolved !== null) continue; // Local include — not cross-repo
// Unresolved: emit as consumer contract
const normalizedRel = rel.replace(/\\/g, '/');
@ -585,7 +314,7 @@ export class IncludeExtractor implements ContractExtractor {
symbolName: cleaned,
confidence: 0.85,
meta: {
source: extractionSource,
source: 'tree_sitter',
includePath: cleaned,
},
});

View file

@ -1,7 +1,6 @@
import type { ContractType, CrossLink, GroupManifestLink, StoredContract } from '../types.js';
import type { CypherExecutor } from '../contract-extractor.js';
import { logger } from '../../logger.js';
export interface ManifestExtractResult {
contracts: StoredContract[];
crossLinks: CrossLink[];
@ -178,7 +177,7 @@ export class ManifestExtractor {
// NOTE: All lookups use EXACT equality on the relevant name field and
// deterministic ORDER BY before LIMIT 1. Previous versions used CONTAINS
// for fuzzy matching (plus an unconditional IDL file fallback for gRPC)
// for fuzzy matching (plus an unconditional ".proto" fallback for gRPC)
// which produced silent false positives: e.g. manifest "/orders" would
// match "/suborders", and a gRPC manifest entry in a repo with any
// .proto file would attach to a random proto symbol.
@ -226,21 +225,16 @@ export class ManifestExtractor {
LIMIT 1`,
{ contract: link.contract },
);
} else if (link.type === 'grpc' || link.type === 'thrift') {
} else if (link.type === 'grpc') {
// Contract is "Service/Method" or just "Service" (or package.Service
// variants). Prefer matching by method name when present, otherwise
// by service name. Thrift generated Java classes often use
// package.Service in manifests while graph Class/Interface names are
// stored as bare Service, so strip the package prefix for thrift
// service-name lookups. NO IDL path fallback — that's guaranteed to
// return a wrong symbol in any repo with more than one IDL file.
// by service name. NO .proto path fallback — that's guaranteed to
// return a wrong symbol in any repo with more than one proto file.
// Label filters scope lookups: methods → Function|Method, services
// → Class|Interface (no label match = no silent wrong hits on
// File/Variable nodes that happen to share the name).
const parts = link.contract.split('/');
const rawServiceName = parts[0]?.trim() ?? '';
const serviceName =
link.type === 'thrift' ? (rawServiceName.split('.').pop() ?? '') : rawServiceName;
const serviceName = parts[0]?.trim() ?? '';
const methodName = parts[1]?.trim() ?? '';
if (methodName) {
rows = await executor(
@ -282,21 +276,6 @@ export class ManifestExtractor {
LIMIT 1`,
{ contract: link.contract },
);
} else if (link.type === 'custom') {
// Workspace extractors produce qualified contracts like "mathlex::Expression".
// Graph nodes store the unqualified symbol name ("Expression"), so strip
// the "provider::" prefix before querying.
const symbolName = link.contract.includes('::')
? link.contract.split('::').pop()!
: link.contract;
rows = await executor(
`MATCH (n:Function|Method|Class|Interface|Struct|Enum|Trait|Constructor|TypeAlias|Impl|Macro|Union|Typedef|Property|Record|Delegate|Annotation|Template|Const|Static|CodeElement)
WHERE n.name = $symbolName
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
{ symbolName },
);
} else {
return null;
}
@ -312,7 +291,7 @@ export class ManifestExtractor {
// fail the whole manifest extraction. Unresolved contracts still
// get a synthetic symbolUid below, so cross-impact can proceed.
const message = err instanceof Error ? err.message : String(err);
logger.warn(
console.warn(
`[manifest-extractor] resolveSymbol failed for ${link.type}:${link.contract} ` +
`in ${repoPathKey}: ${message}`,
);
@ -358,8 +337,6 @@ export class ManifestExtractor {
}
case 'grpc':
return `grpc::${contract}`;
case 'thrift':
return `thrift::${contract}`;
case 'topic':
return `topic::${contract}`;
case 'lib':

View file

@ -1,4 +1,4 @@
import type { StoredContract, CrossLink, MatchingConfig } from './types.js';
import type { StoredContract, CrossLink } from './types.js';
export interface MatchResult {
matched: CrossLink[];
@ -10,45 +10,8 @@ export interface WildcardMatchResult {
remaining: StoredContract[];
}
function isServiceWildcard(cid: string): boolean {
return (cid.startsWith('grpc::') || cid.startsWith('thrift::')) && cid.endsWith('/*');
}
/**
* Detect HTTP contracts that are too generic or infrastructure-level to
* produce meaningful cross-repo links. These are still extracted (useful
* for documentation / route maps) but excluded from cross-link matching.
*
* Two categories:
* 1. Health-check / readiness endpoints every service has one, matching
* them produces N×M false links.
* 2. Param-only paths routes like `/{param}` or `/{param}/{param}` that
* collapse to a single catch-all after normalization. These match any
* service with a similar shape, producing false positives.
*
* Both are configurable via matching.exclude_links_paths and
* matching.exclude_links_param_only_paths in group.yaml.
*/
function buildNoisyContractFilter(
matchingConfig?: MatchingConfig,
): (contractId: string) => boolean {
const excludePaths = matchingConfig?.exclude_links_paths?.length
? new Set(matchingConfig.exclude_links_paths.map((p) => p.replace(/\/+$/, '')))
: new Set<string>();
const excludeParamOnly = matchingConfig?.exclude_links_param_only_paths === true;
return function isNoisyHttpContract(contractId: string): boolean {
if (!contractId.startsWith('http::')) return false;
const parts = contractId.split('::');
if (parts.length < 3) return false;
const pathPart = parts.slice(2).join('::').replace(/\/+$/, '');
if (excludePaths.has(pathPart)) return true;
if (excludeParamOnly) {
const segments = pathPart.split('/').filter(Boolean);
if (segments.length > 0 && segments.every((s) => s === '{param}')) return true;
}
return false;
};
function isGrpcWildcard(cid: string): boolean {
return cid.startsWith('grpc::') && cid.endsWith('/*');
}
export function normalizeContractId(id: string): string {
@ -69,9 +32,8 @@ export function normalizeContractId(id: string): string {
}
return id;
}
case 'grpc':
case 'thrift': {
// Canonical form: `<type>::<lowercased-package-or-service>[/<method>]`.
case 'grpc': {
// Canonical form: `grpc::<lowercased-package-or-service>[/<method>]`.
//
// The package/service segment is lowercased because gRPC package
// names are effectively case-insensitive across language bindings
@ -85,23 +47,22 @@ export function normalizeContractId(id: string): string {
// as DISTINCT canonical forms: `grpc::userservice` does not match
// `grpc::userservice/Login`. That's by design — callers that want
// service-level manifest matching against method-level providers
// should use the service wildcard form `grpc::UserService/*` or
// `thrift::UserService/*` which is
// should use the gRPC wildcard form `grpc::UserService/*` which is
// handled by runWildcardMatch below.
const slashIdx = rest.indexOf('/');
if (slashIdx > 0) {
const pkg = rest.substring(0, slashIdx).toLowerCase();
const method = rest.substring(slashIdx);
return `${type}::${pkg}${method}`;
return `grpc::${pkg}${method}`;
}
if (slashIdx === 0) {
// Malformed "/method" with leading slash — keep as-is so two
// equally malformed ids can still match each other.
return `${type}::${rest}`;
return `grpc::${rest}`;
}
// No slash: package/service only. Lowercase to match the package
// segment produced by the pkg/method branch above.
return `${type}::${rest.toLowerCase()}`;
return `grpc::${rest.toLowerCase()}`;
}
case 'topic':
return `topic::${rest.trim().toLowerCase()}`;
@ -129,41 +90,11 @@ function findMatchingKeys(contractId: string, index: Map<string, StoredContract[
return matches;
}
if (normalized.startsWith('thrift::')) {
const rest = normalized.substring('thrift::'.length);
const slashIdx = rest.indexOf('/');
if (slashIdx > 0) {
const service = rest.substring(0, slashIdx);
const method = rest.substring(slashIdx + 1);
if (!service.includes('.') && method && method !== '*') {
const matches: string[] = [];
for (const key of index.keys()) {
if (!key.startsWith('thrift::') || key.endsWith('/*')) continue;
const providerRest = key.substring('thrift::'.length);
const providerSlashIdx = providerRest.indexOf('/');
if (providerSlashIdx < 0) continue;
const providerService = providerRest.substring(0, providerSlashIdx);
const providerMethod = providerRest.substring(providerSlashIdx + 1);
if (providerMethod !== method) continue;
if (providerService === service || providerService.endsWith('.' + service)) {
matches.push(key);
}
}
matches.sort();
return matches.length === 1 ? matches : [];
}
}
}
return [];
}
export function buildProviderIndex(
contracts: StoredContract[],
matchingConfig?: MatchingConfig,
): Map<string, StoredContract[]> {
const isNoisy = buildNoisyContractFilter(matchingConfig);
const providers = contracts.filter((c) => c.role === 'provider' && !isNoisy(c.contractId));
export function buildProviderIndex(contracts: StoredContract[]): Map<string, StoredContract[]> {
const providers = contracts.filter((c) => c.role === 'provider');
const index = new Map<string, StoredContract[]>();
for (const p of providers) {
const key = normalizeContractId(p.contractId);
@ -177,15 +108,11 @@ export function buildProviderIndex(
export function runExactMatch(
contracts: StoredContract[],
providerIndex?: Map<string, StoredContract[]>,
matchingConfig?: MatchingConfig,
): MatchResult {
const isNoisy = buildNoisyContractFilter(matchingConfig);
const index = providerIndex ?? buildProviderIndex(contracts, matchingConfig);
const index = providerIndex ?? buildProviderIndex(contracts);
// Skip service wildcard consumers — they go to wildcard pass only
const consumers = contracts.filter(
(c) => c.role === 'consumer' && !isServiceWildcard(c.contractId) && !isNoisy(c.contractId),
);
// Skip gRPC wildcard consumers — they go to wildcard pass only
const consumers = contracts.filter((c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId));
const matched: CrossLink[] = [];
const matchedConsumerIds = new Set<string>();
@ -229,15 +156,14 @@ export function runExactMatch(
// normalUnmatched: contracts that weren't matched in exact pass
const normalUnmatched = contracts.filter((c) => {
if (isServiceWildcard(c.contractId)) return false; // excluded from exact, handled separately
if (isNoisy(c.contractId)) return false; // excluded from matching — don't surface as unmatched
if (isGrpcWildcard(c.contractId)) return false; // excluded from exact, handled separately
const id = `${c.repo}::${c.contractId}`;
return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id);
});
// Re-add service wildcard contracts — they were never in exact matching
const serviceWildcards = contracts.filter((c) => isServiceWildcard(c.contractId));
const unmatched = [...normalUnmatched, ...serviceWildcards];
// Re-add gRPC wildcard contracts — they were never in exact matching
const grpcWildcards = contracts.filter((c) => isGrpcWildcard(c.contractId));
const unmatched = [...normalUnmatched, ...grpcWildcards];
return { matched, unmatched };
}
@ -247,28 +173,21 @@ export function runWildcardMatch(
providerIndex: Map<string, StoredContract[]>,
): WildcardMatchResult {
const wildcardConsumers = unmatched.filter(
(c) => c.role === 'consumer' && isServiceWildcard(c.contractId),
(c) => c.role === 'consumer' && isGrpcWildcard(c.contractId),
);
const matched: CrossLink[] = [];
const matchedConsumerIds = new Set<string>();
for (const consumer of wildcardConsumers) {
const normalized = normalizeContractId(consumer.contractId);
const typeEnd = normalized.indexOf('::');
const consumerType = normalized.slice(0, typeEnd);
// "grpc::com.example.userservice/*" → "com.example.userservice"
// "thrift::userservice/*" → "userservice"
const fqService = normalized.slice(typeEnd + 2, -2); // strip "<type>::" and "/*"
const candidateProviders: StoredContract[] = [];
const matchedProviderServices = new Set<string>();
// "grpc::userservice/*" → "userservice"
const fqService = normalized.slice(normalized.indexOf('::') + 2, -2); // strip "grpc::" and "/*"
for (const [key, providers] of providerIndex) {
// Only match against non-wildcard same-type providers (method-level IDs).
const keyTypeEnd = key.indexOf('::');
if (keyTypeEnd < 0 || key.endsWith('/*')) continue;
const providerType = key.slice(0, keyTypeEnd);
if (providerType !== consumerType) continue;
const afterPrefix = key.slice(keyTypeEnd + 2); // strip "<type>::"
// Only match against non-wildcard gRPC providers (method-level IDs)
if (!key.startsWith('grpc::') || key.endsWith('/*')) continue;
const afterPrefix = key.slice(6); // strip "grpc::"
const slashIdx = afterPrefix.indexOf('/');
if (slashIdx < 0) continue;
const providerFqService = afterPrefix.slice(0, slashIdx);
@ -280,46 +199,39 @@ export function runWildcardMatch(
if (!isMatch) continue;
matchedProviderServices.add(providerFqService);
candidateProviders.push(...providers);
}
if (consumerType === 'thrift' && !fqService.includes('.') && matchedProviderServices.size > 1) {
continue;
}
for (const provider of candidateProviders) {
// Skip same-repo same-service (same logic as runExactMatch)
if (provider.repo === consumer.repo) {
if (!provider.service || !consumer.service || provider.service === consumer.service) {
continue;
for (const provider of providers) {
// Skip same-repo same-service (same logic as runExactMatch)
if (provider.repo === consumer.repo) {
if (!provider.service || !consumer.service || provider.service === consumer.service) {
continue;
}
}
}
matched.push({
from: {
repo: consumer.repo,
service: consumer.service,
symbolUid: consumer.symbolUid,
symbolRef: consumer.symbolRef,
},
to: {
repo: provider.repo,
service: provider.service,
symbolUid: provider.symbolUid,
symbolRef: provider.symbolRef,
},
type: consumer.type,
contractId: consumer.contractId, // consumer's wildcard ID
matchType: 'wildcard',
confidence: Math.min(provider.confidence, consumer.confidence),
});
matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`);
matched.push({
from: {
repo: consumer.repo,
service: consumer.service,
symbolUid: consumer.symbolUid,
symbolRef: consumer.symbolRef,
},
to: {
repo: provider.repo,
service: provider.service,
symbolUid: provider.symbolUid,
symbolRef: provider.symbolRef,
},
type: consumer.type,
contractId: consumer.contractId, // consumer's wildcard ID
matchType: 'wildcard',
confidence: Math.min(provider.confidence, consumer.confidence),
});
matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`);
}
}
}
const remaining = unmatched.filter((c) => {
if (c.role !== 'consumer' || !isServiceWildcard(c.contractId)) return true;
if (c.role !== 'consumer' || !isGrpcWildcard(c.contractId)) return true;
return !matchedConsumerIds.has(`${c.repo}::${c.contractId}`);
});

View file

@ -6,19 +6,16 @@ import { readRegistry, type RegistryEntry } from '../../storage/repo-manager.js'
import type { GroupConfig, RepoHandle, RepoSnapshot, StoredContract, CrossLink } from './types.js';
import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
import { GrpcExtractor } from './extractors/grpc-extractor.js';
import { ThriftExtractor } from './extractors/thrift-extractor.js';
import { TopicExtractor } from './extractors/topic-extractor.js';
import { IncludeExtractor } from './extractors/include-extractor.js';
import { ManifestExtractor } from './extractors/manifest-extractor.js';
import { discoverWorkspaceLinks } from './extractors/workspace-extractor.js';
import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.js';
import { runExactMatch } from './matching.js';
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
import type { CypherExecutor } from './contract-extractor.js';
import { writeContractRegistry } from './storage.js';
import { writeBridge } from './bridge-db.js';
import type { ContractRegistry } from './types.js';
import { logger } from '../logger.js';
export interface SyncOptions {
extractorOverride?:
| ((repo: RepoHandle) => Promise<StoredContract[]>)
@ -89,18 +86,15 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
let autoContracts: StoredContract[] = [];
let manifestCrossLinks: CrossLink[] = [];
let dbExecutors: Map<string, CypherExecutor> | undefined;
let registryEntries: RegistryEntry[] | undefined;
const eo = opts?.extractorOverride;
if (eo && eo.length === 0) {
autoContracts = await (eo as () => Promise<StoredContract[]>)();
} else {
registryEntries = await readRegistry();
const entries = registryEntries;
const entries = await readRegistry();
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
const httpEx = new HttpRouteExtractor();
const grpcEx = new GrpcExtractor();
const thriftEx = new ThriftExtractor();
const topicEx = new TopicExtractor();
const includeEx = new IncludeExtractor();
dbExecutors = new Map<string, CypherExecutor>();
@ -149,17 +143,6 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
}
if (config.detect.thrift) {
const extracted = await thriftEx.extract(executor, handle.repoPath, handle);
for (const c of extracted) {
autoContracts.push({
...c,
repo: groupPath,
service: assignService(c.symbolRef.filePath, boundaries),
});
}
}
if (config.detect.topics) {
const extracted = await topicEx.extract(executor, handle.repoPath, handle);
for (const c of extracted) {
@ -208,69 +191,44 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
}
// Auto-discover workspace dependency contracts (Rust Cargo workspaces, etc.)
// and merge them with explicit manifest links. Discovered links use the same
// ManifestExtractor pipeline as hand-written links in group.yaml.
let allLinks = [...config.links];
if (config.detect.workspace_deps) {
const repoPaths = new Map<string, string>();
if (!registryEntries) registryEntries = await readRegistry();
for (const [groupPath, regName] of Object.entries(config.repos)) {
const e = registryEntries.find((en) => en.name === regName);
if (e) repoPaths.set(groupPath, e.path);
}
const wsResult = await discoverWorkspaceLinks(config.repos, repoPaths, dbExecutors);
if (wsResult.links.length > 0) {
allLinks = [...allLinks, ...wsResult.links];
if (opts?.verbose) {
for (const s of wsResult.stats) {
logger.info(
` workspace-deps: discovered ${s.linkCount} cross-${s.ecosystem.toLowerCase()} links from ${s.projectCount} ${s.ecosystem} projects`,
);
}
}
}
}
// Process manifest links declared in group.yaml (plus any auto-discovered).
// Process manifest links declared in group.yaml.
// ManifestExtractor is fully implemented but was never wired into this
// pipeline — config.links were parsed and validated but silently dropped.
// Placed after the DB try/finally: resolveSymbol falls back to synthetic
// UIDs when dbExecutors is undefined or a pool is closed, so cross-links
// are always generated regardless of whether real DB executors are available.
if (allLinks.length > 0) {
if (config.links.length > 0) {
// Warn about dangling links that reference repos not declared in config.repos.
// They still generate cross-links via synthetic UIDs (determinism is preserved),
// but the operator probably meant something that now silently does nothing useful.
const knownRepos = new Set(Object.keys(config.repos));
for (const link of allLinks) {
for (const link of config.links) {
const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r));
if (dangling.length > 0) {
logger.warn(
console.warn(
`[group/sync] manifest link ${link.type}:${link.contract} references repos not in config.repos: ${dangling.join(', ')} — cross-links will use synthetic UIDs`,
);
}
}
const manifestEx = new ManifestExtractor();
const manifestResult = await manifestEx.extractFromManifest(allLinks, dbExecutors);
const manifestResult = await manifestEx.extractFromManifest(config.links, dbExecutors);
autoContracts.push(...manifestResult.contracts);
manifestCrossLinks = manifestResult.crossLinks;
if (opts?.verbose) {
logger.info(
` manifest: ${manifestCrossLinks.length} cross-links from ${allLinks.length} links (${config.links.length} declared + ${allLinks.length - config.links.length} discovered)`,
console.log(
` manifest: ${manifestCrossLinks.length} cross-links from ${config.links.length} declared links`,
);
}
}
const providerIndex = buildProviderIndex(autoContracts, config.matching);
const { matched, unmatched } = runExactMatch(autoContracts, providerIndex, config.matching);
const wildcard = runWildcardMatch(unmatched, providerIndex);
const { matched, unmatched } = runExactMatch(autoContracts);
// Dedupe cross-links. Manifest contracts participate in runExactMatch, so a
// manifest-declared link can also emit a matchType:'exact' CrossLink with the
// same endpoints. Prefer the manifest version — it reflects operator intent
// and carries matchType:'manifest' which downstream consumers may rely on.
const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched, ...wildcard.matched]);
const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched]);
const allContracts: StoredContract[] = autoContracts;
const registry: ContractRegistry = {
@ -284,34 +242,18 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
if (opts?.groupDir && !opts.skipWrite) {
await writeContractRegistry(opts.groupDir, registry);
// writeBridge failure (disk full, schema error, permission denied) must
// not mask the registry — contracts.json was just written successfully
// and is the canonical source of truth. A stale or absent bridge
// degrades impact queries to empty results, which is recoverable on
// the next sync. Surface the failure as a warning so operators can
// act, but do not propagate it.
// (PR #1156 follow-up review: writeBridge error in sync.ts propagates
// uncaught.)
try {
await writeBridge(opts.groupDir, {
contracts: allContracts,
crossLinks,
repoSnapshots,
missingRepos,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(
{ err: msg, groupDir: opts.groupDir },
'⚠️ writeBridge failed; contracts.json is intact but bridge.lbug is stale. Re-run `gitnexus group sync` to retry.',
);
}
await writeBridge(opts.groupDir, {
contracts: allContracts,
crossLinks,
repoSnapshots,
missingRepos,
});
}
return {
contracts: allContracts,
crossLinks,
unmatched: wildcard.remaining,
unmatched,
missingRepos,
repoSnapshots,
};

View file

@ -1,4 +1,4 @@
export type ContractType = 'http' | 'grpc' | 'thrift' | 'topic' | 'lib' | 'custom' | 'include';
export type ContractType = 'http' | 'grpc' | 'topic' | 'lib' | 'custom' | 'include';
export type MatchType = 'exact' | 'manifest' | 'wildcard' | 'bm25' | 'embedding';
export type ContractRole = 'provider' | 'consumer';
@ -24,36 +24,16 @@ export interface GroupManifestLink {
export interface DetectConfig {
http: boolean;
grpc: boolean;
thrift: boolean;
topics: boolean;
shared_libs: boolean;
embedding_fallback: boolean;
includes: boolean;
workspace_deps: boolean;
}
export interface MatchingConfig {
bm25_threshold: number;
embedding_threshold: number;
max_candidates_per_step: number;
/**
* HTTP paths to exclude from cross-link matching. Contracts at these paths
* are still extracted and visible in the registry, but they don't produce
* cross-repo links. Useful for health-check endpoints (`/ping`, `/health`)
* that every service exposes and would otherwise create N×M false links.
* Trailing slashes are normalized before comparison.
* @default []
*/
exclude_links_paths?: string[];
/**
* When `true`, exclude HTTP routes where every path segment is `{param}`
* (e.g. `/{param}`, `/{param}/{param}`) from cross-link matching. Mixed
* routes like `/users/{param}` are not affected. These param-only routes
* collapse to a single catch-all after normalization and produce false
* positives across unrelated services.
* @default false
*/
exclude_links_param_only_paths?: boolean;
}
export interface SymbolRef {

View file

@ -716,6 +716,7 @@ export const processCalls = async (
propertyName: string;
filePath: string;
srcId: string;
line?: number;
}[] = [];
// Phase P cross-file: accumulate heritage across files for cross-file isSubclassOf.
// Used as a secondary check when per-file parentMap lacks the relationship — helps
@ -933,7 +934,7 @@ export const processCalls = async (
// Defer resolution: Ruby attr_accessor properties are registered during
// this same loop, so cross-file lookups fail if the declaring file hasn't
// been processed yet. Collect now, resolve after all files are done.
pendingWrites.push({ receiverTypeName, propertyName, filePath: file.path, srcId });
pendingWrites.push({ receiverTypeName, propertyName, filePath: file.path, srcId, line: captureMap['assignment'].startPosition.row + 1 });
}
// Assignment-only capture (no @call sibling): skip the rest of this
// forEach iteration — this acts as a `continue` in the match loop.
@ -1382,7 +1383,7 @@ export const processCalls = async (
);
if (fieldOwner) {
graph.addRelationship({
id: generateId('ACCESSES', `${pw.srcId}:${fieldOwner.nodeId}:write`),
id: generateId('ACCESSES', `${pw.srcId}:${fieldOwner.nodeId}:write${pw.line !== undefined ? `:${pw.line}` : ''}`),
sourceId: pw.srcId,
targetId: fieldOwner.nodeId,
type: 'ACCESSES',
@ -2979,7 +2980,7 @@ export const processAssignmentsFromExtracted = (
const fieldOwner = resolveFieldOwnership(receiverTypeName, asn.propertyName, asn.filePath, ctx);
if (!fieldOwner) continue;
graph.addRelationship({
id: generateId('ACCESSES', `${asn.sourceId}:${fieldOwner.nodeId}:write`),
id: generateId('ACCESSES', `${asn.sourceId}:${fieldOwner.nodeId}:write${asn.line !== undefined ? `:${asn.line}` : ''}`),
sourceId: asn.sourceId,
targetId: fieldOwner.nodeId,
type: 'ACCESSES',

View file

@ -55,6 +55,15 @@ import {
cImportOwningScope,
cReceiverBinding,
} from './c/index.js';
import {
emitCppScopeCaptures,
interpretCppImport,
interpretCppTypeBinding,
cppArityCompatibility,
cppBindingScopeFor,
cppImportOwningScope,
cppReceiverBinding,
} from './cpp/index.js';
const C_BUILT_INS: ReadonlySet<string> = new Set([
'printf',
@ -447,4 +456,14 @@ export const cppProvider = defineLanguage({
heritageExtractor: createHeritageExtractor(SupportedLanguages.CPlusPlus),
labelOverride: cppLabelOverride,
builtInNames: C_BUILT_INS,
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
emitScopeCaptures: emitCppScopeCaptures,
interpretImport: interpretCppImport,
interpretTypeBinding: interpretCppTypeBinding,
bindingScopeFor: cppBindingScopeFor,
importOwningScope: cppImportOwningScope,
receiverBinding: cppReceiverBinding,
arityCompatibility: cppArityCompatibility,
// mergeBindings + resolveImportTarget live on ScopeResolver (see cpp/scope-resolver.ts).
});

View file

@ -0,0 +1,180 @@
import type { SyntaxNode } from '../../utils/ast-helpers.js';
export interface CppArityInfo {
parameterCount?: number;
requiredParameterCount?: number;
parameterTypes?: string[];
}
/**
* Compute declaration arity from a C++ function definition or declaration node.
* Extends the C arity computation with support for:
* - optional_parameter_declaration (default parameters)
* - variadic_parameter_declaration / parameter packs
* - (void) explicit zero-parameter form
*/
export function computeCppDeclarationArity(node: SyntaxNode): CppArityInfo {
const funcDecl = findFuncDeclarator(node);
if (funcDecl === null) return {};
const paramList = funcDecl.childForFieldName('parameters');
if (paramList === null) return {};
const params: SyntaxNode[] = [];
// Track whether a C-style variadic `...` anonymous token appears.
// tree-sitter-cpp emits `...` as an anonymous (non-named) child of
// parameter_list, not as `variadic_parameter`.
let hasEllipsis = false;
for (let i = 0; i < paramList.childCount; i++) {
const child = paramList.child(i);
if (child === null) continue;
if (
child.type === 'parameter_declaration' ||
child.type === 'optional_parameter_declaration' ||
child.type === 'variadic_parameter' ||
child.type === 'variadic_parameter_declaration'
) {
params.push(child);
} else if (child.type === '...' || (!child.isNamed && child.text === '...')) {
hasEllipsis = true;
}
}
// Empty parameter list: C++ `void foo()` means zero params (unlike C)
if (params.length === 0 && !hasEllipsis) {
return { parameterCount: 0, requiredParameterCount: 0, parameterTypes: [] };
}
// (void) means zero parameters
if (params.length === 1 && params[0].type === 'parameter_declaration') {
const typeNode = params[0].childForFieldName('type');
const hasDeclarator = params[0].childForFieldName('declarator') !== null;
if (typeNode !== null && typeNode.text === 'void' && !hasDeclarator) {
return { parameterCount: 0, requiredParameterCount: 0, parameterTypes: [] };
}
}
// C-style variadic: `void foo(int x, ...)` — the `...` is an anonymous
// token in tree-sitter-cpp, detected via `hasEllipsis` above.
// C++ parameter packs: `template<typename... Ts> void foo(Ts... args)` —
// detected as `variadic_parameter_declaration`.
const isVariadic =
hasEllipsis ||
params.some(
(p) => p.type === 'variadic_parameter' || p.type === 'variadic_parameter_declaration',
);
const optionalCount = params.filter((p) => p.type === 'optional_parameter_declaration').length;
const requiredCount = params.filter(
(p) =>
p.type === 'parameter_declaration' ||
// variadic_parameter_declaration with a name is a parameter pack — counts as one
p.type === 'variadic_parameter_declaration',
).length;
const totalNonVariadic = requiredCount + optionalCount;
const types: string[] = [];
for (const p of params) {
if (p.type === 'variadic_parameter') {
types.push('...');
} else if (p.type === 'variadic_parameter_declaration') {
// Parameter pack: treated as variadic
types.push('...');
} else {
const typeNode = p.childForFieldName('type');
types.push(normalizeCppParamType(typeNode?.text ?? 'unknown'));
}
}
// Append '...' for C-style variadic if not already in types
if (hasEllipsis && !types.includes('...')) {
types.push('...');
}
return {
parameterCount: isVariadic ? undefined : totalNonVariadic,
requiredParameterCount: requiredCount,
parameterTypes: types,
};
}
/**
* Compute call-site arity from a call_expression node.
*/
export function computeCppCallArity(node: SyntaxNode): number {
const argList = node.childForFieldName('arguments');
if (argList === null) return 0;
let count = 0;
for (let i = 0; i < argList.childCount; i++) {
const child = argList.child(i);
if (child === null) continue;
if (child.type !== ',' && child.type !== '(' && child.type !== ')') {
count++;
}
}
return count;
}
/**
* Normalize a C++ parameter type for overload disambiguation.
* Maps common qualified/aliased types to their canonical short forms
* so that `narrowOverloadCandidates` can match against literal-inferred
* argument types (e.g. `inferCppLiteralType` returns `'string'` for
* string literals, not `'std::string'`).
*/
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();
// Strip reference/pointer markers
t = t.replace(/[&*]+\s*$/, '').trim();
// Strip template parameters
t = t.replace(/<[^>]*>/g, '').trim();
// Map std:: types to canonical short forms
const STD_MAP: Record<string, string> = {
'std::string': 'string',
'std::wstring': 'string',
'std::string_view': 'string',
'string': 'string',
'char': 'char',
'int': 'int',
'long': 'int',
'short': 'int',
'unsigned': 'int',
'unsigned int': 'int',
'long long': 'int',
'size_t': 'int',
'std::size_t': 'int',
'float': 'double',
'double': 'double',
'bool': 'bool',
'nullptr_t': 'null',
'std::nullptr_t': 'null',
};
return STD_MAP[t] ?? t;
}
function findFuncDeclarator(node: SyntaxNode): SyntaxNode | null {
let decl = node.childForFieldName('declarator');
if (decl === null) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'function_declarator') return c;
}
return null;
}
// Unwrap pointer_declarator / reference_declarator
while (decl.type === 'pointer_declarator' || decl.type === 'reference_declarator') {
const next = decl.childForFieldName('declarator');
if (next === null) {
// reference_declarator may not use field name
for (let i = 0; i < decl.childCount; i++) {
const c = decl.child(i);
if (c?.type === 'function_declarator') return c;
}
break;
}
decl = next;
}
if (decl.type === 'function_declarator') return decl;
return null;
}

View file

@ -0,0 +1,35 @@
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
/**
* C++ arity compatibility: supports overloading and default parameters.
*
* Unlike C (no overloading, exact match only), C++ has:
* - Overloaded functions (same name, different signatures)
* - Default parameters (requiredParameterCount < parameterCount)
* - Variadic functions (C-style `...`)
* - Parameter packs (V1: treated as variadic)
* - Templates (V1: generic-ignored, arity check on non-template params)
*
* Verdict:
* - 'compatible': callsite.arity fits within [required, total] range
* - 'incompatible': callsite.arity is outside the valid range
* - 'unknown': insufficient metadata to determine
*/
export function cppArityCompatibility(
def: SymbolDefinition,
callsite: Callsite,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown';
const variadic = def.parameterTypes?.some((t) => t === '...') ?? false;
// Too few arguments: less than the minimum required
if (min !== undefined && callsite.arity < min) return 'incompatible';
// Too many arguments: more than the maximum and not variadic
if (max !== undefined && callsite.arity > max && !variadic) return 'incompatible';
return 'compatible';
}

View file

@ -0,0 +1,406 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import {
findNodeAtRange,
nodeToCapture,
syntheticCapture,
type SyntaxNode,
} from '../../utils/ast-helpers.js';
import { getCppParser, getCppScopeQuery } from './query.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js';
import { computeCppDeclarationArity, computeCppCallArity } from './arity-metadata.js';
import { markFileLocal } from './file-local-linkage.js';
export function emitCppScopeCaptures(
sourceText: string,
filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
let tree = cachedTree as ReturnType<ReturnType<typeof getCppParser>['parse']> | undefined;
if (tree === undefined) {
tree = parseSourceSafe(getCppParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
}
const rawMatches = getCppScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = [];
// Track ranges where typedef-struct was captured as @declaration.struct
// so we can suppress the duplicate @declaration.typedef match.
const structTypedefRanges = new Set<string>();
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
for (const c of m.captures) {
const tag = '@' + c.name;
if (tag.startsWith('@_')) continue;
grouped[tag] = nodeToCapture(tag, c.node);
}
if (Object.keys(grouped).length === 0) continue;
// ── Handle #include statements ──────────────────────────────────
if (grouped['@import.statement'] !== undefined) {
const anchor = grouped['@import.statement']!;
const includeNode = findNodeAtRange(tree.rootNode, anchor.range, 'preproc_include');
if (includeNode !== null) {
const split = splitCppInclude(includeNode);
if (split !== null) {
out.push(split);
continue;
}
}
}
// ── Handle using declarations (using namespace / using name) ────
if (grouped['@import.using-decl'] !== undefined) {
const anchor = grouped['@import.using-decl']!;
const usingNode = findNodeAtRange(tree.rootNode, anchor.range, 'using_declaration');
if (usingNode !== null) {
const split = splitCppUsingDecl(usingNode);
if (split !== null) {
out.push(split);
continue;
}
}
}
// ── Track typedef-struct ranges ─────────────────────────────────
const structAnchor = grouped['@declaration.struct'] ?? grouped['@declaration.class'];
if (structAnchor !== undefined) {
const r = structAnchor.range;
structTypedefRanges.add(`${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`);
}
// Suppress @declaration.typedef if the same range was already captured
const typedefAnchor = grouped['@declaration.typedef'];
if (typedefAnchor !== undefined) {
const r = typedefAnchor.range;
const key = `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`;
if (structTypedefRanges.has(key)) continue;
}
// ── Enrich function/method declarations with arity metadata ─────
const declAnchor = grouped['@declaration.function'] ?? grouped['@declaration.method'];
if (declAnchor !== undefined) {
const fnNode =
findNodeAtRange(tree.rootNode, declAnchor.range, 'function_definition') ??
findNodeAtRange(tree.rootNode, declAnchor.range, 'declaration') ??
findNodeAtRange(tree.rootNode, declAnchor.range, 'field_declaration');
if (fnNode !== null) {
const arity = computeCppDeclarationArity(fnNode);
if (arity.parameterCount !== undefined) {
grouped['@declaration.parameter-count'] = syntheticCapture(
'@declaration.parameter-count',
fnNode,
String(arity.parameterCount),
);
}
if (arity.requiredParameterCount !== undefined) {
grouped['@declaration.required-parameter-count'] = syntheticCapture(
'@declaration.required-parameter-count',
fnNode,
String(arity.requiredParameterCount),
);
}
if (arity.parameterTypes !== undefined) {
grouped['@declaration.parameter-types'] = syntheticCapture(
'@declaration.parameter-types',
fnNode,
JSON.stringify(arity.parameterTypes),
);
}
// Detect static storage class (file-local linkage)
if (hasStaticStorageClass(fnNode)) {
const nameText = grouped['@declaration.name']?.text;
if (nameText !== undefined) {
markFileLocal(filePath, nameText);
}
}
// Detect anonymous namespace (file-local linkage)
if (isInsideAnonymousNamespace(fnNode)) {
const nameText = grouped['@declaration.name']?.text;
if (nameText !== undefined) {
markFileLocal(filePath, nameText);
}
}
}
}
// ── Detect static variables (file-local linkage) ────────────────
const varDeclAnchor = grouped['@declaration.variable'];
if (varDeclAnchor !== undefined) {
const varNode = findNodeAtRange(tree.rootNode, varDeclAnchor.range, 'declaration');
if (varNode !== null) {
if (hasStaticStorageClass(varNode) || isInsideAnonymousNamespace(varNode)) {
const nameText = grouped['@declaration.name']?.text;
if (nameText !== undefined) {
markFileLocal(filePath, nameText);
}
}
}
}
// ── Enrich call references with arity ───────────────────────────
const callAnchor =
grouped['@reference.call.free'] ??
grouped['@reference.call.member'] ??
grouped['@reference.call.qualified'];
if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) {
const callNode = findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression');
if (callNode !== null) {
grouped['@reference.arity'] = syntheticCapture(
'@reference.arity',
callNode,
String(computeCppCallArity(callNode)),
);
}
}
// ── Enrich constructor calls (new Foo()) with arity ─────────────
const ctorCallAnchor = grouped['@reference.call.constructor'];
if (ctorCallAnchor !== undefined && grouped['@reference.arity'] === undefined) {
const newNode = findNodeAtRange(tree.rootNode, ctorCallAnchor.range, 'new_expression');
if (newNode !== null) {
grouped['@reference.arity'] = syntheticCapture(
'@reference.arity',
newNode,
String(computeCppCallArity(newNode)),
);
}
}
// ── Synthesize argument types for overload narrowing ────────────
const anyCallAnchor = callAnchor ?? ctorCallAnchor;
if (anyCallAnchor !== undefined && grouped['@reference.parameter-types'] === undefined) {
const cNode =
findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'call_expression') ??
findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'new_expression');
if (cNode !== null) {
const argTypes = inferCppCallArgTypes(cNode);
if (argTypes !== undefined && argTypes.length > 0) {
grouped['@reference.parameter-types'] = syntheticCapture(
'@reference.parameter-types',
cNode,
JSON.stringify(argTypes),
);
}
}
}
// ── Post-process @type-binding.assignment for auto declarations ──
// The wildcard `type: (_)` in the @type-binding.assignment query
// pattern matches before the more specific @type-binding.alias and
// @type-binding.member-access patterns. When the type is `auto`
// (placeholder_type_specifier), we re-inspect the AST to synthesize
// the correct capture tags so interpret.ts can produce the right
// rawTypeName for compound-receiver chain resolution.
if (
grouped['@type-binding.assignment'] !== undefined &&
grouped['@type-binding.type']?.text === 'auto'
) {
const anchor = grouped['@type-binding.assignment']!;
const declNode = findNodeAtRange(tree.rootNode, anchor.range, 'declaration');
if (declNode !== null) {
const declarator = declNode.childForFieldName('declarator');
if (declarator?.type === 'init_declarator') {
const valueNode = declarator.childForFieldName('value');
if (valueNode !== null) {
if (valueNode.type === 'identifier') {
// auto alias = existingVar → promote to @type-binding.alias
grouped['@type-binding.alias'] = anchor;
grouped['@type-binding.type'] = nodeToCapture('@type-binding.type', valueNode);
delete grouped['@type-binding.assignment'];
} else if (valueNode.type === 'field_expression') {
// auto addr = user.address → promote to @type-binding.member-access
const argNode = valueNode.childForFieldName('argument');
const fieldNode = valueNode.childForFieldName('field');
if (argNode !== null && fieldNode !== null) {
grouped['@type-binding.member-access'] = anchor;
grouped['@type-binding.member-access-receiver'] = nodeToCapture(
'@type-binding.member-access-receiver',
argNode,
);
grouped['@type-binding.type'] = nodeToCapture('@type-binding.type', fieldNode);
delete grouped['@type-binding.assignment'];
}
} else if (valueNode.type === 'call_expression') {
const fnNode = valueNode.childForFieldName('function');
if (fnNode?.type === 'field_expression') {
// auto city = addr.getCity() → promote to @type-binding.alias
// with dotted rawName "addr.getCity" for compound-receiver
const argNode = fnNode.childForFieldName('argument');
const fieldNode = fnNode.childForFieldName('field');
if (argNode !== null && fieldNode !== null) {
grouped['@type-binding.member-access'] = anchor;
grouped['@type-binding.member-access-receiver'] = nodeToCapture(
'@type-binding.member-access-receiver',
argNode,
);
grouped['@type-binding.type'] = nodeToCapture('@type-binding.type', fieldNode);
delete grouped['@type-binding.assignment'];
}
}
}
}
}
}
}
out.push(grouped);
}
return out;
}
/**
* Infer argument types from a call_expression or new_expression node.
* Used for overload disambiguation by parameter types.
*
* Only literal types are inferred identifiers and complex expressions
* return empty string (unknown) so narrowOverloadCandidates treats them
* as any-match.
*/
function inferCppCallArgTypes(node: SyntaxNode): string[] | undefined {
const argList = node.childForFieldName('arguments');
if (argList === null) return undefined;
const types: string[] = [];
for (let i = 0; i < argList.childCount; i++) {
const child = argList.child(i);
if (child === null) continue;
if (child.type === ',' || child.type === '(' || child.type === ')') continue;
const litType = inferCppLiteralType(child);
if (litType !== '') {
types.push(litType);
} else if (child.type === 'identifier') {
// Variable reference — look up declared type in enclosing scope
types.push(lookupDeclaredTypeForIdentifier(child));
} else {
types.push('');
}
}
return types.length > 0 ? types : undefined;
}
/**
* Infer the canonical type name of a C++ literal AST node.
* Returns empty string for non-literal / unknown nodes.
*/
function inferCppLiteralType(node: SyntaxNode): string {
switch (node.type) {
case 'number_literal': {
const text = node.text;
// Floating-point literals contain '.', 'e', 'E', or end with 'f'/'F'
if (text.includes('.') || text.includes('e') || text.includes('E') ||
text.endsWith('f') || text.endsWith('F')) {
return 'double';
}
return 'int';
}
case 'string_literal':
case 'raw_string_literal':
case 'concatenated_string':
return 'string';
case 'char_literal':
return 'char';
case 'true':
case 'false':
return 'bool';
case 'null':
case 'nullptr':
return 'null';
default:
return '';
}
}
/**
* Look up the declared type of a variable by scanning sibling declarations
* in the enclosing compound_statement (function body). Handles:
* - `std::string result = ...` 'string'
* - `int n = ...` 'int'
* - `const int n = ...` 'int'
* Returns empty string if no declaration found or type is auto/placeholder.
*/
function lookupDeclaredTypeForIdentifier(identNode: SyntaxNode): string {
const varName = identNode.text;
// Walk up to the enclosing compound_statement (function body)
let scope: SyntaxNode | null = identNode.parent;
while (
scope !== null &&
scope.type !== 'compound_statement' &&
scope.type !== 'translation_unit'
) {
scope = scope.parent;
}
if (scope === null) return '';
// Scan declarations in the scope for a matching variable name
for (let i = 0; i < scope.childCount; i++) {
const stmt = scope.child(i);
if (stmt === null || stmt.type !== 'declaration') continue;
const typeNode = stmt.childForFieldName('type');
if (typeNode === null) continue;
// Skip auto/placeholder types — those need chain-follow, not literal
if (typeNode.type === 'placeholder_type_specifier') continue;
// Check init_declarator children for the variable name
const declarator = stmt.childForFieldName('declarator');
if (declarator === null) continue;
if (declarator.type === 'init_declarator') {
const nameChild = declarator.childForFieldName('declarator');
if (nameChild !== null && nameChild.text === varName) {
return normalizeCppTypeText(typeNode.text);
}
} else if (declarator.text === varName) {
return normalizeCppTypeText(typeNode.text);
}
}
return '';
}
/** Normalize a type-specifier text for argument type matching.
* Strips qualifiers (const, volatile), namespace prefixes (std::),
* and pointer/reference markers. */
function normalizeCppTypeText(text: string): string {
let t = text.trim();
t = t.replace(/\b(const|volatile|static|extern|mutable)\b/g, '').trim();
t = t.replace(/^.*::/, ''); // strip namespace prefix
t = t.replace(/[*&]/g, '').trim();
return t;
}
/**
* Check if a C++ function_definition or declaration has `static` storage class.
*/
function hasStaticStorageClass(node: SyntaxNode): boolean {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child !== null && child.type === 'storage_class_specifier' && child.text === 'static') {
return true;
}
}
return false;
}
/**
* Check if a node is inside an anonymous namespace (file-local linkage in C++).
* Anonymous namespaces have no `name` field in tree-sitter-cpp.
*/
function isInsideAnonymousNamespace(node: SyntaxNode): boolean {
let ancestor: SyntaxNode | null = node.parent ?? null;
while (ancestor !== null) {
if (ancestor.type === 'namespace_definition') {
// Anonymous namespace: has declaration_list but no name child
const nameChild = ancestor.childForFieldName?.('name') ?? null;
if (nameChild === null) return true;
}
ancestor = ancestor.parent;
}
return false;
}

View file

@ -0,0 +1,67 @@
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
/**
* Per-file set of symbol names with file-local linkage.
* In C++ there are two sources of file-local linkage:
* 1. `static` storage class (same as C)
* 2. Anonymous namespace (`namespace { ... }`)
*
* Populated during `emitCppScopeCaptures` and consumed by
* `expandCppWildcardNames` to exclude file-local symbols from
* cross-file wildcard import visibility.
*
* NOTE: module-level state, single-process-single-repo use only.
* Call `clearFileLocalNames()` at the start of each resolution pass.
*
* Key: filePath, Value: Set of file-local symbol names.
*/
const fileLocalNames = new Map<string, Set<string>>();
/** Record a symbol name as file-local (static or anonymous namespace). */
export function markFileLocal(filePath: string, name: string): void {
let names = fileLocalNames.get(filePath);
if (names === undefined) {
names = new Set<string>();
fileLocalNames.set(filePath, names);
}
names.add(name);
}
/** Check whether a symbol name has file-local linkage in the given file. */
export function isFileLocal(filePath: string, name: string): boolean {
return fileLocalNames.get(filePath)?.has(name) ?? false;
}
/** Clear tracked file-local names (call at start of each resolution pass). */
export function clearFileLocalNames(): void {
fileLocalNames.clear();
}
/**
* Return the names visible through a C++ wildcard import (`#include`).
* All module-scope defs from the target file are visible EXCEPT those
* with file-local linkage (static functions/variables, anonymous namespace symbols).
*/
export function expandCppWildcardNames(
targetModuleScope: ScopeId,
parsedFiles: readonly ParsedFile[],
): readonly string[] {
const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope);
if (target === undefined) return [];
const seen = new Set<string>();
const names: string[] = [];
for (const def of target.localDefs) {
const name = simpleName(def);
if (name === '') continue;
if (isFileLocal(target.filePath, name)) continue;
if (seen.has(name)) continue;
seen.add(name);
names.push(name);
}
return names;
}
function simpleName(def: SymbolDefinition): string {
return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
}

View file

@ -0,0 +1,53 @@
import { readdirSync, type Dirent } from 'fs';
import { join, relative } from 'path';
/** C++ header extensions to scan for in the workspace. */
const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh']);
/**
* Walk `repoPath` recursively and return relative paths of all C++ header files.
* Used by `loadResolutionConfig` so the C++ resolver can resolve `#include`
* targets that live in header files.
*
* Scans for: .h, .hpp, .hxx, .hh
*/
export function scanCppHeaderFiles(repoPath: string): ReadonlySet<string> {
const headers = new Set<string>();
walk(repoPath, repoPath, headers);
return headers;
}
function walk(dir: string, root: string, out: Set<string>): void {
let entries: Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' });
} catch {
return; // permission denied, etc.
}
for (const entry of entries) {
const name = entry.name;
const full = join(dir, name);
if (entry.isDirectory()) {
if (
name === 'node_modules' ||
name === '.git' ||
name === 'vendor' ||
name === 'dist' ||
name === 'build' ||
name === 'out' ||
name === 'target' ||
name === '_build' ||
name === '.next' ||
name.startsWith('cmake-build')
) {
continue;
}
walk(full, root, out);
} else if (entry.isFile()) {
const ext = name.slice(name.lastIndexOf('.'));
if (HEADER_EXTENSIONS.has(ext)) {
out.add(relative(root, full).replace(/\\/g, '/'));
}
}
}
}

View file

@ -0,0 +1,120 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
/**
* Decompose a `preproc_include` node into a CaptureMatch with structured
* import captures. C++ #include maps to a wildcard import (all symbols
* from the header are visible). Identical to C's splitCInclude.
*/
export function splitCppInclude(node: SyntaxNode): CaptureMatch | null {
const pathNode = node.childForFieldName?.('path') ?? null;
if (pathNode === null) {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child === null) continue;
if (child.type === 'string_literal' || child.type === 'system_lib_string') {
return buildIncludeCapture(node, child);
}
}
return null;
}
return buildIncludeCapture(node, pathNode);
}
function buildIncludeCapture(node: SyntaxNode, pathNode: SyntaxNode): CaptureMatch {
let raw: string;
if (pathNode.type === 'string_literal') {
const content = pathNode.namedChildren.find((c) => c.type === 'string_content');
raw = content?.text ?? pathNode.text.replace(/^"|"$/g, '');
} else {
raw = pathNode.text;
if (raw.startsWith('<') && raw.endsWith('>')) {
raw = raw.slice(1, -1);
}
}
const isSystem = pathNode.type === 'system_lib_string';
const result: Record<string, Capture> = {
'@import.statement': nodeToCapture('@import.statement', node),
'@import.kind': syntheticCapture('@import.kind', node, 'wildcard'),
'@import.source': syntheticCapture('@import.source', node, raw),
};
if (isSystem) {
result['@import.system'] = syntheticCapture('@import.system', node, 'true');
}
return result;
}
/**
* Decompose a `using_declaration` node into a CaptureMatch.
*
* tree-sitter-cpp produces:
* using namespace std; using_declaration { "using", "namespace", identifier("std"), ";" }
* using std::vector; using_declaration { "using", qualified_identifier("std::vector"), ";" }
*
* The first form is a wildcard import (all names from namespace).
* The second form is a named import (single symbol).
*/
export function splitCppUsingDecl(node: SyntaxNode): CaptureMatch | null {
if (node.type !== 'using_declaration') return null;
// Check for "namespace" keyword among anonymous children
let hasNamespaceKeyword = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child !== null && !child.isNamed && child.text === 'namespace') {
hasNamespaceKeyword = true;
break;
}
}
if (hasNamespaceKeyword) {
// using namespace <name>;
// The namespace name can be an identifier or qualified_identifier
let namespaceName: string | null = null;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child === null) continue;
if (child.type === 'identifier' || child.type === 'qualified_identifier') {
namespaceName = child.text;
break;
}
}
if (namespaceName === null) return null;
return {
'@import.statement': nodeToCapture('@import.statement', node),
'@import.kind': syntheticCapture('@import.kind', node, 'wildcard'),
'@import.source': syntheticCapture('@import.source', node, namespaceName),
'@import.using-namespace': syntheticCapture('@import.using-namespace', node, 'true'),
};
}
// using <qualified_identifier>; (e.g. using std::vector)
let qualId: SyntaxNode | null = null;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child !== null && child.type === 'qualified_identifier') {
qualId = child;
break;
}
}
if (qualId === null) return null;
// Extract the imported name (last identifier) and source (namespace part)
const nameNode = qualId.childForFieldName?.('name') ?? null;
const scopeNode = qualId.childForFieldName?.('scope') ?? null;
const importedName = nameNode?.text ?? qualId.text.split('::').pop() ?? '';
const source = scopeNode?.text ?? qualId.text.replace(new RegExp('::' + importedName + '$'), '');
return {
'@import.statement': nodeToCapture('@import.statement', node),
'@import.kind': syntheticCapture('@import.kind', node, 'named'),
'@import.source': syntheticCapture('@import.source', node, source),
'@import.name': syntheticCapture('@import.name', node, importedName),
};
}

View file

@ -0,0 +1,18 @@
import { resolveCImportTarget } from '../c/import-target.js';
/**
* Resolve a C++ #include path to a file in the workspace.
* C++ #include path resolution is identical to C:
* 1. Same-directory sibling (relative lookup)
* 2. Exact match
* 3. Suffix match with depth + lexicographic tiebreak
*
* Re-exports the C implementation since the #include semantics are shared.
*/
export function resolveCppImportTarget(
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
): string | null {
return resolveCImportTarget(targetRaw, fromFile, allFilePaths);
}

View file

@ -0,0 +1,24 @@
/**
* C++ scope-resolution hooks (RFC #909 Ring 3).
*/
export { emitCppScopeCaptures } from './captures.js';
export {
interpretCppImport,
interpretCppTypeBinding,
normalizeCppTypeName,
} from './interpret.js';
export { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js';
export { cppArityCompatibility } from './arity.js';
export { cppMergeBindings } from './merge-bindings.js';
export {
cppBindingScopeFor,
cppImportOwningScope,
cppReceiverBinding,
} from './simple-hooks.js';
export { resolveCppImportTarget } from './import-target.js';
export {
markFileLocal,
isFileLocal,
clearFileLocalNames,
expandCppWildcardNames,
} from './file-local-linkage.js';

View file

@ -0,0 +1,107 @@
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
/**
* Interpret a C++ import capture into a ParsedImport.
*
* C++ has three import forms:
* 1. #include "file.h" wildcard import (all symbols from header)
* 2. using namespace X; wildcard import (all symbols from namespace X)
* 3. using X::name; named import (single symbol from namespace X)
*
* System headers (#include <...>) are not resolved to local files.
*/
export function interpretCppImport(captures: CaptureMatch): ParsedImport | null {
const source = captures['@import.source']?.text;
if (source === undefined) return null;
// System headers are not resolved to local files
if (captures['@import.system'] !== undefined) return null;
const kind = captures['@import.kind']?.text;
if (kind === 'named') {
// using X::name — named import
const importedName = captures['@import.name']?.text;
if (importedName === undefined) return null;
return { kind: 'named', targetRaw: source, localName: importedName, importedName };
}
// #include or using namespace — wildcard import
return { kind: 'wildcard', targetRaw: source };
}
/**
* Interpret a C++ type-binding capture into a ParsedTypeBinding.
*
* Source classification (strongest weakest):
* - `'parameter-annotation'` function parameter type
* - `'annotation'` explicit type declaration (`User user;`)
* - `'assignment-inferred'` typed init (`User user = ...`)
* - `'constructor'` constructor call (`auto u = User(...)` / `User{}`)
* - `'return'` function return type
* - `'field'` class field type
* - `'alias'` `auto x = existingVar`
*/
export function interpretCppTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const name = captures['@type-binding.name']?.text;
const type = captures['@type-binding.type']?.text;
if (name === undefined || type === undefined) return null;
let source: TypeRef['source'] = 'annotation';
if (captures['@type-binding.parameter'] !== undefined) {
source = 'parameter-annotation';
} else if (captures['@type-binding.constructor'] !== undefined) {
source = 'constructor-inferred';
} else if (captures['@type-binding.return'] !== undefined) {
source = 'return-annotation';
} else if (captures['@type-binding.field'] !== undefined) {
// Field types are structurally equivalent to annotations — the type
// is explicitly written, not inferred.
source = 'annotation';
} else if (captures['@type-binding.member-access'] !== undefined) {
// auto addr = user.address — the type is inferred from the member access.
// Synthesize a dotted rawName ("receiver.field") so compound-receiver
// can resolve the chain: look up receiver's class, then field's type.
const receiver = captures['@type-binding.member-access-receiver']?.text;
if (receiver !== undefined && name !== undefined) {
return { boundName: name, rawTypeName: `${receiver}.${type}`, source: 'assignment-inferred' };
}
source = 'assignment-inferred';
} else if (captures['@type-binding.alias'] !== undefined) {
// auto alias = existingVar — the type is inferred from the RHS variable.
source = 'assignment-inferred';
} else if (captures['@type-binding.assignment'] !== undefined) {
source = 'assignment-inferred';
} else if (captures['@type-binding.annotation'] !== undefined) {
source = 'annotation';
}
return { boundName: name, rawTypeName: normalizeCppTypeName(type), source };
}
/**
* Normalize a C++ type name: strip pointer/array/reference syntax,
* qualifiers, and template parameters (V1: generic-ignored).
*/
export function normalizeCppTypeName(text: string): string {
let t = text.trim();
// Strip const, volatile, restrict, static, extern, inline, mutable, constexpr
t = t
.replace(/\b(const|volatile|restrict|static|extern|inline|mutable|constexpr|consteval)\b/g, '')
.trim();
// Strip template parameters: List<User> → List
t = t.replace(/<[^>]*>/g, '').trim();
// Strip pointer stars
while (t.endsWith('*')) t = t.slice(0, -1).trim();
while (t.startsWith('*')) t = t.slice(1).trim();
// Strip reference markers
while (t.endsWith('&')) t = t.slice(0, -1).trim();
// Strip array brackets
t = t.replace(/\[.*?\]/g, '').trim();
// Strip struct/union/enum/class prefixes
t = t.replace(/^(struct|union|enum|class)\s+/, '');
// Strip leading :: (global namespace qualifier)
t = t.replace(/^::/, '');
return t;
}

View file

@ -0,0 +1,38 @@
import type { BindingRef } from 'gitnexus-shared';
const TIER: Record<BindingRef['origin'], number> = {
local: 0,
namespace: 1,
import: 2,
reexport: 3,
wildcard: 4,
};
/**
* C++ merge bindings: first-wins by tier.
*
* C++ tier precedence:
* local(0) > namespace(1) > import(2) > reexport(3) > wildcard(4)
*
* Unlike C (no namespaces), C++ uses the `namespace` tier for symbols
* brought in via `using namespace X;` that are then locally referenced.
* The tier ordering ensures local definitions shadow namespace imports,
* which in turn shadow wildcard #include imports.
*/
export function cppMergeBindings(
existing: readonly BindingRef[],
incoming: readonly BindingRef[],
_scopeId: string,
): BindingRef[] {
const seen = new Set<string>();
return [...existing, ...incoming]
.sort(
(a, b) =>
(TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId),
)
.filter((binding) => {
if (seen.has(binding.def.nodeId)) return false;
seen.add(binding.def.nodeId);
return true;
});
}

View file

@ -0,0 +1,456 @@
import Parser from 'tree-sitter';
import CPP from 'tree-sitter-cpp';
const CPP_SCOPE_QUERY = `
;; Scopes
(translation_unit) @scope.module
(namespace_definition) @scope.namespace
(class_specifier) @scope.class
(struct_specifier) @scope.class
(function_definition) @scope.function
(lambda_expression) @scope.function
(compound_statement) @scope.block
(if_statement) @scope.block
(for_statement) @scope.block
(for_range_loop) @scope.block
(while_statement) @scope.block
(do_statement) @scope.block
(switch_statement) @scope.block
(case_statement) @scope.block
(try_statement) @scope.block
(catch_clause) @scope.block
;; Declarations namespace
(namespace_definition
name: (namespace_identifier) @declaration.name) @declaration.namespace
;; Anonymous namespace (no name child) captured as scope only, names
;; inside are marked file-local by captures.ts.
;; Declarations class / struct (named)
(class_specifier
name: (type_identifier) @declaration.name
body: (field_declaration_list)) @declaration.class
(struct_specifier
name: (type_identifier) @declaration.name
body: (field_declaration_list)) @declaration.struct
;; Declarations class / struct inside template_declaration
(template_declaration
(class_specifier
name: (type_identifier) @declaration.name
body: (field_declaration_list)) @declaration.class)
(template_declaration
(struct_specifier
name: (type_identifier) @declaration.name
body: (field_declaration_list)) @declaration.struct)
;; Declarations enum
(enum_specifier
name: (type_identifier) @declaration.name) @declaration.enum
;; Declarations enum constants
(enumerator
name: (identifier) @declaration.name) @declaration.const
;; Declarations function definition (plain identifier)
(function_definition
declarator: (function_declarator
declarator: (identifier) @declaration.name)) @declaration.function
;; Declarations function definition with pointer return
(function_definition
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (identifier) @declaration.name))) @declaration.function
;; Declarations out-of-class method (qualified_identifier)
(function_definition
declarator: (function_declarator
declarator: (qualified_identifier
name: (identifier) @declaration.name))) @declaration.method
;; Declarations out-of-class method with pointer return
(function_definition
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (qualified_identifier
name: (identifier) @declaration.name)))) @declaration.method
;; Declarations out-of-class method (destructor_name)
(function_definition
declarator: (function_declarator
declarator: (qualified_identifier
name: (destructor_name) @declaration.name))) @declaration.method
;; Declarations template function definition
(template_declaration
(function_definition
declarator: (function_declarator
declarator: (identifier) @declaration.name)) @declaration.function)
;; Declarations template method (qualified)
(template_declaration
(function_definition
declarator: (function_declarator
declarator: (qualified_identifier
name: (identifier) @declaration.name))) @declaration.method)
;; Declarations inline method in class body (field_identifier)
;; tree-sitter-cpp uses field_identifier for names inside class bodies
(function_definition
declarator: (function_declarator
declarator: (field_identifier) @declaration.name)) @declaration.method
;; Declarations inline method with pointer return (field_identifier)
;; Covers: User* lookup(int id) { ... } inside a class body
;; AST: function_definition > pointer_declarator > function_declarator > field_identifier
(function_definition
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (field_identifier) @declaration.name))) @declaration.method
;; Declarations inline method with reference return (field_identifier)
;; Covers: User& getRef() { ... } inside a class body
(function_definition
declarator: (reference_declarator
(function_declarator
declarator: (field_identifier) @declaration.name))) @declaration.method
;; Declarations function prototype (forward declaration)
(declaration
declarator: (function_declarator
declarator: (identifier) @declaration.name)) @declaration.function
;; Declarations function prototype with pointer return
(declaration
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (identifier) @declaration.name))) @declaration.function
;; Declarations typedef
(type_definition
declarator: (type_identifier) @declaration.name) @declaration.typedef
;; Declarations type alias (using Name = Type)
(alias_declaration
name: (type_identifier) @declaration.name) @declaration.typedef
;; Declarations method prototype in class body (forward decl)
;; Covers: class User { void save(); std::string getName(); };
;; AST: field_declaration > function_declarator > field_identifier
(field_declaration
declarator: (function_declarator
declarator: (field_identifier) @declaration.name)) @declaration.method
;; Method prototype with pointer return: User* lookup(int id);
(field_declaration
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (field_identifier) @declaration.name))) @declaration.method
;; Method prototype with reference return: User& getRef();
(field_declaration
declarator: (reference_declarator
(function_declarator
declarator: (field_identifier) @declaration.name))) @declaration.method
;; Declarations fields
(field_declaration
declarator: (field_identifier) @declaration.name) @declaration.field
;; Declarations fields (pointer)
(field_declaration
declarator: (pointer_declarator
declarator: (field_identifier) @declaration.name)) @declaration.field
;; Declarations fields (reference)
(field_declaration
declarator: (reference_declarator
(field_identifier) @declaration.name)) @declaration.field
;; Declarations variables (with initializer)
(declaration
declarator: (init_declarator
declarator: (identifier) @declaration.name)) @declaration.variable
;; Declarations macro definitions
(preproc_def
name: (identifier) @declaration.name) @declaration.macro
(preproc_function_def
name: (identifier) @declaration.name) @declaration.macro
;; Imports #include
(preproc_include) @import.statement
;; Imports using declaration
;; Both "using namespace std;" and "using std::vector;" are
;; using_declaration nodes in tree-sitter-cpp. The captures.ts
;; differentiates between them by checking for a "namespace" anonymous
;; child token.
(using_declaration) @import.using-decl
;; Type bindings parameter annotations
(parameter_declaration
type: (_) @type-binding.type
declarator: (identifier) @type-binding.name) @type-binding.parameter
;; Type bindings reference parameter (const std::string& name)
(parameter_declaration
type: (_) @type-binding.type
declarator: (reference_declarator
(identifier) @type-binding.name)) @type-binding.parameter
;; Type bindings pointer parameter (User* ptr)
(parameter_declaration
type: (_) @type-binding.type
declarator: (pointer_declarator
declarator: (identifier) @type-binding.name)) @type-binding.parameter
;; Type bindings variable with type (init_declarator)
;; Covers: User user("alice"), User user = ..., int x = 0
(declaration
type: (_) @type-binding.type
declarator: (init_declarator
declarator: (identifier) @type-binding.name)) @type-binding.assignment
;; Type bindings plain declaration (no initializer)
;; Covers: User user;
(declaration
type: (type_identifier) @type-binding.type
declarator: (identifier) @type-binding.name) @type-binding.annotation
;; Type bindings pointer variable declaration
;; Covers: User* ptr = new User()
(declaration
type: (type_identifier) @type-binding.type
declarator: (init_declarator
declarator: (pointer_declarator
declarator: (identifier) @type-binding.name))) @type-binding.annotation
;; Type bindings auto + constructor call
;; Covers: auto user = User("alice")
;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + call_expression > identifier
(declaration
type: (placeholder_type_specifier)
declarator: (init_declarator
declarator: (identifier) @type-binding.name
value: (call_expression
function: (identifier) @type-binding.type))) @type-binding.constructor
;; Type bindings auto + brace-init (compound_literal_expression)
;; Covers: auto user = User{}, auto user = User{args}
;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + compound_literal_expression > type_identifier
(declaration
type: (placeholder_type_specifier)
declarator: (init_declarator
declarator: (identifier) @type-binding.name
value: (compound_literal_expression
type: (type_identifier) @type-binding.type))) @type-binding.constructor
;; Type bindings auto + scoped brace-init (qualified)
;; Covers: auto client = ns::HttpClient{}
;; AST: compound_literal_expression > qualified_identifier > type_identifier
(declaration
type: (placeholder_type_specifier)
declarator: (init_declarator
declarator: (identifier) @type-binding.name
value: (compound_literal_expression
type: (qualified_identifier
name: (type_identifier) @type-binding.type)))) @type-binding.constructor
;; Type bindings auto + new expression
;; Covers: auto user = new User(name)
;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + new_expression > type_identifier
(declaration
type: (placeholder_type_specifier)
declarator: (init_declarator
declarator: (identifier) @type-binding.name
value: (new_expression
type: (type_identifier) @type-binding.type))) @type-binding.constructor
;; Type bindings auto + qualified template factory (std::make_shared<Dog>())
;; AST: declaration(1 > placeholder_type_specifier(2)2 > init_declarator(3 >
;; identifier(4)4 > call_expression(5 > qualified_identifier(6 >
;; template_function(7 > template_argument_list(8 > type_descriptor(9 >
;; type_identifier(10)10 )9 )8 )7 )6 )5 )3 )1
(declaration
type: (placeholder_type_specifier)
declarator: (init_declarator
declarator: (identifier) @type-binding.name
value: (call_expression
function: (qualified_identifier
name: (template_function
arguments: (template_argument_list
(type_descriptor
type: (type_identifier) @type-binding.type))))))) @type-binding.constructor
;; Type bindings auto + bare template factory (make_shared<Dog>())
;; Same but without qualified_identifier wrapper one fewer nesting level
(declaration
type: (placeholder_type_specifier)
declarator: (init_declarator
declarator: (identifier) @type-binding.name
value: (call_expression
function: (template_function
arguments: (template_argument_list
(type_descriptor
type: (type_identifier) @type-binding.type)))))) @type-binding.constructor
;; Type bindings auto alias assignment
;; Covers: auto alias = existingVar (RHS is a plain identifier)
;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + identifier
(declaration
type: (placeholder_type_specifier)
declarator: (init_declarator
declarator: (identifier) @type-binding.name
value: (identifier) @type-binding.type)) @type-binding.alias
;; Type bindings auto + member access (field_expression)
;; Covers: auto addr = user.address (RHS is obj.field)
;; AST: declaration > placeholder_type_specifier > init_declarator > identifier + field_expression
;; We capture the field name as @type-binding.type so the compound-receiver
;; chain resolver can look it up on the receiver class scope.
;; The full obj.field text is synthesized by interpret.ts into a dotted
;; rawName for chain-follow resolution.
(declaration
type: (placeholder_type_specifier)
declarator: (init_declarator
declarator: (identifier) @type-binding.name
value: (field_expression
argument: (_) @type-binding.member-access-receiver
field: (field_identifier) @type-binding.type))) @type-binding.member-access
;; Type bindings function return type
;; Covers: User getUser() { ... }
;; AST: function_definition > type_identifier + function_declarator > identifier
(function_definition
type: (type_identifier) @type-binding.type
declarator: (function_declarator
declarator: (identifier) @type-binding.name)) @type-binding.return
;; Return type out-of-class method: User Class::getUser() { ... }
(function_definition
type: (type_identifier) @type-binding.type
declarator: (function_declarator
declarator: (qualified_identifier
name: (identifier) @type-binding.name))) @type-binding.return
;; Return type pointer return: User* getUser() { ... }
(function_definition
type: (type_identifier) @type-binding.type
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (identifier) @type-binding.name))) @type-binding.return
;; Type bindings inline method return type
;; Covers: class Foo { User getUser() { ... } };
(function_definition
type: (type_identifier) @type-binding.type
declarator: (function_declarator
declarator: (field_identifier) @type-binding.name)) @type-binding.return
;; Inline method pointer return type: class Foo { User* lookup(int) { ... } };
(function_definition
type: (type_identifier) @type-binding.type
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (field_identifier) @type-binding.name))) @type-binding.return
;; Type bindings method prototype return type in class body
;; Covers: class User { User* lookup(int); std::string getName(); };
;; AST: field_declaration > function_declarator > field_identifier
(field_declaration
type: (type_identifier) @type-binding.type
declarator: (function_declarator
declarator: (field_identifier) @type-binding.name)) @type-binding.return
;; Method prototype pointer return type: User* lookup(int id);
(field_declaration
type: (type_identifier) @type-binding.type
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (field_identifier) @type-binding.name))) @type-binding.return
;; Type bindings field type declarations (class members)
;; Covers: class User { Address address; };
(field_declaration
type: (type_identifier) @type-binding.type
declarator: (field_identifier) @type-binding.name) @type-binding.field
;; Field pointer type: Address* address;
(field_declaration
type: (type_identifier) @type-binding.type
declarator: (pointer_declarator
declarator: (field_identifier) @type-binding.name)) @type-binding.field
;; Field reference type: Address& address;
(field_declaration
type: (type_identifier) @type-binding.type
declarator: (reference_declarator
(field_identifier) @type-binding.name)) @type-binding.field
;; References constructor calls (new Foo())
(new_expression
type: (type_identifier) @reference.name) @reference.call.constructor
;; Constructor call with qualified type: new ns::Foo()
(new_expression
type: (qualified_identifier
name: (type_identifier) @reference.name)) @reference.call.constructor
;; References free calls
(call_expression
function: (identifier) @reference.name) @reference.call.free
;; References qualified calls (Namespace::func())
(call_expression
function: (qualified_identifier
name: (identifier) @reference.name)) @reference.call.qualified
;; References member calls (obj.method() / ptr->method())
(call_expression
function: (field_expression
argument: (_) @reference.receiver
field: (field_identifier) @reference.name)) @reference.call.member
;; References template calls (func<T>())
(call_expression
function: (template_function
name: (identifier) @reference.name)) @reference.call.free
;; Note: Ns::func<T>() is parsed as qualified_identifier by tree-sitter-cpp,
;; already captured by the qualified calls pattern above.
;; References field reads
(field_expression
argument: (_) @reference.receiver
field: (field_identifier) @reference.name) @reference.read
;; References field writes (assignment)
(assignment_expression
left: (field_expression
argument: (_) @reference.receiver
field: (field_identifier) @reference.name)) @reference.write
`;
let _parser: Parser | null = null;
let _query: Parser.Query | null = null;
export function getCppParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(CPP as Parameters<Parser['setLanguage']>[0]);
}
return _parser;
}
export function getCppScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(CPP as Parameters<Parser['setLanguage']>[0], CPP_SCOPE_QUERY);
}
return _query;
}

View file

@ -0,0 +1,255 @@
import type { ParsedFile, Scope, TypeRef } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { getCppParser } from './query.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
/**
* Populate range-for loop variable type bindings for C++.
*
* Handles three patterns:
* 1. `for (auto& user : users)` simple range-for
* 2. `for (auto& [key, user] : userMap)` structured binding
* 3. `for (auto& user : *usersPtr)` dereference range-for
*
* Strategy: look up the range source variable's type in scope
* typeBindings, extract the last template argument as the element
* type, and inject a typeBinding for the loop variable.
*/
export function populateCppRangeBindings(
parsedFiles: readonly ParsedFile[],
_indexes: ScopeResolutionIndexes,
ctx: {
readonly fileContents: ReadonlyMap<string, string>;
readonly treeCache?: { get(filePath: string): unknown };
},
): void {
const parser = getCppParser();
for (const parsed of parsedFiles) {
const sourceText = ctx.fileContents.get(parsed.filePath);
if (sourceText === undefined) continue;
const cachedTree = ctx.treeCache?.get(parsed.filePath);
const tree =
(cachedTree as ReturnType<typeof parser.parse> | undefined) ??
parseSourceSafe(parser, sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
if (moduleScope === undefined) continue;
const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s]));
// Build a map from parameter name → AST parameter_declaration node
// so we can extract the un-normalized template type from the AST.
const paramTypeMap = buildParamTemplateMap(tree.rootNode);
for (const rangeNode of tree.rootNode.descendantsOfType('for_range_loop')) {
// Get the declarator (loop variable)
const declarator = rangeNode.childForFieldName('declarator');
if (declarator === null) continue;
// Get the range source expression (right side of ':')
const right = rangeNode.childForFieldName('right');
if (right === null) continue;
// Determine the loop variable name(s) and whether this is a structured binding
const varNames = extractLoopVarNames(declarator);
if (varNames.length === 0) continue;
// Determine the range source variable name (handle dereference)
const sourceVarName = extractSourceVarName(right);
if (sourceVarName === null) continue;
// Look up the source variable's full template type from the AST
// (scope typeBindings have been normalized and lost template params)
const fullType = paramTypeMap.get(sourceVarName);
if (fullType === undefined) continue;
// Extract element type from the container type
const elementType = extractCppElementType(fullType);
if (elementType === null) continue;
// Find the enclosing function scope
const functionScope = findEnclosingFunctionScope(rangeNode, scopeMap);
const targetScope = functionScope ?? moduleScope;
const mutable = targetScope.typeBindings as Map<string, TypeRef>;
// For structured binding [key, user], bind the last identifier to the element type
// For simple range-for, bind the single variable
const bindVar = varNames[varNames.length - 1];
mutable.set(bindVar, {
rawName: elementType,
declaredAtScope: targetScope.id,
source: 'annotation',
});
}
}
}
/** Minimal tree-sitter node shape needed by range-binding helpers. */
interface TsNode {
readonly type: string;
readonly text: string;
readonly childCount: number;
child(index: number): TsNode | null;
descendantsOfType(type: string): readonly TsNode[];
childForFieldName(name: string): TsNode | null;
}
/**
* Build a map from parameter name full (un-normalized) type text
* by walking the AST for all `parameter_declaration` nodes.
*
* This bypasses `normalizeCppTypeName` which strips template params,
* giving us the raw `std::vector<User>` text needed for element-type
* extraction.
*/
function buildParamTemplateMap(rootNode: TsNode): Map<string, string> {
const map = new Map<string, string>();
for (const paramNode of rootNode.descendantsOfType('parameter_declaration')) {
const typeNode = paramNode.childForFieldName('type');
if (typeNode === null) continue;
// Extract the parameter name from the declarator subtree.
// The declarator may be: identifier, reference_declarator > identifier,
// or pointer_declarator > identifier.
const declNode = paramNode.childForFieldName('declarator');
if (declNode === null) continue;
const idents = declNode.descendantsOfType('identifier');
if (idents.length === 0) continue;
const paramName = idents[idents.length - 1].text;
// Use the full type node text (preserving template params)
map.set(paramName, typeNode.text);
}
return map;
}
/**
* Extract loop variable name(s) from the declarator node.
* Handles both simple `identifier` and `structured_binding_declarator`.
*/
function extractLoopVarNames(declarator: TsNode): string[] {
// The declarator is typically reference_declarator or pointer_declarator wrapping
// either an identifier or a structured_binding_declarator.
const structBindings = declarator.descendantsOfType('structured_binding_declarator');
if (structBindings.length > 0) {
// structured_binding_declarator contains identifiers like [key, user]
const idents = structBindings[0].descendantsOfType('identifier');
return idents.map((id) => id.text).filter((t) => t !== '_');
}
// Simple case: reference_declarator > identifier or just identifier
const idents = declarator.descendantsOfType('identifier');
if (idents.length > 0) {
return [idents[idents.length - 1].text];
}
return [];
}
/**
* Extract the source variable name from the range expression.
* Handles plain identifiers and dereference expressions (*ptr).
*/
function extractSourceVarName(right: TsNode): string | null {
if (right.type === 'identifier') {
return right.text;
}
if (right.type === 'pointer_expression') {
// *usersPtr → get the argument (usersPtr)
const arg = right.childForFieldName('argument');
if (arg !== null) return arg.text;
}
return null;
}
/**
* Extract the element type from a C++ container type string.
*
* Examples:
* - `vector<User>` `User`
* - `std::vector<User>` `User`
* - `map<std::string, User>` `User` (last template arg)
* - `map<string, User>` `User`
*
* For structured bindings with maps, the last template arg is the value type.
* For vectors/sets, the first (and only) template arg is the element type.
*/
function extractCppElementType(rawType: string): string | null {
// Find the outermost template argument list
const ltIdx = rawType.indexOf('<');
if (ltIdx === -1) return null;
// Extract the template argument string (handle nested templates)
let depth = 0;
let lastCommaOrStart = ltIdx + 1;
let lastArg = '';
for (let i = ltIdx; i < rawType.length; i++) {
const ch = rawType[i];
if (ch === '<') {
depth++;
} else if (ch === '>') {
depth--;
if (depth === 0) {
lastArg = rawType.slice(lastCommaOrStart, i).trim();
break;
}
} else if (ch === ',' && depth === 1) {
lastCommaOrStart = i + 1;
}
}
if (lastArg === '') return null;
// Strip pointer/reference qualifiers and const
let elementType = lastArg
.replace(/^const\s+/, '')
.replace(/\s*[*&]+\s*$/, '')
.trim();
// Strip namespace prefix (std::string → string)
const lastColon = elementType.lastIndexOf('::');
if (lastColon !== -1) {
elementType = elementType.slice(lastColon + 2);
}
return elementType || null;
}
/**
* Find the enclosing Function scope for a tree-sitter node by
* walking up the AST and matching source positions.
*/
function findEnclosingFunctionScope(
node: unknown,
scopeMap: ReadonlyMap<string, Scope>,
): Scope | null {
const tsNode = node as {
readonly parent: unknown;
readonly type: string;
readonly startPosition: { readonly row: number; readonly column: number };
};
let current: typeof tsNode | null = tsNode;
while (current !== null) {
if (current.type === 'function_definition') {
for (const scope of scopeMap.values()) {
if (
scope.kind === 'Function' &&
scope.range.startLine === current.startPosition.row &&
scope.range.startCol === current.startPosition.column
) {
return scope;
}
}
break;
}
current = (current.parent as typeof tsNode) ?? null;
}
return null;
}

View file

@ -0,0 +1,89 @@
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { cppProvider } from '../c-cpp.js';
import { cppArityCompatibility } from './arity.js';
import { cppMergeBindings } from './merge-bindings.js';
import { resolveCppImportTarget } from './import-target.js';
import { scanCppHeaderFiles } from './header-scan.js';
import { expandCppWildcardNames, isFileLocal, clearFileLocalNames } from './file-local-linkage.js';
import { populateCppRangeBindings } from './range-bindings.js';
/**
* C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
* the generic `runScopeResolution` orchestrator (RFC #909 Ring 3).
*
* C++ extends C's scope resolution with:
* - Namespaces (`namespace foo { ... }`)
* - Classes with methods and multiple inheritance
* - `using namespace` (wildcard import from namespace)
* - `using X::name` (named import from namespace)
* - Anonymous namespace (file-local linkage, like C `static`)
* - Default parameters (requiredParameterCount < parameterCount)
* - Overloading (arity-based disambiguation)
* - Templates (V1: generic-ignored, `List<User>` `List`)
* - Leftmost-base MRO for multiple inheritance
*/
export const cppScopeResolver: ScopeResolver = {
language: SupportedLanguages.CPlusPlus,
languageProvider: cppProvider,
importEdgeReason: 'cpp-scope: include',
loadResolutionConfig: (repoPath: string) => {
// Clear stale file-local-linkage data from any previous invocation.
clearFileLocalNames();
return scanCppHeaderFiles(repoPath);
},
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
// Augment allFilePaths with header files discovered via loadResolutionConfig.
// C++ .h/.hpp/.hxx/.hh files may be classified differently by language
// detection but are importable from .cpp files via #include.
const headerPaths = resolutionConfig as ReadonlySet<string> | undefined;
if (headerPaths !== undefined && headerPaths.size > 0) {
const augmented = new Set(allFilePaths);
for (const h of headerPaths) augmented.add(h);
return resolveCppImportTarget(targetRaw, fromFile, augmented);
}
return resolveCppImportTarget(targetRaw, fromFile, allFilePaths);
},
expandsWildcardTo: (targetModuleScope, parsedFiles) =>
expandCppWildcardNames(targetModuleScope, parsedFiles),
mergeBindings: (existing, incoming, scopeId) => cppMergeBindings(existing, incoming, scopeId),
// Adapter: cppArityCompatibility predates ScopeResolver and uses
// (def, callsite). ScopeResolver contract is (callsite, def).
arityCompatibility: (callsite, def) => cppArityCompatibility(def, callsite),
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
isSuperReceiver: (text) =>
// C++ super patterns: explicit base class call `Base::method()`
/^[A-Z]\w*::/.test(text),
// C++ is statically typed — disable field fallback heuristic
fieldFallbackOnMethodLookup: false,
// C++ needs return type propagation across #include boundaries
propagatesReturnTypesAcrossImports: true,
// C++ #include brings in all symbols — enable global free call fallback
allowGlobalFreeCallFallback: true,
// Range-for element type inference: for (auto& user : users) → bind user to User
populateRangeBindings: populateCppRangeBindings,
// C++ method return-type bindings need to be visible from module scope
// for cross-file propagation and compound-receiver chain resolution.
// cppBindingScopeFor hoists @type-binding.return to Module scope.
hoistTypeBindingsToModule: true,
// C++ `static` functions and anonymous namespace symbols have file-local
// linkage — exclude them from global free-call fallback cross-file resolution.
isFileLocalDef: (def: SymbolDefinition) => {
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
return isFileLocal(def.filePath, simple);
},
};

View file

@ -0,0 +1,79 @@
import type {
CaptureMatch,
ParsedImport,
Scope,
ScopeId,
ScopeTree,
TypeRef,
} from 'gitnexus-shared';
/**
* C++ binding scope: default auto-hoist (null) for most declarations.
*
* For `for` statement init-scope variables (e.g. `for (int i = 0; ...)`),
* the variable is scoped to the for-block, not the enclosing function.
* The tree-sitter scope query already captures for_statement as @scope.block,
* so tree-sitter's scope nesting handles this automatically we return null
* to let the default auto-hoist apply.
*/
export function cppBindingScopeFor(
decl: CaptureMatch,
innermost: Scope,
tree: ScopeTree,
): ScopeId | null {
// Hoist return-type bindings to Module scope so:
// 1. propagateImportedReturnTypes can mirror them across files
// 2. compound-receiver can find method return types via hoistTypeBindingsToModule
if (decl['@type-binding.return'] !== undefined) {
let cur: Scope | undefined = innermost;
while (cur !== undefined && cur.kind !== 'Module') {
const parentId: ScopeId | null = cur.parent ?? null;
if (parentId === null) break;
cur = tree.getScope(parentId);
}
if (cur !== undefined && cur.kind === 'Module') return cur.id;
}
return null; // default auto-hoist for other bindings
}
/**
* C++ import owning scope: default (null).
* #include and using declarations are file-scoped in C++.
*/
export function cppImportOwningScope(
_imp: ParsedImport,
_innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
return null;
}
/**
* C++ receiver binding: return `this` TypeRef for methods inside a class.
*
* When a function scope is inside a class scope, the implicit `this` pointer
* refers to the enclosing class. This enables `this->method()` and implicit
* `this` member access resolution.
*/
export function cppReceiverBinding(functionScope: Scope): TypeRef | null {
// Walk up the scope tree to find an enclosing class scope
if (functionScope.parent === null) return null;
// The scope tree structure nests function scopes inside class scopes.
// The orchestrator provides the function scope; we need to check if
// its parent chain contains a class scope.
//
// However, the ScopeResolver.receiverBinding contract receives only
// the function Scope (not the full ScopeTree), and the Scope type
// includes `parent` (a ScopeId) but not a reference to the parent
// Scope object.
//
// The orchestrator already handles this by looking up the class owner
// via populateOwners. We return null here and let the shared infra
// handle receiver resolution through the class-ownership mechanism.
//
// This is consistent with how C# and Go handle it — the receiver
// binding is established through populateOwners + the MRO chain,
// not through this hook.
return null;
}

View file

@ -72,6 +72,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
SupportedLanguages.TypeScript,
SupportedLanguages.Go,
SupportedLanguages.C,
SupportedLanguages.CPlusPlus,
]);
/**

View file

@ -32,7 +32,7 @@ import {
/** Max depth for compound-receiver chain resolution (`a().b().c().d()`).
* Practical code rarely exceeds 3-4 hops; the cap prevents
* pathological recursion if the receiver text is malformed. */
const COMPOUND_RECEIVER_MAX_DEPTH = 4;
const COMPOUND_RECEIVER_MAX_DEPTH = 6;
const MAP_TUPLE_SENTINEL_RE = /^__MAP_TUPLE_(\d+)__:(.+)$/;

View file

@ -151,7 +151,12 @@ export function propagateImportedReturnTypes(
const refs = lookupBindingsAt(importerModule.id, localName, indexes);
for (const ref of refs) {
if (ref.origin !== 'import' && ref.origin !== 'reexport') continue;
if (
ref.origin !== 'import' &&
ref.origin !== 'reexport' &&
ref.origin !== 'wildcard'
)
continue;
const sourceModule = moduleScopeByFile.get(ref.def.filePath);
if (sourceModule === undefined) continue;

View file

@ -16,6 +16,7 @@ import { csharpScopeResolver } from '../../languages/csharp/scope-resolver.js';
import { typescriptScopeResolver } from '../../languages/typescript/scope-resolver.js';
import { goScopeResolver } from '../../languages/go/scope-resolver.js';
import { cScopeResolver } from '../../languages/c/scope-resolver.js';
import { cppScopeResolver } from '../../languages/cpp/scope-resolver.js';
/** Map of `SupportedLanguages` `ScopeResolver`. The phase iterates
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
@ -30,4 +31,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.TypeScript, typescriptScopeResolver],
[SupportedLanguages.Go, goScopeResolver],
[SupportedLanguages.C, cScopeResolver],
[SupportedLanguages.CPlusPlus, cppScopeResolver],
]);

View file

@ -181,6 +181,8 @@ export interface ExtractedAssignment {
propertyName: string;
/** Resolved type name of the receiver if available from TypeEnv */
receiverTypeName?: string;
/** 1-indexed line number of the assignment site (used for per-site dedup) */
line?: number;
}
// `ExtractedHeritage` now lives in `../model/heritage-map.ts` and is
@ -1580,6 +1582,7 @@ const processFileGroup = (
sourceId: srcId,
receiverText,
propertyName,
line: captureMap['assignment'].startPosition.row + 1,
...(receiverTypeName ? { receiverTypeName } : {}),
});
}

View file

@ -1,7 +1,7 @@
/**
* C++: diamond inheritance + include-based imports + ambiguous #include disambiguation
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { describe, it as _it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES,
@ -11,9 +11,12 @@ import {
getNodesByLabelFull,
edgeSet,
runPipelineFromRepo,
createResolverParityIt,
type PipelineResult,
} from './helpers.js';
const it = createResolverParityIt('cpp');
// ---------------------------------------------------------------------------
// Heritage: diamond inheritance + include-based imports
// ---------------------------------------------------------------------------
@ -937,7 +940,7 @@ describe('Write access tracking (C++)', () => {
it('emits ACCESSES write edges for field assignments', () => {
const accesses = getRelationships(result, 'ACCESSES');
const writes = accesses.filter((e) => e.rel.reason === 'write');
expect(writes.length).toBe(2);
expect(writes.length).toBe(3);
const fieldNames = writes.map((e) => e.target);
expect(fieldNames).toContain('name');
expect(fieldNames).toContain('address');

View file

@ -44,6 +44,7 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
'binds the call to alpha/services/sync.py, not omega',
'lex tiebreak still picks alpha/services/sync.py with reversed file-write order',
]),
cpp: new Set<string>([]),
};
type ResolverParityEnv = Readonly<Record<string, string | undefined>>;

View file

@ -1,15 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
const { parseSourceSafeSpy } = vi.hoisted(() => ({ parseSourceSafeSpy: vi.fn() }));
vi.mock('../../../src/core/tree-sitter/safe-parse.js', async () => {
const { buildSafeParseMock } = await import('../../helpers/parse-source-safe-mock.js');
return buildSafeParseMock(parseSourceSafeSpy);
});
import { IncludeExtractor } from '../../../src/core/group/extractors/include-extractor.js';
import type { RepoHandle } from '../../../src/core/group/types.js';
import { normalizeContractId } from '../../../src/core/group/matching.js';
@ -19,7 +11,8 @@ describe('IncludeExtractor', () => {
let extractor: IncludeExtractor;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-include-'));
tmpDir = path.join(os.tmpdir(), `gitnexus-include-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
extractor = new IncludeExtractor();
});
@ -52,7 +45,10 @@ describe('IncludeExtractor', () => {
expect(providers).toHaveLength(2);
const ids = providers.map((p) => p.contractId).sort();
expect(ids).toEqual(['include::map/base/types.h', 'include::map/base/view.h']);
expect(ids).toEqual([
'include::map/base/types.h',
'include::map/base/view.h',
]);
expect(providers[0].type).toBe('include');
expect(providers[0].confidence).toBeGreaterThanOrEqual(0.95);
});
@ -95,7 +91,10 @@ int main() { return 0; }`,
expect(consumers).toHaveLength(2);
const ids = consumers.map((c) => c.contractId).sort();
expect(ids).toEqual(['include::map/base/types.h', 'include::map/base/view.h']);
expect(ids).toEqual([
'include::map/base/types.h',
'include::map/base/view.h',
]);
expect(consumers[0].type).toBe('include');
expect(consumers[0].confidence).toBe(0.85);
});
@ -171,20 +170,33 @@ int main() { return 0; }`,
describe('cross-repo matching', () => {
it('provider and consumer produce matching contractIds', async () => {
// Simulate provider repo (header-only)
const providerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-include-provider-'));
const providerDir = path.join(os.tmpdir(), `gitnexus-include-provider-${Date.now()}`);
fs.mkdirSync(providerDir, { recursive: true });
const providerFile = path.join(providerDir, 'map/base/dice_map_view.h');
fs.mkdirSync(path.dirname(providerFile), { recursive: true });
fs.writeFileSync(providerFile, '#pragma once\nclass DiceMapView {};');
// Simulate consumer repo
const consumerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-include-consumer-'));
const consumerDir = path.join(os.tmpdir(), `gitnexus-include-consumer-${Date.now()}`);
fs.mkdirSync(consumerDir, { recursive: true });
const consumerFile = path.join(consumerDir, 'src/controller.cpp');
fs.mkdirSync(path.dirname(consumerFile), { recursive: true });
fs.writeFileSync(consumerFile, '#include "map/base/dice_map_view.h"\nvoid init() {}');
fs.writeFileSync(
consumerFile,
'#include "map/base/dice_map_view.h"\nvoid init() {}',
);
try {
const providerContracts = await extractor.extract(null, providerDir, makeRepo(providerDir));
const consumerContracts = await extractor.extract(null, consumerDir, makeRepo(consumerDir));
const providerContracts = await extractor.extract(
null,
providerDir,
makeRepo(providerDir),
);
const consumerContracts = await extractor.extract(
null,
consumerDir,
makeRepo(consumerDir),
);
const providers = providerContracts.filter((c) => c.role === 'provider');
const consumers = consumerContracts.filter((c) => c.role === 'consumer');
@ -204,138 +216,18 @@ int main() { return 0; }`,
});
});
// ---- Review finding #4: suffixResolve ambiguity ----
describe('finding #4: suffix-ambiguity does not silently suppress cross-repo include', () => {
it('emits a cross-repo contract when the include path does not match any local file (even if a shorter suffix does)', async () => {
// local repo has `internal/api.h` but NOT `ext/api.h`
writeFile('internal/api.h', '#pragma once');
writeFile(
'src/main.cpp',
`#include "ext/api.h"
int main() { return 0; }`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
// Previously suffixResolve would match `api.h` against `internal/api.h`
// and drop the cross-repo contract. After finding #4 fix, we only
// accept exact full-path matches — so `ext/api.h` must still be
// emitted as a consumer contract.
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('include::ext/api.h');
});
it('still suppresses a local include when the FULL path matches', async () => {
writeFile('ext/api.h', '#pragma once');
writeFile('src/main.cpp', '#include "ext/api.h"\nint main(){return 0;}');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(0);
});
it('resolves locally when include omits extension and a matching .h exists', async () => {
writeFile('foo/bar.h', '#pragma once');
writeFile('src/main.cpp', '#include "foo/bar"\nint main(){return 0;}');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(0);
});
});
// ---- Review finding #5: regex fallback must strip block comments ----
describe('finding #5: regex fallback ignores block-commented includes', () => {
it('does not emit a contract for an #include inside /* ... */', async () => {
// Force regex fallback by producing a file larger than tree-sitter's
// 32 KB hard cap. The include we care about lives inside a block
// comment that spans the file.
const filler = 'int dummy_' + 'x'.repeat(32) + ' = 0;\n'.repeat(1200);
const content = `/*
* Historical include, kept for reference only:
* #include "legacy/old-api.h"
*/
${filler}
#include "real/api.h"
int main(){return 0;}`;
writeFile('src/huge.cpp', content);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const ids = consumers.map((c) => c.contractId);
// The live include should appear; the commented-out one must NOT.
expect(ids).toContain('include::real/api.h');
expect(ids).not.toContain('include::legacy/old-api.h');
});
});
// ---- Review finding #6: meta.source must reflect which extraction path ran ----
describe('finding #6: meta.source reflects extraction path', () => {
it('stamps `tree_sitter` on contracts produced via AST walking', async () => {
writeFile('src/main.cpp', '#include "app/small.h"\nint main(){return 0;}');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect((consumers[0].meta as { source?: string } | undefined)?.source).toBe('tree_sitter');
});
it('meta.source is one of the two documented values (tree_sitter | regex_fallback)', async () => {
// Regex fallback is a defensive branch that only fires if
// parser.setLanguage() or parser.parse() throws. In practice
// tree-sitter-c/cpp handles realistic inputs, so we only assert
// the meta.source contract: it is always present and always one of
// the two documented values. This guards against future regressions
// that might hard-code the wrong string.
writeFile('src/main.cpp', '#include "ext/whatever.h"\nint main(){return 0;}');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumer = contracts.find((c) => c.role === 'consumer');
expect(consumer).toBeDefined();
const src = (consumer?.meta as { source?: string } | undefined)?.source;
expect(['tree_sitter', 'regex_fallback']).toContain(src);
});
});
// ---- Review finding #3: provider id collision on case-sensitive FS ----
describe('finding #3: case-folding is documented and deterministic', () => {
it('collapses `Foo.h` and `foo.h` onto the same provider contract-id (documented trade-off)', async () => {
writeFile('Foo.h', '#pragma once\n// Capital Foo');
// On case-insensitive filesystems (macOS default) the second writeFile
// will overwrite the first, so we only create this when distinct files
// can coexist (case-sensitive FS, e.g. Linux CI).
try {
fs.writeFileSync(path.join(tmpDir, 'foo.h'), '#pragma once\n// lowercase foo');
} catch {
// Ignore — some FS won't allow both names to coexist.
}
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter((c) => c.role === 'provider');
const ids = providers.map((p) => p.contractId);
// Both files (if they coexist) must normalize to the same id.
// dedupe() keeps only one; caller code must be aware of this.
expect(ids).toContain('include::foo.h');
// Never see a mixed-case contract-id leak out.
expect(ids.every((id) => id === id.toLowerCase())).toBe(true);
});
});
// ---- Deduplication ----
describe('deduplication', () => {
it('deduplicates same include from multiple source files', async () => {
writeFile('src/a.cpp', '#include "ext/api.h"\nvoid a() {}');
writeFile('src/b.cpp', '#include "ext/api.h"\nvoid b() {}');
writeFile(
'src/a.cpp',
'#include "ext/api.h"\nvoid a() {}',
);
writeFile(
'src/b.cpp',
'#include "ext/api.h"\nvoid b() {}',
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
@ -367,236 +259,4 @@ int main(){return 0;}`;
expect(normalizeContractId('include::map//base///foo.h')).toBe('include::map/base/foo.h');
});
});
// ---- PR #1156 follow-up: `../` relative includes ----
describe('follow-up: `../` relative includes are skipped', () => {
it('does not emit a consumer contract for `#include "../foo.h"`', async () => {
// Producer: a header that exists locally but only via parent reference
writeFile('include/foo.h', '#pragma once');
writeFile(
'src/sub/main.cpp',
`#include "../../include/foo.h"
#include "real/cross_repo.h"
int main() { return 0; }`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
// Only `real/cross_repo.h` should remain — the `..`-prefixed include
// is intra-repo noise that no provider can ever satisfy.
expect(consumers.map((c) => c.contractId)).toEqual(['include::real/cross_repo.h']);
});
it('skips backslash-form `..\\` for completeness', async () => {
writeFile(
'src/main.cpp',
`#include "..\\\\sibling\\\\foo.h"
#include "remote/header.h"
int main() { return 0; }`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const ids = consumers.map((c) => c.contractId);
expect(ids).toContain('include::remote/header.h');
expect(ids.some((id) => id.includes('..'))).toBe(false);
});
});
// ---- PR #1156 follow-up: macro-style includes ----
describe('follow-up: macro-style #include emits no consumer contract', () => {
it('does not emit a consumer contract for `#include PLATFORM_HEADER` (no separator, no dot)', async () => {
// `#include PLATFORM_HEADER` parses under tree-sitter as an identifier
// node, slips past the existing system-header / `..` filters, and used
// to leak through as a permanently orphaned consumer contract because
// no file is ever named `PLATFORM_HEADER`. Verify the macro guard
// suppresses it while preserving the real cross-repo include.
writeFile(
'src/main.cpp',
`#include PLATFORM_HEADER
#include "real/api.h"
int main() { return 0; }`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers.map((c) => c.contractId)).toEqual(['include::real/api.h']);
});
it('skips multiple macro identifiers in the same translation unit', async () => {
writeFile(
'src/cfg.cpp',
`#include CONFIG_HEADER
#include PLATFORM_HEADER
#include ASSERT_H_
int main(){return 0;}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(0);
});
});
// ---- PR #1156 follow-up: graph provider absolute paths ----
describe('follow-up: extractProvidersGraph strips repo root from absolute paths', () => {
it('produces repo-relative contract IDs when the graph returns absolute paths', async () => {
writeFile('map/base/view.h', '#pragma once\nclass View {};');
writeFile('utils/types.hpp', '#pragma once');
// Stub the Cypher executor to return absolute paths the way
// gitnexus analyze actually persists them.
const absolute1 = path.join(tmpDir, 'map/base/view.h');
const absolute2 = path.join(tmpDir, 'utils/types.hpp');
const stubDb = async () => [
{ filePath: absolute1, fileId: 'File:abs:1' },
{ filePath: absolute2, fileId: 'File:abs:2' },
];
const contracts = await extractor.extract(stubDb, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter((c) => c.role === 'provider');
const ids = providers.map((p) => p.contractId).sort();
expect(ids).toEqual(['include::map/base/view.h', 'include::utils/types.hpp']);
expect(providers.every((p) => p.meta?.source === 'graph')).toBe(true);
});
it('drops graph rows whose path resolves outside the repo root', async () => {
writeFile('local/header.h', '#pragma once');
const absoluteLocal = path.join(tmpDir, 'local/header.h');
const stubDb = async () => [
{ filePath: absoluteLocal, fileId: 'File:1' },
// Stale absolute path from a different machine — must be skipped.
{ filePath: '/some/other/repo/foreign.h', fileId: 'File:2' },
];
const contracts = await extractor.extract(stubDb, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.map((p) => p.contractId)).toEqual(['include::local/header.h']);
});
});
// ---- PR #1156 Codex follow-up: discovery aligned with ingestion ----
describe('follow-up: file discovery honors createIgnoreFilter and getMaxFileSizeBytes', () => {
it('does not emit a provider contract for a header excluded by .gitignore', async () => {
writeFile('.gitignore', 'vendor-headers/\n');
writeFile('vendor-headers/blocked.h', '#pragma once');
writeFile('src/wanted.h', '#pragma once');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providerIds = contracts.filter((c) => c.role === 'provider').map((p) => p.contractId);
expect(providerIds).toContain('include::src/wanted.h');
expect(providerIds).not.toContain('include::vendor-headers/blocked.h');
});
it('does not emit a provider contract for a header excluded by .gitnexusignore', async () => {
writeFile('.gitnexusignore', 'legacy/\n');
writeFile('legacy/old.h', '#pragma once');
writeFile('src/current.h', '#pragma once');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providerIds = contracts.filter((c) => c.role === 'provider').map((p) => p.contractId);
expect(providerIds).toContain('include::src/current.h');
expect(providerIds).not.toContain('include::legacy/old.h');
});
it('does not parse #include directives in a source file excluded by .gitignore', async () => {
// The ignored source file references a header that would otherwise be
// a cross-repo consumer. After alignment, the ignored file is invisible
// to the consumer scan — no consumer contract should appear.
writeFile('.gitignore', 'generated/\n');
writeFile(
'generated/auto.cpp',
`#include "remote/should_not_appear.h"
int auto_main() { return 0; }`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumerIds = contracts.filter((c) => c.role === 'consumer').map((c) => c.contractId);
expect(consumerIds).not.toContain('include::remote/should_not_appear.h');
});
it('skips a provider header whose size exceeds GITNEXUS_MAX_FILE_SIZE', async () => {
const previous = process.env.GITNEXUS_MAX_FILE_SIZE;
process.env.GITNEXUS_MAX_FILE_SIZE = '1'; // 1 KB cap
try {
// 4 KB header — comfortably exceeds the cap.
const oversized = '#pragma once\n' + 'x'.repeat(4 * 1024);
writeFile('huge/big.h', oversized);
writeFile('small/tiny.h', '#pragma once');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providerIds = contracts.filter((c) => c.role === 'provider').map((p) => p.contractId);
expect(providerIds).toContain('include::small/tiny.h');
expect(providerIds).not.toContain('include::huge/big.h');
} finally {
if (previous === undefined) delete process.env.GITNEXUS_MAX_FILE_SIZE;
else process.env.GITNEXUS_MAX_FILE_SIZE = previous;
}
});
it('skips parsing #include directives in source files exceeding GITNEXUS_MAX_FILE_SIZE', async () => {
const previous = process.env.GITNEXUS_MAX_FILE_SIZE;
process.env.GITNEXUS_MAX_FILE_SIZE = '1';
try {
const oversized =
'#include "remote/should_not_appear.h"\n' +
'// padding to push the file past 1 KB\n' +
'x'.repeat(4 * 1024);
writeFile('big/main.cpp', oversized);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumerIds = contracts.filter((c) => c.role === 'consumer').map((c) => c.contractId);
expect(consumerIds).not.toContain('include::remote/should_not_appear.h');
} finally {
if (previous === undefined) delete process.env.GITNEXUS_MAX_FILE_SIZE;
else process.env.GITNEXUS_MAX_FILE_SIZE = previous;
}
});
});
describe('Windows SIGSEGV regression — large input must route through parseSourceSafe', () => {
it('routes >32 767-char header file through parseSourceSafe (not direct parser.parse)', async () => {
parseSourceSafeSpy.mockClear();
// Bump the file-size cap so the >40 000-char file isn't filtered before
// it ever reaches the parser. Direct parser.parse(content) on a string
// this size SIGSEGVs the process on Windows. The spy assertion catches
// the regression — a "no throw" assertion alone is satisfied by the
// bypass on Linux/macOS where parser.parse(40 000 chars) succeeds.
const previousLimit = process.env.GITNEXUS_MAX_FILE_SIZE;
process.env.GITNEXUS_MAX_FILE_SIZE = '512';
try {
const includes = Array.from(
{ length: 1500 },
(_, i) => `#include "lib/header_${i}.h"\n`,
).join('');
const largeHeader = `#pragma once\n${includes}\nstruct Big {};\n`;
expect(largeHeader.length).toBeGreaterThan(40_000);
writeFile('big/big.cpp', largeHeader);
await extractor.extract(null, tmpDir, makeRepo(tmpDir));
expect(parseSourceSafeSpy).toHaveBeenCalled();
} finally {
if (previousLimit === undefined) delete process.env.GITNEXUS_MAX_FILE_SIZE;
else process.env.GITNEXUS_MAX_FILE_SIZE = previousLimit;
}
});
});
});

View file

@ -127,8 +127,10 @@ describe('isRegistryPrimary', () => {
it('handles the CPlusPlus → REGISTRY_PRIMARY_CPP mapping correctly', () => {
process.env['REGISTRY_PRIMARY_CPP'] = 'true';
expect(isRegistryPrimary(SupportedLanguages.CPlusPlus)).toBe(true);
// Negative: the TS-key-style name is NOT read.
delete process.env['REGISTRY_PRIMARY_CPP'];
// Negative: the TS-key-style name is NOT read. CPlusPlus is now in
// MIGRATED_LANGUAGES, so we must explicitly opt it out via the
// canonical env var to verify the wrong-name var has no effect.
process.env['REGISTRY_PRIMARY_CPP'] = 'false';
process.env['REGISTRY_PRIMARY_CPLUSPLUS'] = 'true';
expect(isRegistryPrimary(SupportedLanguages.CPlusPlus)).toBe(false);
});
@ -154,11 +156,13 @@ describe('primaryLanguages', () => {
process.env['REGISTRY_PRIMARY_TYPESCRIPT'] = 'false';
process.env['REGISTRY_PRIMARY_GO'] = 'false';
process.env['REGISTRY_PRIMARY_C'] = 'false';
process.env['REGISTRY_PRIMARY_CPP'] = 'false';
process.env['REGISTRY_PRIMARY_JAVA'] = '1';
const enabled = primaryLanguages();
expect(enabled.has(SupportedLanguages.Python)).toBe(false);
expect(enabled.has(SupportedLanguages.CSharp)).toBe(false);
expect(enabled.has(SupportedLanguages.Go)).toBe(false);
expect(enabled.has(SupportedLanguages.CPlusPlus)).toBe(false);
expect(enabled.has(SupportedLanguages.Java)).toBe(true);
// Only Java is on: migrated defaults overridden off, Java explicitly on.
expect(enabled.size).toBe(1);

View file

@ -0,0 +1,168 @@
/**
* Unit tests for C++ arity compatibility and metadata.
*/
import { describe, it, expect } from 'vitest';
import { cppArityCompatibility } from '../../../../src/core/ingestion/languages/cpp/arity.js';
import {
computeCppDeclarationArity,
computeCppCallArity,
} from '../../../../src/core/ingestion/languages/cpp/arity-metadata.js';
import { getCppParser } from '../../../../src/core/ingestion/languages/cpp/query.js';
import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js';
import type { SymbolDefinition, Callsite } from 'gitnexus-shared';
function parseFuncDef(src: string): SyntaxNode | null {
const tree = getCppParser().parse(src);
for (let i = 0; i < tree.rootNode.namedChildCount; i++) {
const child = tree.rootNode.namedChild(i);
if (child?.type === 'function_definition') return child as SyntaxNode;
}
return null;
}
function parseCallExpr(src: string): SyntaxNode | null {
const tree = getCppParser().parse(src);
const walk = (node: SyntaxNode): SyntaxNode | null => {
if (node.type === 'call_expression') return node;
for (let i = 0; i < node.namedChildCount; i++) {
const found = walk(node.namedChild(i) as SyntaxNode);
if (found) return found;
}
return null;
};
return walk(tree.rootNode as SyntaxNode);
}
function mkDef(overrides: Partial<SymbolDefinition> = {}): SymbolDefinition {
return {
nodeId: 'test-def',
qualifiedName: 'test',
filePath: 'test.cpp',
type: 'Function',
...overrides,
} as SymbolDefinition;
}
function mkCallsite(arity: number): Callsite {
return { arity } as Callsite;
}
// ── Declaration arity ───────────────────────────────────────────────────────
describe('computeCppDeclarationArity', () => {
it('computes arity for zero-parameter function', () => {
const node = parseFuncDef('void foo() {}');
expect(node).not.toBeNull();
const arity = computeCppDeclarationArity(node!);
expect(arity.parameterCount).toBe(0);
expect(arity.requiredParameterCount).toBe(0);
});
it('computes arity for (void) parameter', () => {
const node = parseFuncDef('void foo(void) {}');
expect(node).not.toBeNull();
const arity = computeCppDeclarationArity(node!);
expect(arity.parameterCount).toBe(0);
expect(arity.requiredParameterCount).toBe(0);
});
it('computes arity for multiple parameters', () => {
const node = parseFuncDef('void foo(int x, int y, int z) {}');
expect(node).not.toBeNull();
const arity = computeCppDeclarationArity(node!);
expect(arity.parameterCount).toBe(3);
expect(arity.requiredParameterCount).toBe(3);
});
it('computes arity with default parameters', () => {
const node = parseFuncDef('void foo(int x, int y = 5, int z = 10) {}');
expect(node).not.toBeNull();
const arity = computeCppDeclarationArity(node!);
expect(arity.parameterCount).toBe(3);
expect(arity.requiredParameterCount).toBe(1);
});
it('detects variadic function', () => {
const node = parseFuncDef('void foo(int x, ...) {}');
expect(node).not.toBeNull();
const arity = computeCppDeclarationArity(node!);
expect(arity.parameterCount).toBeUndefined(); // variadic → undefined max
expect(arity.requiredParameterCount).toBe(1);
expect(arity.parameterTypes).toContain('...');
});
it('handles pointer return type', () => {
const node = parseFuncDef('int* create(int size) {}');
expect(node).not.toBeNull();
const arity = computeCppDeclarationArity(node!);
expect(arity.parameterCount).toBe(1);
});
});
// ── Call-site arity ─────────────────────────────────────────────────────────
describe('computeCppCallArity', () => {
it('computes arity for no-argument call', () => {
const node = parseCallExpr('void f() { foo(); }');
expect(node).not.toBeNull();
expect(computeCppCallArity(node!)).toBe(0);
});
it('computes arity for multi-argument call', () => {
const node = parseCallExpr('void f() { foo(1, 2, 3); }');
expect(node).not.toBeNull();
expect(computeCppCallArity(node!)).toBe(3);
});
it('computes arity for single-argument call', () => {
const node = parseCallExpr('void f() { foo(42); }');
expect(node).not.toBeNull();
expect(computeCppCallArity(node!)).toBe(1);
});
});
// ── Arity compatibility ─────────────────────────────────────────────────────
describe('cppArityCompatibility', () => {
it('returns compatible for exact match', () => {
const def = mkDef({ parameterCount: 2, requiredParameterCount: 2 });
expect(cppArityCompatibility(def, mkCallsite(2))).toBe('compatible');
});
it('returns compatible when call uses default params', () => {
const def = mkDef({ parameterCount: 3, requiredParameterCount: 1 });
expect(cppArityCompatibility(def, mkCallsite(1))).toBe('compatible');
expect(cppArityCompatibility(def, mkCallsite(2))).toBe('compatible');
expect(cppArityCompatibility(def, mkCallsite(3))).toBe('compatible');
});
it('returns incompatible for too few args', () => {
const def = mkDef({ parameterCount: 3, requiredParameterCount: 2 });
expect(cppArityCompatibility(def, mkCallsite(1))).toBe('incompatible');
});
it('returns incompatible for too many args (non-variadic)', () => {
const def = mkDef({ parameterCount: 2, requiredParameterCount: 2 });
expect(cppArityCompatibility(def, mkCallsite(5))).toBe('incompatible');
});
it('returns compatible for variadic with extra args', () => {
const def = mkDef({
parameterCount: undefined,
requiredParameterCount: 1,
parameterTypes: ['int', '...'],
});
expect(cppArityCompatibility(def, mkCallsite(5))).toBe('compatible');
});
it('returns unknown when no metadata', () => {
const def = mkDef({});
expect(cppArityCompatibility(def, mkCallsite(2))).toBe('unknown');
});
it('returns unknown for negative arity', () => {
const def = mkDef({ parameterCount: 2, requiredParameterCount: 2 });
expect(cppArityCompatibility(def, mkCallsite(-1))).toBe('unknown');
});
});

View file

@ -0,0 +1,434 @@
/**
* Unit tests for C++ scope query + captures orchestrator.
*
* Pins the capture-tag vocabulary + range shape for every construct
* the scope-resolution pipeline reads. Runs against tree-sitter-cpp.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { emitCppScopeCaptures } from '../../../../src/core/ingestion/languages/cpp/captures.js';
import {
clearFileLocalNames,
isFileLocal,
} from '../../../../src/core/ingestion/languages/cpp/file-local-linkage.js';
function tagsFor(src: string, filePath = 'test.cpp'): string[][] {
const matches = emitCppScopeCaptures(src, filePath);
return matches.map((m) => Object.keys(m).sort());
}
function findMatch(src: string, predicate: (tags: string[]) => boolean, filePath = 'test.cpp') {
const matches = emitCppScopeCaptures(src, filePath);
return matches.find((m) => predicate(Object.keys(m)));
}
function allMatches(src: string, predicate: (tags: string[]) => boolean, filePath = 'test.cpp') {
const matches = emitCppScopeCaptures(src, filePath);
return matches.filter((m) => predicate(Object.keys(m)));
}
// ── Scopes ──────────────────────────────────────────────────────────────────
describe('emitCppScopeCaptures — scopes', () => {
it('captures translation_unit as @scope.module', () => {
const all = tagsFor('int x = 1;');
expect(all.some((t) => t.includes('@scope.module'))).toBe(true);
});
it('captures class_specifier as @scope.class', () => {
const all = tagsFor('class Foo { int x; };');
expect(all.some((t) => t.includes('@scope.class'))).toBe(true);
});
it('captures struct_specifier as @scope.class', () => {
const all = tagsFor('struct Point { int x; int y; };');
expect(all.some((t) => t.includes('@scope.class'))).toBe(true);
});
it('captures namespace_definition as @scope.namespace', () => {
const all = tagsFor('namespace foo { int x; }');
expect(all.some((t) => t.includes('@scope.namespace'))).toBe(true);
});
it('captures function_definition as @scope.function', () => {
const all = tagsFor('void foo() { }');
expect(all.some((t) => t.includes('@scope.function'))).toBe(true);
});
it('captures lambda_expression as @scope.function', () => {
const all = tagsFor('auto f = [](int x) { return x; };');
expect(all.some((t) => t.includes('@scope.function'))).toBe(true);
});
it('captures block-level scopes (if, for, while, do, switch, case, try, catch)', () => {
const src = `
void f() {
if (true) { }
for (int i = 0; i < 10; i++) { }
while (true) { }
do { } while (false);
switch (0) { case 0: break; }
try { } catch (...) { }
}
`;
const all = tagsFor(src);
const blocks = all.filter((t) => t.includes('@scope.block'));
expect(blocks.length).toBeGreaterThanOrEqual(6);
});
it('captures for_range_loop as @scope.block', () => {
const src = `
#include <vector>
void f() {
std::vector<int> v;
for (auto& x : v) { }
}
`;
const all = tagsFor(src);
const blocks = all.filter((t) => t.includes('@scope.block'));
expect(blocks.length).toBeGreaterThanOrEqual(1);
});
});
// ── Declarations — classes / structs ────────────────────────────────────────
describe('emitCppScopeCaptures — class declarations', () => {
it('captures named class with @declaration.class', () => {
const m = findMatch('class Foo { int x; };', (t) => t.includes('@declaration.class'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('Foo');
});
it('captures named struct with @declaration.struct', () => {
const m = findMatch('struct Point { int x; int y; };', (t) =>
t.includes('@declaration.struct'),
);
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('Point');
});
it('captures template class with @declaration.class', () => {
const m = findMatch('template <typename T> class Container { T val; };', (t) =>
t.includes('@declaration.class'),
);
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('Container');
});
});
// ── Declarations — namespaces ───────────────────────────────────────────────
describe('emitCppScopeCaptures — namespace declarations', () => {
it('captures named namespace with @declaration.namespace', () => {
const m = findMatch('namespace foo { int x; }', (t) => t.includes('@declaration.namespace'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('foo');
});
it('anonymous namespace has no @declaration.namespace (only @scope.namespace)', () => {
const matches = allMatches('namespace { int x; }', (t) =>
t.includes('@declaration.namespace'),
);
// Anonymous namespace should NOT produce a @declaration.namespace
expect(matches.length).toBe(0);
});
});
// ── Declarations — functions / methods ──────────────────────────────────────
describe('emitCppScopeCaptures — function declarations', () => {
it('captures function definition with @declaration.function', () => {
const m = findMatch('void foo() {}', (t) => t.includes('@declaration.function'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('foo');
});
it('captures function with pointer return as @declaration.function', () => {
const m = findMatch('int* create() {}', (t) => t.includes('@declaration.function'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('create');
});
it('captures out-of-class method (qualified_identifier) as @declaration.method', () => {
const m = findMatch('void Foo::bar() {}', (t) => t.includes('@declaration.method'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('bar');
});
it('captures destructor as @declaration.method', () => {
const m = findMatch('void Foo::~Foo() {}', (t) => t.includes('@declaration.method'));
expect(m).toBeDefined();
// destructor_name includes the ~
expect(m!['@declaration.name'].text).toContain('~');
});
it('captures inline method (field_identifier) as @declaration.method', () => {
const src = 'class Foo { void bar() {} };';
const m = findMatch(src, (t) => t.includes('@declaration.method'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('bar');
});
it('captures function prototype as @declaration.function', () => {
const m = findMatch('void foo();', (t) => t.includes('@declaration.function'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('foo');
});
it('captures template function as @declaration.function', () => {
const m = findMatch('template <typename T> void foo(T x) {}', (t) =>
t.includes('@declaration.function'),
);
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('foo');
});
});
// ── Declarations — fields ───────────────────────────────────────────────────
describe('emitCppScopeCaptures — field declarations', () => {
it('captures plain field', () => {
const m = findMatch('class Foo { int val; };', (t) => t.includes('@declaration.field'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('val');
});
it('captures pointer field', () => {
const m = findMatch('class Foo { int* ptr; };', (t) => t.includes('@declaration.field'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('ptr');
});
it('captures reference field', () => {
const m = findMatch('class Foo { int& ref; };', (t) => t.includes('@declaration.field'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('ref');
});
});
// ── Declarations — variables ────────────────────────────────────────────────
describe('emitCppScopeCaptures — variable declarations', () => {
it('captures variable with initializer', () => {
const m = findMatch('int x = 42;', (t) => t.includes('@declaration.variable'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('x');
});
});
// ── Declarations — enums ────────────────────────────────────────────────────
describe('emitCppScopeCaptures — enum declarations', () => {
it('captures enum with @declaration.enum', () => {
const m = findMatch('enum Color { Red, Green, Blue };', (t) =>
t.includes('@declaration.enum'),
);
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('Color');
});
it('captures enum constants with @declaration.const', () => {
const matches = allMatches('enum Color { Red, Green, Blue };', (t) =>
t.includes('@declaration.const'),
);
expect(matches.length).toBe(3);
const names = matches.map((m) => m['@declaration.name'].text).sort();
expect(names).toEqual(['Blue', 'Green', 'Red']);
});
});
// ── Declarations — typedef / alias ──────────────────────────────────────────
describe('emitCppScopeCaptures — typedef/alias declarations', () => {
it('captures typedef as @declaration.typedef', () => {
const m = findMatch('typedef int MyInt;', (t) => t.includes('@declaration.typedef'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('MyInt');
});
it('captures using alias as @declaration.typedef', () => {
const m = findMatch('using MyInt = int;', (t) => t.includes('@declaration.typedef'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('MyInt');
});
});
// ── Declarations — macros ───────────────────────────────────────────────────
describe('emitCppScopeCaptures — macro declarations', () => {
it('captures #define as @declaration.macro', () => {
const m = findMatch('#define MAX 100', (t) => t.includes('@declaration.macro'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('MAX');
});
it('captures #define function as @declaration.macro', () => {
const m = findMatch('#define ADD(a,b) ((a)+(b))', (t) => t.includes('@declaration.macro'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('ADD');
});
});
// ── Imports ─────────────────────────────────────────────────────────────────
describe('emitCppScopeCaptures — imports', () => {
it('captures #include local as wildcard import', () => {
const m = findMatch('#include "foo.h"', (t) => t.includes('@import.source'));
expect(m).toBeDefined();
expect(m!['@import.source'].text).toBe('foo.h');
expect(m!['@import.kind'].text).toBe('wildcard');
expect(m!['@import.system']).toBeUndefined();
});
it('captures #include system with system marker', () => {
const m = findMatch('#include <iostream>', (t) => t.includes('@import.source'));
expect(m).toBeDefined();
expect(m!['@import.source'].text).toBe('iostream');
expect(m!['@import.system']).toBeDefined();
});
it('captures using namespace as wildcard import', () => {
const m = findMatch('using namespace std;', (t) =>
t.includes('@import.using-namespace'),
);
expect(m).toBeDefined();
expect(m!['@import.source'].text).toBe('std');
expect(m!['@import.kind'].text).toBe('wildcard');
});
it('captures using declaration as named import', () => {
const m = findMatch('using std::vector;', (t) => t.includes('@import.name'));
expect(m).toBeDefined();
expect(m!['@import.source'].text).toBe('std');
expect(m!['@import.name'].text).toBe('vector');
expect(m!['@import.kind'].text).toBe('named');
});
});
// ── References ──────────────────────────────────────────────────────────────
describe('emitCppScopeCaptures — references', () => {
it('captures free call', () => {
const src = 'void f() { foo(); }';
const m = findMatch(src, (t) => t.includes('@reference.call.free'));
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('foo');
});
it('captures member call (obj.method())', () => {
const src = 'void f() { obj.method(); }';
const m = findMatch(src, (t) => t.includes('@reference.call.member'));
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('method');
});
it('captures member call (ptr->method())', () => {
const src = 'void f() { ptr->method(); }';
const m = findMatch(src, (t) => t.includes('@reference.call.member'));
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('method');
});
it('captures qualified call (Namespace::func())', () => {
const src = 'void f() { Foo::bar(); }';
const m = findMatch(src, (t) => t.includes('@reference.call.qualified'));
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('bar');
});
it('captures field read', () => {
const src = 'void f() { int x = obj.val; }';
const m = findMatch(src, (t) => t.includes('@reference.read'));
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('val');
});
it('captures field write', () => {
const src = 'void f() { obj.val = 42; }';
const m = findMatch(src, (t) => t.includes('@reference.write'));
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('val');
});
});
// ── Type bindings ───────────────────────────────────────────────────────────
describe('emitCppScopeCaptures — type bindings', () => {
it('captures parameter type binding', () => {
const src = 'void foo(int x) {}';
const m = findMatch(src, (t) => t.includes('@type-binding.parameter'));
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('x');
});
it('captures variable type binding', () => {
const src = 'int x = 42;';
const m = findMatch(src, (t) => t.includes('@type-binding.assignment'));
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('x');
});
});
// ── Arity enrichment ────────────────────────────────────────────────────────
describe('emitCppScopeCaptures — arity enrichment', () => {
it('enriches function declaration with parameter count', () => {
const m = findMatch('void foo(int x, int y) {}', (t) =>
t.includes('@declaration.parameter-count'),
);
expect(m).toBeDefined();
expect(m!['@declaration.parameter-count'].text).toBe('2');
});
it('enriches zero-parameter function', () => {
const m = findMatch('void foo() {}', (t) =>
t.includes('@declaration.parameter-count'),
);
expect(m).toBeDefined();
expect(m!['@declaration.parameter-count'].text).toBe('0');
});
it('detects default parameters (required < total)', () => {
const m = findMatch('void foo(int x, int y = 5) {}', (t) =>
t.includes('@declaration.required-parameter-count'),
);
expect(m).toBeDefined();
expect(m!['@declaration.required-parameter-count'].text).toBe('1');
expect(m!['@declaration.parameter-count'].text).toBe('2');
});
it('enriches call reference with arity', () => {
const src = 'void f() { foo(1, 2, 3); }';
const m = findMatch(src, (t) => t.includes('@reference.arity'));
expect(m).toBeDefined();
expect(m!['@reference.arity'].text).toBe('3');
});
});
// ── Static / anonymous namespace detection ──────────────────────────────────
describe('emitCppScopeCaptures — file-local linkage', () => {
beforeEach(() => {
clearFileLocalNames();
});
it('detects static function as file-local', () => {
emitCppScopeCaptures('static void helper() {}', 'test.cpp');
expect(isFileLocal('test.cpp', 'helper')).toBe(true);
});
it('does not mark non-static function as file-local', () => {
emitCppScopeCaptures('void helper() {}', 'test.cpp');
expect(isFileLocal('test.cpp', 'helper')).toBe(false);
});
it('detects function in anonymous namespace as file-local', () => {
emitCppScopeCaptures('namespace { void helper() {} }', 'test.cpp');
expect(isFileLocal('test.cpp', 'helper')).toBe(true);
});
it('does not mark function in named namespace as file-local', () => {
emitCppScopeCaptures('namespace foo { void helper() {} }', 'test.cpp');
expect(isFileLocal('test.cpp', 'helper')).toBe(false);
});
});

View file

@ -0,0 +1,165 @@
/**
* Unit tests for C++ import decomposition, interpretation, and target resolution.
*/
import { describe, it, expect } from 'vitest';
import { getCppParser } from '../../../../src/core/ingestion/languages/cpp/query.js';
import {
splitCppInclude,
splitCppUsingDecl,
} from '../../../../src/core/ingestion/languages/cpp/import-decomposer.js';
import { interpretCppImport } from '../../../../src/core/ingestion/languages/cpp/interpret.js';
import { resolveCppImportTarget } from '../../../../src/core/ingestion/languages/cpp/import-target.js';
import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js';
function parseNode(src: string, type: string): SyntaxNode | null {
const tree = getCppParser().parse(src);
for (let i = 0; i < tree.rootNode.namedChildCount; i++) {
const child = tree.rootNode.namedChild(i);
if (child?.type === type) return child as SyntaxNode;
}
return null;
}
function capt(name: string, text: string) {
return { name, text, range: { startLine: 1, startCol: 1, endLine: 1, endCol: 1 } };
}
// ── #include decomposition ──────────────────────────────────────────────────
describe('C++ include decomposition (splitCppInclude)', () => {
it('decomposes local include "#include \\"foo.h\\""', () => {
const node = parseNode('#include "foo.h"', 'preproc_include');
expect(node).not.toBeNull();
const match = splitCppInclude(node!);
expect(match).not.toBeNull();
expect(match!['@import.source'].text).toBe('foo.h');
expect(match!['@import.kind'].text).toBe('wildcard');
expect(match!['@import.system']).toBeUndefined();
});
it('decomposes system include "#include <iostream>"', () => {
const node = parseNode('#include <iostream>', 'preproc_include');
expect(node).not.toBeNull();
const match = splitCppInclude(node!);
expect(match).not.toBeNull();
expect(match!['@import.source'].text).toBe('iostream');
expect(match!['@import.system']).toBeDefined();
});
it('decomposes C++ header include "#include \\"utils/helpers.hpp\\""', () => {
const node = parseNode('#include "utils/helpers.hpp"', 'preproc_include');
expect(node).not.toBeNull();
const match = splitCppInclude(node!);
expect(match).not.toBeNull();
expect(match!['@import.source'].text).toBe('utils/helpers.hpp');
});
});
// ── using declaration decomposition ─────────────────────────────────────────
describe('C++ using declaration decomposition (splitCppUsingDecl)', () => {
it('decomposes "using namespace std;" as wildcard import', () => {
const node = parseNode('using namespace std;', 'using_declaration');
expect(node).not.toBeNull();
const match = splitCppUsingDecl(node!);
expect(match).not.toBeNull();
expect(match!['@import.kind'].text).toBe('wildcard');
expect(match!['@import.source'].text).toBe('std');
expect(match!['@import.using-namespace']).toBeDefined();
});
it('decomposes "using std::vector;" as named import', () => {
const node = parseNode('using std::vector;', 'using_declaration');
expect(node).not.toBeNull();
const match = splitCppUsingDecl(node!);
expect(match).not.toBeNull();
expect(match!['@import.kind'].text).toBe('named');
expect(match!['@import.source'].text).toBe('std');
expect(match!['@import.name'].text).toBe('vector');
});
it('decomposes nested namespace "using namespace foo::bar;"', () => {
const node = parseNode('using namespace foo::bar;', 'using_declaration');
expect(node).not.toBeNull();
const match = splitCppUsingDecl(node!);
expect(match).not.toBeNull();
expect(match!['@import.kind'].text).toBe('wildcard');
expect(match!['@import.source'].text).toBe('foo::bar');
});
});
// ── Import interpretation ───────────────────────────────────────────────────
describe('C++ import interpretation (interpretCppImport)', () => {
it('interprets local include as wildcard import', () => {
const result = interpretCppImport({
'@import.kind': capt('@import.kind', 'wildcard'),
'@import.source': capt('@import.source', 'header.hpp'),
});
expect(result).toEqual({ kind: 'wildcard', targetRaw: 'header.hpp' });
});
it('returns null for system headers', () => {
const result = interpretCppImport({
'@import.kind': capt('@import.kind', 'wildcard'),
'@import.source': capt('@import.source', 'iostream'),
'@import.system': capt('@import.system', 'true'),
});
expect(result).toBeNull();
});
it('interprets named import (using std::vector)', () => {
const result = interpretCppImport({
'@import.kind': capt('@import.kind', 'named'),
'@import.source': capt('@import.source', 'std'),
'@import.name': capt('@import.name', 'vector'),
});
expect(result).not.toBeNull();
expect(result!.kind).toBe('named');
expect(result!.targetRaw).toBe('std');
});
it('returns null when @import.source is missing', () => {
const result = interpretCppImport({
'@import.kind': capt('@import.kind', 'wildcard'),
});
expect(result).toBeNull();
});
});
// ── Import target resolution ────────────────────────────────────────────────
describe('C++ import target resolution (resolveCppImportTarget)', () => {
it('resolves .hpp header', () => {
const result = resolveCppImportTarget('foo.hpp', 'main.cpp', new Set(['foo.hpp', 'bar.cpp']));
expect(result).toBe('foo.hpp');
});
it('resolves .hxx header', () => {
const result = resolveCppImportTarget('foo.hxx', 'main.cpp', new Set(['foo.hxx']));
expect(result).toBe('foo.hxx');
});
it('prefers same-directory sibling', () => {
const result = resolveCppImportTarget(
'bar.hpp',
'src/foo.cpp',
new Set(['include/bar.hpp', 'src/bar.hpp']),
);
expect(result).toBe('src/bar.hpp');
});
it('resolves suffix match with depth tiebreak', () => {
const result = resolveCppImportTarget(
'foo.h',
'main.cpp',
new Set(['a/b/c/foo.h', 'z/foo.h']),
);
expect(result).toBe('z/foo.h');
});
it('returns null for no match', () => {
expect(resolveCppImportTarget('missing.hpp', 'main.cpp', new Set(['foo.h']))).toBeNull();
});
});