diff --git a/gitnexus/src/core/group/config-parser.ts b/gitnexus/src/core/group/config-parser.ts index 29c868171..8116375ae 100644 --- a/gitnexus/src/core/group/config-parser.ts +++ b/gitnexus/src/core/group/config-parser.ts @@ -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.` 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 { diff --git a/gitnexus/src/core/group/extractors/include-extractor.ts b/gitnexus/src/core/group/extractors/include-extractor.ts index 98cbd371f..86c6e4c44 100644 --- a/gitnexus/src/core/group/extractors/include-extractor.ts +++ b/gitnexus/src/core/group/extractors/include-extractor.ts @@ -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([...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 { return true; } @@ -311,86 +146,24 @@ export class IncludeExtractor implements ContractExtractor { repoPath: string, _repo: RepoHandle, ): Promise { - // 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:` 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:` UIDs in cross-links correspond to graph File nodes. - */ - private async discoverIndexableFiles(repoPath: string): Promise { - 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 { // 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 { + private async extractProvidersGraph(db: CypherExecutor): Promise { 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 { + 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, }, }); diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts index f4d0f77cf..36f8813d7 100644 --- a/gitnexus/src/core/group/extractors/manifest-extractor.ts +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -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': diff --git a/gitnexus/src/core/group/matching.ts b/gitnexus/src/core/group/matching.ts index 0b27655c6..76802f1c3 100644 --- a/gitnexus/src/core/group/matching.ts +++ b/gitnexus/src/core/group/matching.ts @@ -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(); - 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: `::[/]`. + case 'grpc': { + // Canonical form: `grpc::[/]`. // // 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 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 { - const isNoisy = buildNoisyContractFilter(matchingConfig); - const providers = contracts.filter((c) => c.role === 'provider' && !isNoisy(c.contractId)); +export function buildProviderIndex(contracts: StoredContract[]): Map { + const providers = contracts.filter((c) => c.role === 'provider'); const index = new Map(); for (const p of providers) { const key = normalizeContractId(p.contractId); @@ -177,15 +108,11 @@ export function buildProviderIndex( export function runExactMatch( contracts: StoredContract[], providerIndex?: Map, - 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(); @@ -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, ): 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(); 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 "::" and "/*" - const candidateProviders: StoredContract[] = []; - const matchedProviderServices = new Set(); + // "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 "::" + // 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}`); }); diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index cd64fdf8c..cfc0f48ee 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -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) @@ -89,18 +86,15 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis let autoContracts: StoredContract[] = []; let manifestCrossLinks: CrossLink[] = []; let dbExecutors: Map | undefined; - let registryEntries: RegistryEntry[] | undefined; const eo = opts?.extractorOverride; if (eo && eo.length === 0) { autoContracts = await (eo as () => Promise)(); } 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(); @@ -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(); - 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, }; diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index 8e43ff78f..d54f0701b 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -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 { diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 9fa6c1ae5..40bc50f9a 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -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', diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 7fab4689b..58e59fe6f 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -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 = 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). }); diff --git a/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts new file mode 100644 index 000000000..ff7e98501 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts @@ -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 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 = { + '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; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/arity.ts b/gitnexus/src/core/ingestion/languages/cpp/arity.ts new file mode 100644 index 000000000..e13fa6a3a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/arity.ts @@ -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'; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts new file mode 100644 index 000000000..69205a1b6 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -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['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(); + + for (const m of rawMatches) { + const grouped: Record = {}; + 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; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts b/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts new file mode 100644 index 000000000..e617fbc44 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts @@ -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>(); + +/** 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(); + 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(); + 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 ?? ''; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts b/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts new file mode 100644 index 000000000..39ef608b3 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts @@ -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 { + const headers = new Set(); + walk(repoPath, repoPath, headers); + return headers; +} + +function walk(dir: string, root: string, out: Set): 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, '/')); + } + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts new file mode 100644 index 000000000..eb6b252ce --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts @@ -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 = { + '@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 ; + // 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 ; (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), + }; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/import-target.ts b/gitnexus/src/core/ingestion/languages/cpp/import-target.ts new file mode 100644 index 000000000..26e317c6e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/import-target.ts @@ -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 | null { + return resolveCImportTarget(targetRaw, fromFile, allFilePaths); +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/index.ts b/gitnexus/src/core/ingestion/languages/cpp/index.ts new file mode 100644 index 000000000..3890a45c1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/index.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts new file mode 100644 index 000000000..93fa3372e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts @@ -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 → 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; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts new file mode 100644 index 000000000..6409cef46 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts @@ -0,0 +1,38 @@ +import type { BindingRef } from 'gitnexus-shared'; + +const TIER: Record = { + 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(); + 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; + }); +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts new file mode 100644 index 000000000..7d9e46e83 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts @@ -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()) ─ +;; 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()) ─────── +;; 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()) ──────────────────────── +(call_expression + function: (template_function + name: (identifier) @reference.name)) @reference.call.free + +;; Note: Ns::func() 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[0]); + } + return _parser; +} + +export function getCppScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(CPP as Parameters[0], CPP_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts b/gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts new file mode 100644 index 000000000..204d1ab21 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts @@ -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; + 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 | 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; + + // 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` text needed for element-type + * extraction. + */ +function buildParamTemplateMap(rootNode: TsNode): Map { + const map = new Map(); + 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` + * - `std::vector` → `User` + * - `map` → `User` (last template arg) + * - `map` → `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, +): 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; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts new file mode 100644 index 000000000..d5b9542c2 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -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` ≡ `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 | 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); + }, +}; diff --git a/gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts new file mode 100644 index 000000000..63500abd5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts @@ -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; +} diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index e063ce20c..47ff886f8 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -72,6 +72,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set = n [SupportedLanguages.TypeScript, typescriptScopeResolver], [SupportedLanguages.Go, goScopeResolver], [SupportedLanguages.C, cScopeResolver], + [SupportedLanguages.CPlusPlus, cppScopeResolver], ]); diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index def4e1299..9a71fc16c 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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 } : {}), }); } diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 0839d59f0..59d066af7 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -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'); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index b8e2676c6..012d12aad 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -44,6 +44,7 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly([]), }; type ResolverParityEnv = Readonly>; diff --git a/gitnexus/test/unit/group/include-extractor.test.ts b/gitnexus/test/unit/group/include-extractor.test.ts index 321773518..572d28959 100644 --- a/gitnexus/test/unit/group/include-extractor.test.ts +++ b/gitnexus/test/unit/group/include-extractor.test.ts @@ -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; - } - }); - }); }); diff --git a/gitnexus/test/unit/registry-primary-flag.test.ts b/gitnexus/test/unit/registry-primary-flag.test.ts index 9e8be3455..2db624527 100644 --- a/gitnexus/test/unit/registry-primary-flag.test.ts +++ b/gitnexus/test/unit/registry-primary-flag.test.ts @@ -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); diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-arity.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-arity.test.ts new file mode 100644 index 000000000..a89a3167d --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-arity.test.ts @@ -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 { + 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'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts new file mode 100644 index 000000000..2334b6ddb --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts @@ -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 + void f() { + std::vector 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 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 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 ', (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); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts new file mode 100644 index 000000000..34b144777 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts @@ -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 "', () => { + const node = parseNode('#include ', '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(); + }); +});