mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-23 00:41:36 +00:00
fix(cpp): complete scope-resolution parity
This commit is contained in:
parent
fcab1e2e82
commit
dc1db90525
34 changed files with 2950 additions and 1038 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
});
|
||||
|
|
|
|||
180
gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts
Normal file
180
gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts
Normal 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;
|
||||
}
|
||||
35
gitnexus/src/core/ingestion/languages/cpp/arity.ts
Normal file
35
gitnexus/src/core/ingestion/languages/cpp/arity.ts
Normal 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';
|
||||
}
|
||||
406
gitnexus/src/core/ingestion/languages/cpp/captures.ts
Normal file
406
gitnexus/src/core/ingestion/languages/cpp/captures.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 ?? '';
|
||||
}
|
||||
53
gitnexus/src/core/ingestion/languages/cpp/header-scan.ts
Normal file
53
gitnexus/src/core/ingestion/languages/cpp/header-scan.ts
Normal 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, '/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
120
gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts
Normal file
120
gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
18
gitnexus/src/core/ingestion/languages/cpp/import-target.ts
Normal file
18
gitnexus/src/core/ingestion/languages/cpp/import-target.ts
Normal 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);
|
||||
}
|
||||
24
gitnexus/src/core/ingestion/languages/cpp/index.ts
Normal file
24
gitnexus/src/core/ingestion/languages/cpp/index.ts
Normal 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';
|
||||
107
gitnexus/src/core/ingestion/languages/cpp/interpret.ts
Normal file
107
gitnexus/src/core/ingestion/languages/cpp/interpret.ts
Normal 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;
|
||||
}
|
||||
38
gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts
Normal file
38
gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
456
gitnexus/src/core/ingestion/languages/cpp/query.ts
Normal file
456
gitnexus/src/core/ingestion/languages/cpp/query.ts
Normal 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;
|
||||
}
|
||||
255
gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts
Normal file
255
gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts
Normal 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;
|
||||
}
|
||||
89
gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts
Normal file
89
gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
79
gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts
Normal file
79
gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -72,6 +72,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
|
|||
SupportedLanguages.TypeScript,
|
||||
SupportedLanguages.Go,
|
||||
SupportedLanguages.C,
|
||||
SupportedLanguages.CPlusPlus,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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+)__:(.+)$/;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
168
gitnexus/test/unit/scope-resolution/cpp/cpp-arity.test.ts
Normal file
168
gitnexus/test/unit/scope-resolution/cpp/cpp-arity.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
434
gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts
Normal file
434
gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
165
gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts
Normal file
165
gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue