diff --git a/README.md b/README.md index 9b14396a8..d55e8ef94 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,8 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max | `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size. `0` disables the pool (sequential fallback). Equivalent to `--workers `. | Constrained containers (cgroup CPU limits), CI runners with explicit quotas, or debugging a worker-only crash via `0`. | | `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. | | `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. | +| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. | +| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. | | `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size `. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. | | `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout ` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. | | `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. | diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 29253f969..55995b76b 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -473,6 +473,8 @@ async function ensureHeap(): Promise { */ const ANALYZE_CLI_ENV_KEYS = [ 'GITNEXUS_VERBOSE', + 'GITNEXUS_PROFILE_DEFERRED', + 'GITNEXUS_PROFILE_DEFERRED_SLOW_MS', 'GITNEXUS_MAX_FILE_SIZE', 'GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', 'GITNEXUS_EMBEDDING_THREADS', diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts index 02a6ed60f..7e7d4d852 100644 --- a/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts +++ b/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts @@ -2,6 +2,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { CallExtractionConfig } from '../../call-types.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; export const cCallConfig: CallExtractionConfig = { language: SupportedLanguages.C, @@ -9,4 +10,168 @@ export const cCallConfig: CallExtractionConfig = { export const cppCallConfig: CallExtractionConfig = { language: SupportedLanguages.CPlusPlus, + extractLanguageCallSite(callNode) { + return extractCppOperatorCallSite(callNode); + }, }; + +function extractCppOperatorCallSite(callNode: SyntaxNode) { + if (callNode.type !== 'binary_expression') return null; + if (isPrimitiveOnlyBinaryOperatorCall(callNode)) return null; + + const operator = callNode.childForFieldName('operator')?.text.trim(); + // Keep the legacy DAG conservative: only simple identifier operands are + // modeled here. Complex expressions stay unresolved instead of guessed. + if (operator === '+') { + const left = callNode.childForFieldName('left'); + const right = callNode.childForFieldName('right'); + if (left?.type !== 'identifier' || right?.type !== 'identifier') return null; + return { + calledName: 'operator+', + callForm: 'member' as const, + receiverName: left.text, + argCount: 1, + }; + } + + if (operator === '<<') { + const right = callNode.childForFieldName('right'); + if (right?.type !== 'identifier') return null; + return { + calledName: 'operator<<', + callForm: 'free' as const, + argCount: 2, + }; + } + + return null; +} + +function isPrimitiveOnlyBinaryOperatorCall(callNode: SyntaxNode): boolean { + const left = callNode.childForFieldName('left'); + const right = callNode.childForFieldName('right'); + if (left === null || right === null) return false; + return isBuiltinOperatorOperand(left) && isBuiltinOperatorOperand(right); +} + +function isBuiltinOperatorOperand(node: SyntaxNode): boolean { + return isBuiltinOperatorType(inferCppOperatorOperandType(node)); +} + +function inferCppOperatorOperandType(node: SyntaxNode): string { + const literalType = inferCppLiteralType(node); + if (literalType !== '') return literalType; + if (node.type === 'identifier') return lookupCppIdentifierType(node); + return ''; +} + +function inferCppLiteralType(node: SyntaxNode): string { + if (node.type === 'number_literal') return node.text.includes('.') ? 'double' : 'int'; + if (node.type === 'char_literal') return 'char'; + if (node.type === 'true' || node.type === 'false') return 'bool'; + return ''; +} + +function lookupCppIdentifierType(identNode: SyntaxNode): string { + const varName = identNode.text; + let scope: SyntaxNode | null = identNode.parent; + while ( + scope !== null && + scope.type !== 'compound_statement' && + scope.type !== 'translation_unit' + ) { + scope = scope.parent; + } + if (scope === null) return ''; + + const parameterType = lookupCppFunctionParameterType(scope, varName); + if (parameterType !== '') return parameterType; + + 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'); + const declarator = stmt.childForFieldName('declarator'); + if (typeNode === null || declarator === null) continue; + if (extractDeclaratorLeafName(declarator) === varName) + return normalizeCppTypeText(typeNode.text); + } + return ''; +} + +function lookupCppFunctionParameterType(scope: SyntaxNode, varName: string): string { + let node: SyntaxNode | null = scope.parent; + while (node !== null) { + if (node.type === 'function_definition' || node.type === 'function_declarator') { + const fnDecl = + node.type === 'function_declarator' + ? node + : findFirstDescendantOfType(node, 'function_declarator'); + const params = fnDecl?.childForFieldName('parameters') ?? null; + if (params === null) return ''; + for (let i = 0; i < params.namedChildCount; i++) { + const param = params.namedChild(i); + if (param === null || param.type !== 'parameter_declaration') continue; + const declarator = param.childForFieldName('declarator'); + const typeNode = param.childForFieldName('type'); + if ( + declarator !== null && + typeNode !== null && + extractDeclaratorLeafName(declarator) === varName + ) { + return normalizeCppTypeText(typeNode.text); + } + } + return ''; + } + node = node.parent; + } + return ''; +} + +function findFirstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | null { + if (node.type === type) return node; + for (let i = 0; i < node.namedChildCount; i++) { + const found = findFirstDescendantOfType(node.namedChild(i)!, type); + if (found !== null) return found; + } + return null; +} + +function extractDeclaratorLeafName(node: SyntaxNode): string { + if ( + node.type === 'identifier' || + node.type === 'field_identifier' || + node.type === 'operator_name' + ) { + return node.text; + } + + const named = node.namedChildren; + for (let i = named.length - 1; i >= 0; i--) { + const name = extractDeclaratorLeafName(named[i]!); + if (name !== '') return name; + } + return ''; +} + +function normalizeCppTypeText(text: string): string { + return text + .replace(/\b(const|volatile|static|extern|register|mutable|inline|constexpr)\b/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function isBuiltinOperatorType(type: string): boolean { + return ( + type === 'bool' || + type === 'char' || + type === 'double' || + type === 'float' || + type === 'int' || + type === 'long' || + type === 'short' || + type === 'signed' || + type === 'unsigned' + ); +} diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 1b5a234b4..0ab961a7a 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -39,6 +39,16 @@ import { generateId } from '../../lib/utils.js'; import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared'; import { isRegistryPrimary } from './registry-primary-flag.js'; import { isVerboseIngestionEnabled } from './utils/verbose.js'; +import { + deferredCallFileSlowMs, + deferredCallLogEveryN, + getDeferredProfileDroppedCount, + isDeferredResolutionProfileEnabled, + logDeferredProfile, + profileElapsedMs, + resetDeferredProfileDroppedCount, + startTimer, +} from './utils/deferred-resolution-profile.js'; import { yieldToEventLoop } from './utils/event-loop.js'; import { parseSourceSafe } from '../tree-sitter/safe-parse.js'; import { @@ -2909,6 +2919,39 @@ export const processCallsFromExtracted = async ( } const totalFiles = byFile.size; let filesProcessed = 0; + // Counts only files that survived the registry-primary skip — what the user + // is actually waiting on. Keyed by this counter, the first per-file progress + // log fires on the first *resolved* file rather than file #1 of byFile, + // which would silently land inside the skip block on mixed Python+JVM repos + // where the skipped language sorts first. + let resolvedFiles = 0; + const profileCalls = isDeferredResolutionProfileEnabled(); + const slowFileMs = profileCalls ? deferredCallFileSlowMs() : 0; + const logEveryN = profileCalls ? deferredCallLogEveryN() : 0; + let skippedRegistryPrimaryFiles = 0; + + // Fresh dropped-log counter per analyze run — the module-private counter + // in deferred-resolution-profile.ts is process-lived, so without a reset + // here it would accumulate across consecutive analyze invocations in the + // same Node process (e.g., the MCP server, eval harness, integration + // tests). + if (profileCalls) resetDeferredProfileDroppedCount(); + + // One-pass pre-count of the eventual non-skipped total so the live progress + // denominator stays stable as the loop iterates. Otherwise `${totalFiles - + // skippedRegistryPrimaryFiles}` drifts upward — files iterated before later + // registry-primary skips have been seen carry an inflated denominator, and + // the ratio only self-corrects after every file has been classified. Pre- + // count runs only on the enabled path so the disabled path stays free of + // the extra Map iteration. Defaults to 0 on the disabled path; the live log + // gate is also disabled there, so the value is never read. + let resolvedTotal = 0; + if (profileCalls) { + for (const filePath of byFile.keys()) { + const lang = getLanguageFromFilename(filePath); + if (!lang || !isRegistryPrimary(lang)) resolvedTotal++; + } + } for (const [filePath, calls] of byFile) { filesProcessed++; @@ -2920,7 +2963,19 @@ export const processCallsFromExtracted = async ( // Registry-primary gate: skip Python (etc.) entirely when the // scope-based phase owns CALLS for this language. const fileLanguage = getLanguageFromFilename(filePath); - if (fileLanguage && isRegistryPrimary(fileLanguage)) continue; + if (fileLanguage && isRegistryPrimary(fileLanguage)) { + skippedRegistryPrimaryFiles++; + continue; + } + + resolvedFiles++; + const tFile = startTimer(profileCalls); + + if (profileCalls && (resolvedFiles === 1 || resolvedFiles % logEveryN === 0)) { + logDeferredProfile( + `calls ${resolvedFiles}/${resolvedTotal} file=${filePath} sites=${calls.length}`, + ); + } ctx.enableCache(filePath); const widenCache: WidenCache = new Map(); @@ -3079,6 +3134,25 @@ export const processCallsFromExtracted = async ( } ctx.clearCache(); + + if (tFile !== null) { + const elapsed = profileElapsedMs(tFile); + if (elapsed >= slowFileMs) { + logDeferredProfile( + `slow file ${elapsed.toFixed(0)}ms path=${filePath} calls=${calls.length} lang=${fileLanguage ?? 'unknown'}`, + ); + } + } + } + + if (profileCalls) { + logDeferredProfile( + `processCallsFromExtracted done: ${totalFiles} files, ${extractedCalls.length} call sites, skipped registry-primary files=${skippedRegistryPrimaryFiles}`, + ); + const droppedCount = getDeferredProfileDroppedCount(); + if (droppedCount > 0) { + logDeferredProfile(`note: ${droppedCount} profile log lines dropped (logger errors)`); + } } onProgress?.(totalFiles, totalFiles); diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 453baca20..874c98b89 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -231,6 +231,7 @@ const cCppExtractFunctionName = ( c?.type === 'qualified_identifier' || c?.type === 'identifier' || c?.type === 'field_identifier' || + c?.type === 'operator_name' || c?.type === 'parenthesized_declarator' ) { innerDeclarator = c; @@ -244,7 +245,7 @@ const cCppExtractFunctionName = ( if (!nameNode) { for (let i = 0; i < innerDeclarator.childCount; i++) { const c = innerDeclarator.child(i); - if (c?.type === 'identifier') { + if (c?.type === 'identifier' || c?.type === 'operator_name') { nameNode = c; break; } @@ -256,7 +257,8 @@ const cCppExtractFunctionName = ( } } else if ( innerDeclarator?.type === 'identifier' || - innerDeclarator?.type === 'field_identifier' + innerDeclarator?.type === 'field_identifier' || + innerDeclarator?.type === 'operator_name' ) { // field_identifier is used for method names inside C++ class bodies funcName = innerDeclarator.text; @@ -275,7 +277,7 @@ const cCppExtractFunctionName = ( if (!nameNode) { for (let i = 0; i < nestedId.childCount; i++) { const c = nestedId.child(i); - if (c?.type === 'identifier') { + if (c?.type === 'identifier' || c?.type === 'operator_name') { nameNode = c; break; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 4e4cb4dcf..de8cd058e 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -182,17 +182,41 @@ export function emitCppScopeCaptures( grouped['@reference.call.free'] ?? grouped['@reference.call.member'] ?? grouped['@reference.call.qualified']; + const operatorAnchor = grouped['@reference.operator']; + if (operatorAnchor !== undefined) { + const operatorNode = + callAnchor !== undefined + ? findNodeAtRange(tree.rootNode, callAnchor.range, 'binary_expression') + : null; + if (operatorNode !== null && isPrimitiveOnlyBinaryOperator(operatorNode)) continue; + } if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) { - const callNode = findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression'); - if (callNode !== null) { + const callNode = + findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ?? + findNodeAtRange(tree.rootNode, callAnchor.range, 'binary_expression'); + if (callNode?.type === 'call_expression') { grouped['@reference.arity'] = syntheticCapture( '@reference.arity', callNode, String(computeCppCallArity(callNode)), ); + } else if (callNode?.type === 'binary_expression') { + grouped['@reference.arity'] = syntheticCapture( + '@reference.arity', + callNode, + grouped['@reference.call.member'] !== undefined ? '1' : '2', + ); } } + if (operatorAnchor !== undefined && grouped['@reference.name'] === undefined) { + grouped['@reference.name'] = syntheticCapture( + '@reference.name', + findNodeAtRange(tree.rootNode, operatorAnchor.range, operatorAnchor.text) ?? tree.rootNode, + `operator${operatorAnchor.text}`, + ); + } + // ── Enrich constructor calls (new Foo()) with arity ───────────── const ctorCallAnchor = grouped['@reference.call.constructor']; if (ctorCallAnchor !== undefined && grouped['@reference.arity'] === undefined) { @@ -211,9 +235,13 @@ export function emitCppScopeCaptures( if (anyCallAnchor !== undefined && grouped['@reference.parameter-types'] === undefined) { const cNode = findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'call_expression') ?? - findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'new_expression'); + findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'new_expression') ?? + findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'binary_expression'); if (cNode !== null) { - const argTypes = inferCppCallArgTypes(cNode); + const argTypes = + cNode.type === 'binary_expression' + ? inferCppBinaryOperatorArgTypes(cNode, grouped['@reference.call.free'] !== undefined) + : inferCppCallArgTypes(cNode); if (argTypes !== undefined && argTypes.length > 0) { grouped['@reference.parameter-types'] = syntheticCapture( '@reference.parameter-types', @@ -221,7 +249,13 @@ export function emitCppScopeCaptures( JSON.stringify(argTypes), ); } - const argTypeClasses = inferCppCallArgTypeClasses(cNode); + const argTypeClasses = + cNode.type === 'binary_expression' + ? inferCppBinaryOperatorArgTypeClasses( + cNode, + grouped['@reference.call.free'] !== undefined, + ) + : inferCppCallArgTypeClasses(cNode); if (argTypeClasses !== undefined && argTypeClasses.length > 0) { grouped['@reference.parameter-type-classes'] = syntheticCapture( '@reference.parameter-type-classes', @@ -716,6 +750,69 @@ function inferCppCallArgTypeClasses(node: SyntaxNode): ParameterTypeClass[] | un return classes.length > 0 ? classes : undefined; } +function inferCppBinaryOperatorArgTypes( + node: SyntaxNode, + includeLeftOperand: boolean, +): string[] | undefined { + const operands = binaryOperatorOperands(node, includeLeftOperand); + if (operands.length === 0) return undefined; + const types = operands.map(inferCppExpressionType); + return types.length > 0 ? types : undefined; +} + +function inferCppBinaryOperatorArgTypeClasses( + node: SyntaxNode, + includeLeftOperand: boolean, +): ParameterTypeClass[] | undefined { + const operands = binaryOperatorOperands(node, includeLeftOperand); + if (operands.length === 0) return undefined; + const classes = operands.map(inferCppExpressionTypeClass); + return classes.length > 0 ? classes : undefined; +} + +function binaryOperatorOperands(node: SyntaxNode, includeLeftOperand: boolean): SyntaxNode[] { + const operands: SyntaxNode[] = []; + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (includeLeftOperand && left !== null) operands.push(left); + if (right !== null) operands.push(right); + return operands; +} + +function isPrimitiveOnlyBinaryOperator(node: SyntaxNode): boolean { + const operands = binaryOperatorOperands(node, true); + return operands.length > 0 && operands.every((operand) => isBuiltinOperatorType(operand)); +} + +function isBuiltinOperatorType(node: SyntaxNode): boolean { + const type = inferCppExpressionType(node); + return ( + type === 'bool' || + type === 'char' || + type === 'double' || + type === 'float' || + type === 'int' || + type === 'long' || + type === 'short' || + type === 'signed' || + type === 'unsigned' + ); +} + +function inferCppExpressionType(node: SyntaxNode): string { + const litType = inferCppLiteralType(node); + if (litType !== '') return litType; + if (node.type === 'identifier') return lookupDeclaredTypeForIdentifier(node); + return ''; +} + +function inferCppExpressionTypeClass(node: SyntaxNode): ParameterTypeClass { + const litType = inferCppLiteralType(node); + if (litType !== '') return valueTypeClass(litType); + if (node.type === 'identifier') return lookupDeclaredTypeClassForIdentifier(node); + return unknownTypeClass('unknown'); +} + function valueTypeClass(base: string): ParameterTypeClass { return { base, cv: 'none', indirection: 'value', pointerDepth: 0 }; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts index 70d544e3d..d42b586ce 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/query.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts @@ -98,6 +98,12 @@ const CPP_SCOPE_QUERY = ` declarator: (qualified_identifier name: (identifier) @declaration.name))) @declaration.method +;; Out-of-class operator method: Point::operator+(...) +(function_definition + declarator: (function_declarator + declarator: (qualified_identifier + name: (operator_name) @declaration.name))) @declaration.method + ;; ─── Declarations — out-of-class method with pointer return ───────── (function_definition declarator: (pointer_declarator @@ -130,6 +136,11 @@ const CPP_SCOPE_QUERY = ` declarator: (function_declarator declarator: (field_identifier) @declaration.name)) @declaration.method +;; Inline operator method in class body: Point operator+(Point) const { ... } +(function_definition + declarator: (function_declarator + declarator: (operator_name) @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 @@ -145,17 +156,49 @@ const CPP_SCOPE_QUERY = ` (function_declarator declarator: (field_identifier) @declaration.name))) @declaration.method +;; Inline operator method with reference return: Point& operator+=(Point) { ... } +(field_declaration_list + (function_definition + declarator: (reference_declarator + (function_declarator + declarator: (operator_name) @declaration.name))) @declaration.method) + +;; Free operator definition with reference return: std::ostream& operator<<(...) { ... } +(translation_unit + (function_definition + declarator: (reference_declarator + (function_declarator + declarator: (operator_name) @declaration.name))) @declaration.function) + +(namespace_definition + body: (declaration_list + (function_definition + declarator: (reference_declarator + (function_declarator + declarator: (operator_name) @declaration.name))) @declaration.function)) + ;; ─── Declarations — function prototype (forward declaration) ──────── (declaration declarator: (function_declarator declarator: (identifier) @declaration.name)) @declaration.function +;; Free operator prototype: std::ostream& operator<<(std::ostream&, T) +(declaration + declarator: (function_declarator + declarator: (operator_name) @declaration.name)) @declaration.function + ;; ─── Declarations — function prototype with pointer return ────────── (declaration declarator: (pointer_declarator declarator: (function_declarator declarator: (identifier) @declaration.name))) @declaration.function +;; Free operator prototype with reference return. +(declaration + declarator: (reference_declarator + (function_declarator + declarator: (operator_name) @declaration.name))) @declaration.function + ;; ─── Declarations — typedef ───────────────────────────────────────── (type_definition declarator: (type_identifier) @declaration.name) @declaration.typedef @@ -171,6 +214,11 @@ const CPP_SCOPE_QUERY = ` declarator: (function_declarator declarator: (field_identifier) @declaration.name)) @declaration.method +;; Operator method prototype in class body: Point operator+(Point) const; +(field_declaration + declarator: (function_declarator + declarator: (operator_name) @declaration.name)) @declaration.method + ;; Method prototype with pointer return: User* lookup(int id); (field_declaration declarator: (pointer_declarator @@ -183,6 +231,11 @@ const CPP_SCOPE_QUERY = ` (function_declarator declarator: (field_identifier) @declaration.name))) @declaration.method +(field_declaration + declarator: (reference_declarator + (function_declarator + declarator: (operator_name) @declaration.name))) @declaration.method + ;; ─── Declarations — fields ────────────────────────────────────────── (field_declaration declarator: (field_identifier) @declaration.name) @declaration.field @@ -473,6 +526,22 @@ const CPP_SCOPE_QUERY = ` argument: (_) @reference.receiver field: (field_identifier) @reference.name)) @reference.call.member +;; Conservative operator-call support (#1636): model a + b as a +;; member-style operator+ lookup, and lhs << rhs as a free +;; operator<< lookup. Free operator+(T,T), member operator<<, and +;; complex operand expressions remain false negatives for now. +;; Built-in operators remain unresolved because no user-defined +;; operator target exists. +(binary_expression + left: (_) @reference.receiver + operator: "+" @reference.operator + right: (_)) @reference.call.member + +(binary_expression + left: (_) + operator: "<<" @reference.operator + right: (_)) @reference.call.free + ;; ─── References — template calls (func()) ──────────────────────── (call_expression function: (template_function diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index 5f69a2713..591e79bfc 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -20,14 +20,26 @@ import { * Kotlin is intentionally registered but not yet listed in * `MIGRATED_LANGUAGES`, matching the Java migration pattern from #1482: * the resolver can run in shadow/forced mode, while production default - * stays on the legacy DAG until registry-primary parity reaches the - * RFC threshold. Forced mode currently passes 154/175 fixtures (88%), - * including core import, receiver, companion, default-param, vararg, - * constructor, local assignment-chain, and collection-iteration fixtures. - * Remaining gaps are advanced TypeEnv behaviors such as smart casts, - * cross-file iterable return propagation, method-chain fixpoint cases, - * overload target-id selection, virtual dispatch, and interface default - * method dispatch. + * stays on the legacy DAG until the RFC flip criteria in #1746 are met. + * + * **Forced-mode parity (`REGISTRY_PRIMARY_KOTLIN=1`):** 175/175 fixtures + * after the migration sub-issues #1758–#1763 closed. Covers core + * import, receiver, companion, default-param, vararg, constructor, + * local assignment-chain, collection-iteration, smart casts + * (`when (x) { is T -> … }` and `if (x is T)` — #1758), cross-file + * iterable return propagation (#1759), single-level method-chain + * fixpoint receiver types (#1760), parameter-type-narrowed overload + * target-id selection (#1761), virtual dispatch via constructor RHS + * (`val x: Animal = Dog()` — #1762), and interface default-method + * dispatch via implements-split MRO (#1763). + * + * **Remaining pre-flip blockers (#1746):** #1755 (forced-mode preview + * CI workflow — obviated once Kotlin lands in `MIGRATED_LANGUAGES` + * because the existing scope-parity matrix auto-discovers it), #1756 + * (companion vs instance member dispatch), and #1757 (lambda scopes + * and lambda-parameter bindings). The flip PR adds + * `SupportedLanguages.Kotlin` to `MIGRATED_LANGUAGES` after the named + * blockers close. */ export const kotlinScopeResolver: ScopeResolver = { language: SupportedLanguages.Kotlin, diff --git a/gitnexus/src/core/ingestion/model/heritage-map.ts b/gitnexus/src/core/ingestion/model/heritage-map.ts index ae89022b4..4cee244e3 100644 --- a/gitnexus/src/core/ingestion/model/heritage-map.ts +++ b/gitnexus/src/core/ingestion/model/heritage-map.ts @@ -15,6 +15,10 @@ import type { ResolutionContext } from './resolution-context.js'; import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared'; +import { + isDeferredResolutionProfileEnabled, + logDeferredProfile, +} from '../utils/deferred-resolution-profile.js'; // --------------------------------------------------------------------------- // ExtractedHeritage — the shape produced by the parse worker / heritage @@ -176,11 +180,35 @@ export const buildHeritageMap = ( // interfaceName → Set (implementor lookup for interface dispatch) const implementorFiles = new Map>(); + const profileHeritage = isDeferredResolutionProfileEnabled(); + let maxNameCartesian = 0; + let ambiguousHeritageRecords = 0; + let unresolvedChildLookups = 0; + let unresolvedParentLookups = 0; + for (const h of heritage) { // ── Parent lookup (nodeId-based) ──────────────────────────────── const childDefs = ctx.model.types.lookupClassByName(h.className); const parentDefs = ctx.model.types.lookupClassByName(h.parentName); + // Unresolved-side counters live in a separate guard so they observe + // records the ambiguity block below skips. On JVM monorepos the + // pathological fan-out case is precisely "many same-named children + // with an unresolved external supertype" (or the inverse) — both + // sides non-empty is the case `ambiguousHeritageRecords` already + // covers; the unresolved cases were silently dropped from the + // metric before this counter. + if (profileHeritage) { + if (childDefs.length === 0) unresolvedChildLookups++; + if (parentDefs.length === 0) unresolvedParentLookups++; + } + + if (profileHeritage && childDefs.length > 0 && parentDefs.length > 0) { + const product = childDefs.length * parentDefs.length; + if (product > 1) ambiguousHeritageRecords++; + if (product > maxNameCartesian) maxNameCartesian = product; + } + if (childDefs.length > 0 && parentDefs.length > 0) { for (const child of childDefs) { for (const parent of parentDefs) { @@ -368,6 +396,17 @@ export const buildHeritageMap = ( return implementorFiles.get(interfaceName) ?? EMPTY_SET; }; + if (profileHeritage) { + logDeferredProfile( + `buildHeritageMap: ${heritage.length} heritage records, ` + + `${ambiguousHeritageRecords} with child×parent lookup product >1, ` + + `max product ${maxNameCartesian}, ` + + `${unresolvedChildLookups} unresolved child lookups, ` + + `${unresolvedParentLookups} unresolved parent lookups, ` + + `${implementorFiles.size} interface implementor keys`, + ); + } + return { getParents, getAncestors, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index 10e4557d2..bf954b5cf 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -71,6 +71,12 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { isDev } from '../utils/env.js'; import { isVerboseIngestionEnabled } from '../utils/verbose.js'; +import { + endTimer, + isDeferredResolutionProfileEnabled, + logDeferredProfile, + startTimer, +} from '../utils/deferred-resolution-profile.js'; import { synthesizeWildcardImportBindings, needsSynthesis } from './wildcard-synthesis.js'; import { extractORMQueriesInline } from './orm-extraction.js'; @@ -698,7 +704,15 @@ export async function runChunkedParseAndResolve( // heritage: 75 -> 80 (5) // routes: 80 -> 85 (5) // calls: 85 -> 95 (10) + const deferredProfile = isDeferredResolutionProfileEnabled(); + if (deferredProfile) { + logDeferredProfile( + `deferred band start: imports=${deferredWorkerImports.length} heritage=${deferredWorkerHeritage.length} ` + + `calls=${deferredWorkerCalls.length} routes=${allExtractedRoutes.length}`, + ); + } if (deferredWorkerImports.length > 0) { + const tImports = startTimer(deferredProfile); await processImportsFromExtracted( graph, allPathObjects, @@ -721,6 +735,11 @@ export async function runChunkedParseAndResolve( repoPath, importCtx, ); + endTimer( + tImports, + (ms) => + `processImportsFromExtracted: ${ms.toFixed(0)}ms (${deferredWorkerImports.length} import batches before drain)`, + ); // U15 (lightweight M1): processImportsFromExtracted is the sole // consumer of `deferredWorkerImports`. Free the array now so the // GC can reclaim the per-file ExtractedImport records before the @@ -732,8 +751,10 @@ export async function runChunkedParseAndResolve( deferredWorkerImports.length = 0; } if (anyChunkNeedsWildcardSynth) { + const tWildcard = startTimer(deferredProfile); synthesizeWildcardImportBindings(graph, ctx); hasSynthesized = true; + endTimer(tWildcard, (ms) => `synthesizeWildcardImportBindings: ${ms.toFixed(0)}ms`); } // L5 from PR #1693 review: populate `exportedTypeMap` from the in-progress // graph BEFORE `seedCrossFileReceiverTypes` runs. Previously the seeding @@ -754,11 +775,22 @@ export async function runChunkedParseAndResolve( ctx.namedImportMap, exportedTypeMap, ); - if (isDev && enrichedCount > 0) { - logger.info(`🔗 E1: Seeded ${enrichedCount} cross-file receiver types (all chunks)`); + if (enrichedCount > 0) { + // Two independent gates, not else-if: when both isDev AND + // deferredProfile are active, BOTH lines fire — log scrapers keyed + // on the original "🔗 E1" emoji marker keep matching, AND operators + // grepping the [deferred-profile] prefix see no gap between the + // wildcard-synth and heritage timings. + if (isDev) { + logger.info(`🔗 E1: Seeded ${enrichedCount} cross-file receiver types (all chunks)`); + } + if (deferredProfile) { + logDeferredProfile(`E1: seeded ${enrichedCount} cross-file receiver types (all chunks)`); + } } } if (deferredWorkerHeritage.length > 0) { + const tHeritage = startTimer(deferredProfile); await processHeritageFromExtracted(graph, deferredWorkerHeritage, ctx, (current, total) => { const ratio = total > 0 ? current / total : 1; onProgress({ @@ -773,8 +805,14 @@ export async function runChunkedParseAndResolve( }, }); }); + endTimer( + tHeritage, + (ms) => + `processHeritageFromExtracted: ${ms.toFixed(0)}ms (${deferredWorkerHeritage.length} records)`, + ); } if (allExtractedRoutes.length > 0) { + const tRoutes = startTimer(deferredProfile); await processRoutesFromExtracted(graph, allExtractedRoutes, ctx, (current, total) => { const ratio = total > 0 ? current / total : 1; onProgress({ @@ -789,12 +827,25 @@ export async function runChunkedParseAndResolve( }, }); }); + endTimer( + tRoutes, + (ms) => + `processRoutesFromExtracted: ${ms.toFixed(0)}ms (${allExtractedRoutes.length} routes)`, + ); } - const fullWorkerHeritageMap = - deferredWorkerHeritage.length > 0 - ? buildHeritageMap(deferredWorkerHeritage, ctx, getHeritageStrategyForLanguage) - : undefined; + let fullWorkerHeritageMap: ReturnType | undefined; + if (deferredWorkerHeritage.length > 0) { + const tBuildHeritage = startTimer(deferredProfile); + fullWorkerHeritageMap = buildHeritageMap( + deferredWorkerHeritage, + ctx, + getHeritageStrategyForLanguage, + ); + endTimer(tBuildHeritage, (ms) => `buildHeritageMap wall: ${ms.toFixed(0)}ms`); + } else if (deferredProfile) { + logDeferredProfile('buildHeritageMap: skipped (no heritage records)'); + } // U15 (lightweight M1): buildHeritageMap is the LAST consumer of the // raw `deferredWorkerHeritage` records — processCallsFromExtracted // below reads from the derived `fullWorkerHeritageMap` instead. Free @@ -804,6 +855,12 @@ export async function runChunkedParseAndResolve( deferredWorkerHeritage.length = 0; if (deferredWorkerCalls.length > 0) { + if (deferredProfile) { + logDeferredProfile( + `processCallsFromExtracted: starting (${deferredWorkerCalls.length} call sites, heritageMap=${fullWorkerHeritageMap !== undefined})`, + ); + } + const tCalls = startTimer(deferredProfile); await processCallsFromExtracted( graph, deferredWorkerCalls, @@ -829,6 +886,7 @@ export async function runChunkedParseAndResolve( fullWorkerHeritageMap, bindingAccumulator, ); + endTimer(tCalls, (ms) => `processCallsFromExtracted: ${ms.toFixed(0)}ms total`); } if (deferredAssignments.length > 0) { diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index e050c6553..94fc172dc 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -37,6 +37,7 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import { parseTruthyEnv } from './utils/env.js'; /** * Languages whose RFC #909 Ring 3 scope-resolution migration is complete. @@ -115,10 +116,6 @@ export function primaryLanguages(): ReadonlySet { // ─── Internal ─────────────────────────────────────────────────────────────── -/** Accepted truthy strings (case-insensitive, trimmed). */ -const TRUTHY_VALUES: ReadonlySet = new Set(['true', '1', 'yes']); - function parseFlag(raw: string | undefined): boolean { - if (raw === undefined) return false; - return TRUTHY_VALUES.has(raw.trim().toLowerCase()); + return parseTruthyEnv(raw); } diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 09080d2c6..a737214b3 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -1048,6 +1048,7 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@type-binding.type', '@reference.name', '@reference.receiver', + '@reference.operator', '@reference.arity', '@reference.parameter-types', '@reference.parameter-type-classes', diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index f02ae2cb2..f67823c8c 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -702,7 +702,9 @@ export const CPP_QUERIES = ` ; Functions & Methods (direct declarator) (function_definition declarator: (function_declarator declarator: (identifier) @name)) @definition.function +(function_definition declarator: (function_declarator declarator: (operator_name) @name)) @definition.function (function_definition declarator: (function_declarator declarator: (qualified_identifier name: (identifier) @name))) @definition.method +(function_definition declarator: (function_declarator declarator: (qualified_identifier name: (operator_name) @name))) @definition.method ; Functions/methods returning pointers (pointer_declarator wraps function_declarator) (function_definition declarator: (pointer_declarator declarator: (function_declarator declarator: (identifier) @name))) @definition.function @@ -714,14 +716,18 @@ export const CPP_QUERIES = ` ; Functions/methods returning references (reference_declarator wraps function_declarator) (function_definition declarator: (reference_declarator (function_declarator declarator: (identifier) @name))) @definition.function +(function_definition declarator: (reference_declarator (function_declarator declarator: (operator_name) @name))) @definition.function (function_definition declarator: (reference_declarator (function_declarator declarator: (qualified_identifier name: (identifier) @name)))) @definition.method +(function_definition declarator: (reference_declarator (function_declarator declarator: (qualified_identifier name: (operator_name) @name)))) @definition.method ; Destructors (destructor_name is distinct from identifier in tree-sitter-cpp) (function_definition declarator: (function_declarator declarator: (qualified_identifier name: (destructor_name) @name))) @definition.method ; Function declarations / prototypes (common in headers) (declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.function +(declaration declarator: (function_declarator declarator: (operator_name) @name)) @definition.function (declaration declarator: (pointer_declarator declarator: (function_declarator declarator: (identifier) @name))) @definition.function +(declaration declarator: (reference_declarator (function_declarator declarator: (operator_name) @name))) @definition.function ; Class/struct data member fields (Address address; int count;) ; Uses field_identifier to exclude method declarations (which use function_declarator) @@ -740,13 +746,13 @@ export const CPP_QUERIES = ` ; Inline class method declarations (inside class body, no body: void save();) ; tree-sitter-cpp uses field_identifier (not identifier) for names inside class bodies -(field_declaration declarator: (function_declarator declarator: [(field_identifier) (identifier)] @name)) @definition.method +(field_declaration declarator: (function_declarator declarator: [(field_identifier) (identifier) (operator_name)] @name)) @definition.method ; Inline class method declarations returning a pointer (User* lookup();) (field_declaration declarator: (pointer_declarator declarator: (function_declarator declarator: [(field_identifier) (identifier)] @name))) @definition.method ; Inline class method declarations returning a reference (User& lookup();) -(field_declaration declarator: (reference_declarator (function_declarator declarator: [(field_identifier) (identifier)] @name))) @definition.method +(field_declaration declarator: (reference_declarator (function_declarator declarator: [(field_identifier) (identifier) (operator_name)] @name))) @definition.method ; Inline class method definitions (inside class body, with body: void Foo() { ... }) (field_declaration_list @@ -785,6 +791,8 @@ export const CPP_QUERIES = ` (call_expression function: (field_expression field: (field_identifier) @call.name)) @call (call_expression function: (qualified_identifier name: (identifier) @call.name)) @call (call_expression function: (template_function name: (identifier) @call.name)) @call +(binary_expression operator: "+" @call.name) @call +(binary_expression operator: "<<" @call.name) @call ; Constructor calls: new User() (new_expression type: (type_identifier) @call.name) @call diff --git a/gitnexus/src/core/ingestion/utils/deferred-resolution-profile.ts b/gitnexus/src/core/ingestion/utils/deferred-resolution-profile.ts new file mode 100644 index 000000000..697ca3bb6 --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/deferred-resolution-profile.ts @@ -0,0 +1,120 @@ +/** + * Wall-clock logging for the post-chunk deferred resolution band + * (imports → heritage → heritage map → legacy call resolution). + * + * Enabled when either: + * - `GITNEXUS_VERBOSE=1` / `gitnexus analyze -v` (primary path for #1741), or + * - `GITNEXUS_PROFILE_DEFERRED=1` (force on without full verbose ingestion noise) + * + * Issue #1741: large Java/Kotlin repos appear stuck at "Resolving calls" + * because the UI progress bar updates every 100 files and intermediate + * stages emit little to the log. + */ + +import { logger } from '../../logger.js'; +import { parseTruthyEnv } from './env.js'; +import { isVerboseIngestionEnabled } from './verbose.js'; + +// Module-private tuning constants for the gates below. Not exported — these +// are internal knobs, not part of the module's API surface. +const LOG_EVERY_N_VERBOSE = 10; +const LOG_EVERY_N_PROFILE = 100; +const DEFAULT_SLOW_MS_VERBOSE = 3_000; +const DEFAULT_SLOW_MS = 5_000; + +/** True when deferred-stage timing / progress logs should emit. */ +export const isDeferredResolutionProfileEnabled = (): boolean => + isVerboseIngestionEnabled() || parseTruthyEnv(process.env.GITNEXUS_PROFILE_DEFERRED); + +/** Log a call-resolution progress line every N files (finer when verbose). */ +export const deferredCallLogEveryN = (): number => + isVerboseIngestionEnabled() ? LOG_EVERY_N_VERBOSE : LOG_EVERY_N_PROFILE; + +/** Per-file call-resolution log threshold (ms). Lower default when verbose. */ +export const deferredCallFileSlowMs = (): number => { + const raw = process.env.GITNEXUS_PROFILE_DEFERRED_SLOW_MS; + if (raw) { + // Use Number() not parseInt: parseInt('1e9', 10) === 1 (prefix-parses, drops the exponent), + // which would turn a user-intended "effectively disabled" threshold into a 1 ms log storm. + const n = Number(raw); + if (Number.isFinite(n) && n > 0) return n; + } + return isVerboseIngestionEnabled() ? DEFAULT_SLOW_MS_VERBOSE : DEFAULT_SLOW_MS; +}; + +export const profileNow = (): bigint => process.hrtime.bigint(); + +export const profileElapsedMs = (start: bigint): number => + Number(process.hrtime.bigint() - start) / 1e6; + +// Module-private counter for `[deferred-profile]` log lines the underlying +// logger refused to accept. Pino's SonicBoom transport is sync:false today, +// so steady-state `logger.info(string)` calls don't throw — but first-use +// construction paths (pino-pretty resolve, level validation) and any future +// transport reconfiguration could. The wrap below catches and counts so a +// failing logger cannot abort the deferred band, and the count surfaces in +// the deferred-band done-summary (see processCallsFromExtracted) so the +// failure is visible rather than silently swallowed (DoD §2.8). +let droppedLogLines = 0; + +/** + * Number of `logDeferredProfile` calls whose underlying `logger.info` threw. + * Surfaced in the deferred-band done-summary when greater than zero. + */ +export const getDeferredProfileDroppedCount = (): number => droppedLogLines; + +/** + * Reset the dropped-line counter. Call from test `afterEach` to keep the + * module-private state from leaking across tests. Also used inside + * `processCallsFromExtracted` at function entry so each analyze run gets + * a fresh count rather than accumulating across the process lifetime. + */ +export const resetDeferredProfileDroppedCount = (): void => { + droppedLogLines = 0; +}; + +export const logDeferredProfile = (message: string): void => { + try { + logger.info(`[deferred-profile] ${message}`); + } catch { + // Do not call the failing logger from the handler — that would risk + // an infinite loop if the failure mode is steady-state. Just count. + droppedLogLines++; + } +}; + +/** + * Capture a monotonic timestamp when profiling is enabled; otherwise return null. + * Pair with `endTimer` so the type system narrows correctly — using `null` instead + * of a `0n` sentinel makes "profiling disabled" structurally distinct from + * "zero elapsed time" and lets TypeScript catch missing guards. + */ +export const startTimer = (enabled: boolean): bigint | null => + enabled ? process.hrtime.bigint() : null; + +/** + * Emit a `[deferred-profile]` log line for a captured timer. No-op when the + * timer is `null` (profiling was disabled at capture time). The formatter + * receives elapsed ms so the call sites stay readable. + * + * The format callback runs inside a try/catch so a throwing formatter + * (custom toString, JSON.stringify on a circular object) cannot abort the + * deferred resolution band — observability code must never escalate to a + * load-bearing failure. On catch we emit a single `formatter error: …` + * line via logDeferredProfile and return; the caller's stage continues + * as if profiling had no-op'd for this timer. DoD §2.8 ("no silent + * catches that swallow diagnostics") is satisfied by surfacing the + * failure message rather than dropping it. + */ +export const endTimer = (start: bigint | null, format: (elapsedMs: number) => string): void => { + if (start === null) return; + const elapsedMs = profileElapsedMs(start); + let message: string; + try { + message = format(elapsedMs); + } catch (err) { + logDeferredProfile(`formatter error: ${err instanceof Error ? err.message : String(err)}`); + return; + } + logDeferredProfile(message); +}; diff --git a/gitnexus/src/core/ingestion/utils/env.ts b/gitnexus/src/core/ingestion/utils/env.ts index 6cb9d549f..5beeb818f 100644 --- a/gitnexus/src/core/ingestion/utils/env.ts +++ b/gitnexus/src/core/ingestion/utils/env.ts @@ -10,6 +10,24 @@ /** Whether we're running in development mode (enables verbose console logging). */ export const isDev = process.env.NODE_ENV === 'development'; +/** + * Parse a narrow-form truthy env-var value. Accepts `'1'`, `'true'`, `'yes'` + * (case-insensitive, whitespace-trimmed). Anything else — including + * `undefined`, empty string, `'0'`, `'false'`, `'no'`, or unknown tokens — + * returns `false`. + * + * This is the shared helper for narrow-form truthy parsing across the + * ingestion module. `logger.ts` uses a broader negative-list form + * (`isTruthyEnv`) that intentionally accepts anything except a small set of + * falsy tokens — that lives separately because it follows pino-debug + * conventions and serves a different purpose. + */ +export const parseTruthyEnv = (raw: string | undefined): boolean => { + if (raw === undefined) return false; + const value = raw.trim().toLowerCase(); + return value === '1' || value === 'true' || value === 'yes'; +}; + /** * Whether scope-resolution dev validators (e.g. `validateBindingsImmutability`) * should run AND emit warnings. Off by default in CLI runs to avoid silent diff --git a/gitnexus/src/core/ingestion/utils/verbose.ts b/gitnexus/src/core/ingestion/utils/verbose.ts index 0e616dca9..c697a5aaa 100644 --- a/gitnexus/src/core/ingestion/utils/verbose.ts +++ b/gitnexus/src/core/ingestion/utils/verbose.ts @@ -1,6 +1,4 @@ -export const isVerboseIngestionEnabled = (): boolean => { - const raw = process.env.GITNEXUS_VERBOSE; - if (!raw) return false; - const value = raw.toLowerCase(); - return value === '1' || value === 'true' || value === 'yes'; -}; +import { parseTruthyEnv } from './env.js'; + +export const isVerboseIngestionEnabled = (): boolean => + parseTruthyEnv(process.env.GITNEXUS_VERBOSE); diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.cpp new file mode 100644 index 000000000..46a0eef98 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.cpp @@ -0,0 +1,26 @@ +#include "lib.h" + +namespace std { +ostream cout; +} + +std::ostream& operator<<(std::ostream& os, const Point& p) { + return os; +} + +void runMember(Point a, Point b) { + Point c = a + b; +} + +void runFree(Point p) { + std::cout << p; +} + +void runBuiltin() { + int x = 1 + 2; +} + +void runBuiltinVariables(int a, int b) { + int x = a + b; + int y = a << b; +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.h new file mode 100644 index 000000000..6966a680c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.h @@ -0,0 +1,23 @@ +#pragma once + +namespace std { +class ostream {}; +extern ostream cout; +} + +struct Point { + Point operator+(Point rhs) const { + return rhs; + } + + Point& operator+=(Point rhs) { + return *this; + } +}; + +std::ostream& operator<<(std::ostream& os, const Point& p); + +void runMember(Point a, Point b); +void runFree(Point p); +void runBuiltin(); +void runBuiltinVariables(int a, int b); diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index e1376938e..90bc75117 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -17,6 +17,65 @@ import { const it = createResolverParityIt('cpp'); +// --------------------------------------------------------------------------- +// C++ overloaded operators (#1636) +// --------------------------------------------------------------------------- + +describe('C++ overloaded operator call resolution (#1636)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-overloaded-operators'), () => {}); + }, 60000); + + it('resolves member operator+ for user-defined operands', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'runMember' && c.target === 'operator+', + ); + + expect(calls).toHaveLength(1); + expect(calls[0]?.targetLabel).toBe('Method'); + expect(calls[0]?.targetFilePath).toBe('lib.h'); + }); + + it('resolves free operator<< for user-defined operands', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'runFree' && c.target === 'operator<<', + ); + + expect(calls).toHaveLength(1); + expect(calls[0]?.targetLabel).toBe('Function'); + expect(calls[0]?.targetFilePath).toBe('lib.cpp'); + }); + + it('does not synthesize an operator edge for built-in int + int', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'runBuiltin' && c.target.startsWith('operator'), + ); + + expect(calls).toHaveLength(0); + }); + + it('does not synthesize operator edges for built-in int variables', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'runBuiltinVariables' && c.target.startsWith('operator'), + ); + + expect(calls).toHaveLength(0); + }); + + it('classifies reference-return inline operators as methods', () => { + const methods = getNodesByLabelFull(result, 'Method').filter((m) => m.name === 'operator+='); + const functions = getNodesByLabelFull(result, 'Function').filter( + (f) => f.name === 'operator+=', + ); + + expect(methods).toHaveLength(1); + expect(methods[0]?.properties.filePath).toBe('lib.h'); + expect(functions).toHaveLength(0); + }); +}); + // --------------------------------------------------------------------------- // Heritage: diamond inheritance + include-based imports // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/deferred-resolution-profile-wiring.test.ts b/gitnexus/test/unit/deferred-resolution-profile-wiring.test.ts new file mode 100644 index 000000000..0fe4ead86 --- /dev/null +++ b/gitnexus/test/unit/deferred-resolution-profile-wiring.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { _captureLogger } from '../../src/core/logger.js'; +import { processCallsFromExtracted } from '../../src/core/ingestion/call-processor.js'; +import { buildHeritageMap } from '../../src/core/ingestion/model/heritage-map.js'; +import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js'; +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import { + getDeferredProfileDroppedCount, + resetDeferredProfileDroppedCount, +} from '../../src/core/ingestion/utils/deferred-resolution-profile.js'; +import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js'; +import type { ExtractedCall } from '../../src/core/ingestion/workers/parse-worker.js'; + +describe('deferred-resolution-profile wiring', () => { + let cap: ReturnType; + let prevProfileDeferred: string | undefined; + let prevVerbose: string | undefined; + let prevRegistryTypeScript: string | undefined; + + beforeEach(() => { + cap = _captureLogger(); + prevProfileDeferred = process.env.GITNEXUS_PROFILE_DEFERRED; + prevVerbose = process.env.GITNEXUS_VERBOSE; + prevRegistryTypeScript = process.env.REGISTRY_PRIMARY_TYPESCRIPT; + process.env.GITNEXUS_PROFILE_DEFERRED = '1'; + delete process.env.GITNEXUS_VERBOSE; + process.env.REGISTRY_PRIMARY_TYPESCRIPT = 'false'; + }); + + afterEach(() => { + cap.restore(); + if (prevProfileDeferred === undefined) delete process.env.GITNEXUS_PROFILE_DEFERRED; + else process.env.GITNEXUS_PROFILE_DEFERRED = prevProfileDeferred; + if (prevVerbose === undefined) delete process.env.GITNEXUS_VERBOSE; + else process.env.GITNEXUS_VERBOSE = prevVerbose; + if (prevRegistryTypeScript === undefined) delete process.env.REGISTRY_PRIMARY_TYPESCRIPT; + else process.env.REGISTRY_PRIMARY_TYPESCRIPT = prevRegistryTypeScript; + resetDeferredProfileDroppedCount(); + vi.restoreAllMocks(); + }); + + const deferredMsgs = (): string[] => + cap + .records() + .map((r) => String(r.msg ?? '')) + .filter((m) => m.includes('[deferred-profile]')); + + it('buildHeritageMap emits profile stats when GITNEXUS_PROFILE_DEFERRED=1', () => { + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/a.java', 'Foo', 'class:a:Foo', 'Class'); + ctx.model.symbols.add('src/b.java', 'Foo', 'class:b:Foo', 'Class'); + ctx.model.symbols.add('src/c.java', 'Bar', 'class:c:Bar', 'Class'); + ctx.model.symbols.add('src/d.java', 'Bar', 'class:d:Bar', 'Class'); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/a.java', className: 'Foo', parentName: 'Bar', kind: 'extends' }, + ]; + + buildHeritageMap(heritage, ctx); + + expect( + deferredMsgs().some( + (m) => + m.includes('buildHeritageMap:') && + m.includes('child×parent lookup product >1') && + m.includes('max product') && + m.includes('0 unresolved child lookups') && + m.includes('0 unresolved parent lookups'), + ), + ).toBe(true); + }); + + it('buildHeritageMap counts unresolved parent lookups (U7, JVM pathological case)', () => { + const ctx = createResolutionContext(); + // Many same-named children all resolved. + ctx.model.symbols.add('src/a.java', 'Foo', 'class:a:Foo', 'Class'); + ctx.model.symbols.add('src/b.java', 'Foo', 'class:b:Foo', 'Class'); + // Parent (e.g., external library) is NOT in the symbol index — lookup + // returns []. The legacy counter would silently drop this record from + // the metric. With U7, it shows up as an unresolved-parent lookup. + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/a.java', className: 'Foo', parentName: 'ExternalBase', kind: 'extends' }, + ]; + + buildHeritageMap(heritage, ctx); + + expect(deferredMsgs().some((m) => m.includes('1 unresolved parent lookups'))).toBe(true); + expect(deferredMsgs().some((m) => m.includes('0 unresolved child lookups'))).toBe(true); + }); + + it('buildHeritageMap counts unresolved child lookups (U7, inverse case)', () => { + const ctx = createResolutionContext(); + // Parent resolved, child name not in symbol index. + ctx.model.symbols.add('src/c.java', 'Bar', 'class:c:Bar', 'Class'); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/x.java', className: 'UnknownChild', parentName: 'Bar', kind: 'extends' }, + ]; + + buildHeritageMap(heritage, ctx); + + expect(deferredMsgs().some((m) => m.includes('1 unresolved child lookups'))).toBe(true); + expect(deferredMsgs().some((m) => m.includes('0 unresolved parent lookups'))).toBe(true); + }); + + it('processCallsFromExtracted emits done summary with skipped registry-primary count', async () => { + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function'); + + const calls: ExtractedCall[] = [ + { + filePath: 'src/index.ts', + calledName: 'helper', + sourceId: 'Function:src/index.ts:main', + }, + { + filePath: 'src/main.py', + calledName: 'run', + sourceId: 'Function:src/main.py:main', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + expect( + deferredMsgs().some( + (m) => + m.includes('processCallsFromExtracted done:') && + m.includes('skipped registry-primary files=1'), + ), + ).toBe(true); + }); + + it('processCallsFromExtracted logs the first non-skipped file as 1/1 even when a registry-primary file sorts first', async () => { + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function'); + + // Python sorts before TypeScript in byFile insertion order. Before the + // fix for #4 the first per-file log was keyed on filesProcessed===1, which + // was consumed by the Python skip and never emitted for the TS file. + const calls: ExtractedCall[] = [ + { filePath: 'src/early.py', calledName: 'run', sourceId: 'Function:src/early.py:main' }, + { filePath: 'src/index.ts', calledName: 'helper', sourceId: 'Function:src/index.ts:main' }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + expect(deferredMsgs().some((m) => m.includes('calls 1/1 file=src/index.ts'))).toBe(true); + expect(deferredMsgs().some((m) => m.includes('skipped registry-primary files=1'))).toBe(true); + }); + + it('processCallsFromExtracted denominator stays stable across mixed-language interleaving (A1 pre-pass)', async () => { + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/a.ts', 'a', 'Function:src/a.ts:a', 'Function'); + ctx.model.symbols.add('src/b.ts', 'b', 'Function:src/b.ts:b', 'Function'); + ctx.model.symbols.add('src/c.ts', 'c', 'Function:src/c.ts:c', 'Function'); + ctx.model.symbols.add('src/d.ts', 'd', 'Function:src/d.ts:d', 'Function'); + + // Alternating TS / PY order: byFile = [ts, py, ts, py, ts, py, ts, py]. + // Before the U1 pre-pass, the first per-file log carried denominator 8 + // (totalFiles - 0 skips) and self-corrected only after every skip was + // observed. With the pre-pass, the denominator is 4 from the first + // emission onward — every entry uses the same resolvedTotal. + const calls: ExtractedCall[] = [ + { filePath: 'src/a.ts', calledName: 'a', sourceId: 'Function:src/a.ts:f' }, + { filePath: 'src/p1.py', calledName: 'a', sourceId: 'Function:src/p1.py:f' }, + { filePath: 'src/b.ts', calledName: 'b', sourceId: 'Function:src/b.ts:f' }, + { filePath: 'src/p2.py', calledName: 'b', sourceId: 'Function:src/p2.py:f' }, + { filePath: 'src/c.ts', calledName: 'c', sourceId: 'Function:src/c.ts:f' }, + { filePath: 'src/p3.py', calledName: 'c', sourceId: 'Function:src/p3.py:f' }, + { filePath: 'src/d.ts', calledName: 'd', sourceId: 'Function:src/d.ts:f' }, + { filePath: 'src/p4.py', calledName: 'd', sourceId: 'Function:src/p4.py:f' }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + // Every per-file emission carries `/4` (the eventual resolved-file + // total), not the in-flight `totalFiles - skippedSoFar`. + expect(deferredMsgs().some((m) => m.includes('calls 1/4 file=src/a.ts'))).toBe(true); + expect(deferredMsgs().some((m) => /calls \d+\/[^4]/.test(m))).toBe(false); + expect(deferredMsgs().some((m) => m.includes('skipped registry-primary files=4'))).toBe(true); + }); + + it('processCallsFromExtracted resets the dropped-line counter at entry (U4)', async () => { + // logger is a Proxy that vi.spyOn can't override; we seed the counter by + // directly mutating it via the public reset / observation surface. The + // test then verifies processCallsFromExtracted brings the counter back to + // zero at the start of its run. + resetDeferredProfileDroppedCount(); + // Force-bump the counter by simulating a dropped line: there's no public + // increment, but we can prove the reset happens by setting up a non-zero + // counter state via processCallsFromExtracted's own reset path called + // twice in a row — both invocations should leave the counter at zero. + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function'); + const calls: ExtractedCall[] = [ + { filePath: 'src/index.ts', calledName: 'helper', sourceId: 'Function:src/index.ts:main' }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + expect(getDeferredProfileDroppedCount()).toBe(0); + + // Second run: counter is still zero (idempotent reset). + await processCallsFromExtracted(graph, calls, ctx); + expect(getDeferredProfileDroppedCount()).toBe(0); + }); + + it('processCallsFromExtracted does not log per-file progress for registry-primary skips', async () => { + const graph = createKnowledgeGraph(); + const ctx = createResolutionContext(); + + const calls: ExtractedCall[] = [ + { + filePath: 'src/only.py', + calledName: 'run', + sourceId: 'Function:src/only.py:main', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + expect(deferredMsgs().some((m) => m.includes('calls 1/1 file=src/only.py'))).toBe(false); + expect(deferredMsgs().some((m) => m.includes('skipped registry-primary files=1'))).toBe(true); + }); +}); diff --git a/gitnexus/test/unit/deferred-resolution-profile.test.ts b/gitnexus/test/unit/deferred-resolution-profile.test.ts new file mode 100644 index 000000000..21f4a8551 --- /dev/null +++ b/gitnexus/test/unit/deferred-resolution-profile.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + deferredCallFileSlowMs, + deferredCallLogEveryN, + endTimer, + getDeferredProfileDroppedCount, + isDeferredResolutionProfileEnabled, + logDeferredProfile, + profileElapsedMs, + profileNow, + resetDeferredProfileDroppedCount, + startTimer, +} from '../../src/core/ingestion/utils/deferred-resolution-profile.js'; +import { _captureLogger } from '../../src/core/logger.js'; + +describe('deferred-resolution-profile', () => { + afterEach(() => { + delete process.env.GITNEXUS_PROFILE_DEFERRED; + delete process.env.GITNEXUS_PROFILE_DEFERRED_SLOW_MS; + delete process.env.GITNEXUS_VERBOSE; + resetDeferredProfileDroppedCount(); + vi.restoreAllMocks(); + }); + + it('is off by default', () => { + expect(isDeferredResolutionProfileEnabled()).toBe(false); + }); + + it('enables on GITNEXUS_VERBOSE=1', () => { + process.env.GITNEXUS_VERBOSE = '1'; + expect(isDeferredResolutionProfileEnabled()).toBe(true); + expect(deferredCallLogEveryN()).toBe(10); + expect(deferredCallFileSlowMs()).toBe(3000); + }); + + it('enables on GITNEXUS_PROFILE_DEFERRED=1', () => { + process.env.GITNEXUS_PROFILE_DEFERRED = '1'; + expect(isDeferredResolutionProfileEnabled()).toBe(true); + expect(deferredCallLogEveryN()).toBe(100); + }); + + it('reads slow-file threshold from env', () => { + process.env.GITNEXUS_PROFILE_DEFERRED_SLOW_MS = '250'; + expect(deferredCallFileSlowMs()).toBe(250); + }); + + describe('logDeferredProfile dropped-line counter (U4)', () => { + // Background: `logger` (gitnexus/src/core/logger.ts) is a Proxy with a lazy + // `get` trap and no `set` trap, so vi.spyOn on `logger.info` fails with + // "property is not defined on the object" — the inner pino method isn't a + // stable own-property to wrap. These tests exercise the helper API and the + // happy path; the catch arm is pinned by source-shape assertions below. + + it('counter is zero at module entry (after reset in afterEach)', () => { + expect(getDeferredProfileDroppedCount()).toBe(0); + }); + + it('does not increment when logger.info succeeds', () => { + const cap = _captureLogger(); + try { + logDeferredProfile('normal message'); + expect(getDeferredProfileDroppedCount()).toBe(0); + } finally { + cap.restore(); + } + }); + + it('multiple successful calls keep the counter at zero', () => { + const cap = _captureLogger(); + try { + logDeferredProfile('m1'); + logDeferredProfile('m2'); + logDeferredProfile('m3'); + expect(getDeferredProfileDroppedCount()).toBe(0); + } finally { + cap.restore(); + } + }); + + it('resetDeferredProfileDroppedCount returns the counter to zero', () => { + // Drive the counter via a stub since we can't spy on the Proxy. + // Mutate the counter through the public API: simulate a dropped line + // by calling logDeferredProfile inside a forced-throw context. + // Without a way to force logger.info to throw, the most we can test + // here is that reset() is idempotent on an already-zero counter and + // that the getter reads what reset wrote. + resetDeferredProfileDroppedCount(); + expect(getDeferredProfileDroppedCount()).toBe(0); + resetDeferredProfileDroppedCount(); + expect(getDeferredProfileDroppedCount()).toBe(0); + }); + + it('source defines a try/catch around the logger.info call', () => { + // Pin the catch arm via source shape — see logger Proxy note above. + const fs = require('node:fs') as typeof import('node:fs'); + const path = require('node:path') as typeof import('node:path'); + const url = require('node:url') as typeof import('node:url'); + const here = path.dirname(url.fileURLToPath(import.meta.url)); + const sourcePath = path.resolve( + here, + '../../src/core/ingestion/utils/deferred-resolution-profile.ts', + ); + const source = fs.readFileSync(sourcePath, 'utf-8'); + + expect( + /export const logDeferredProfile[\s\S]*?try \{\s*logger\.info\(`\[deferred-profile\] \$\{message\}`\);\s*\} catch[\s\S]*?droppedLogLines\+\+/.test( + source, + ), + ).toBe(true); + }); + }); + + describe('endTimer (U3 formatter exception safety)', () => { + it('emits the formatter output via [deferred-profile] when start is non-null', () => { + const cap = _captureLogger(); + try { + const start = startTimer(true); + endTimer(start, (ms) => `stage A: ${ms.toFixed(0)}ms`); + const messages = cap.records().map((r) => String(r.msg ?? '')); + expect(messages.some((m) => /\[deferred-profile\] stage A: \d+ms/.test(m))).toBe(true); + } finally { + cap.restore(); + } + }); + + it('is a no-op when start is null (profiling disabled), even if formatter would throw', () => { + const cap = _captureLogger(); + try { + const formatter = vi.fn(() => { + throw new Error('should never run'); + }); + endTimer(null, formatter); + expect(formatter).not.toHaveBeenCalled(); + expect(cap.records()).toEqual([]); + } finally { + cap.restore(); + } + }); + + it('catches a throwing formatter and emits one formatter-error line', () => { + const cap = _captureLogger(); + try { + const start = startTimer(true); + expect(() => + endTimer(start, () => { + throw new Error('boom'); + }), + ).not.toThrow(); + + const messages = cap.records().map((r) => String(r.msg ?? '')); + const errLines = messages.filter((m) => + m.includes('[deferred-profile] formatter error: boom'), + ); + expect(errLines.length).toBe(1); + } finally { + cap.restore(); + } + }); + + it('coerces non-Error throws (string, plain object) via String() in the error message', () => { + const cap = _captureLogger(); + try { + const start = startTimer(true); + endTimer(start, () => { + throw 'plain string'; + }); + const messages = cap.records().map((r) => String(r.msg ?? '')); + expect( + messages.some((m) => m.includes('[deferred-profile] formatter error: plain string')), + ).toBe(true); + } finally { + cap.restore(); + } + }); + }); + + it('profileElapsedMs converts hrtime deltas to ms with exact arithmetic', () => { + const spy = vi.spyOn(process.hrtime, 'bigint'); + try { + spy.mockReturnValueOnce(1_000_000_000n); + const start = profileNow(); + spy.mockReturnValueOnce(1_002_500_000n); + expect(profileElapsedMs(start)).toBe(2.5); + + spy.mockReturnValueOnce(5_000_000_000n); + const startZero = profileNow(); + spy.mockReturnValueOnce(5_000_000_000n); + expect(profileElapsedMs(startZero)).toBe(0); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/gitnexus/test/unit/env.test.ts b/gitnexus/test/unit/env.test.ts new file mode 100644 index 000000000..6b5e767f3 --- /dev/null +++ b/gitnexus/test/unit/env.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { parseTruthyEnv } from '../../src/core/ingestion/utils/env.js'; + +describe('parseTruthyEnv', () => { + it.each([ + ['1', true], + ['true', true], + ['yes', true], + ['TRUE', true], + ['Yes', true], + ['YES', true], + [' 1 ', true], + [' true ', true], + ['\tyes\n', true], + ])('accepts %j as truthy', (raw, expected) => { + expect(parseTruthyEnv(raw)).toBe(expected); + }); + + it.each([ + ['0', false], + ['false', false], + ['no', false], + ['off', false], + ['', false], + [' ', false], + ['maybe', false], + ['2', false], + ['truthy', false], + ['1.0', false], + ['yes please', false], + ])('rejects %j as falsy', (raw, expected) => { + expect(parseTruthyEnv(raw)).toBe(expected); + }); + + it('returns false for undefined', () => { + expect(parseTruthyEnv(undefined)).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/parse-impl-e1-emission-shape.test.ts b/gitnexus/test/unit/parse-impl-e1-emission-shape.test.ts new file mode 100644 index 000000000..55b3a19ca --- /dev/null +++ b/gitnexus/test/unit/parse-impl-e1-emission-shape.test.ts @@ -0,0 +1,63 @@ +/** + * U2 (A2 from PR #1773 review) — regression guard for the E1 enrichment + * log dual-emission shape. + * + * The E1 line at the top of `runChunkedParseAndResolve`'s post-chunk band + * has two independent emission targets: + * - `logger.info('🔗 E1: Seeded …')` for the `isDev` path (dev-mode log + * scrapers still match the original emoji marker). + * - `logDeferredProfile('E1: seeded …')` for the `GITNEXUS_PROFILE_DEFERRED` + * path (operators grepping the [deferred-profile] prefix see no gap + * between wildcard-synth and heritage timings). + * + * When both flags are set, BOTH lines must fire. The original code used + * `if (isDev) { ... } else if (deferredProfile) { ... }` which is mutually + * exclusive and silently swallowed the [deferred-profile] line on combined- + * flag runs. This pin guards against the regression returning. + * + * Driving the four-case truth table via the real pipeline requires the + * worker path (`deferredWorkerCalls` only populates from chunk-worker + * extraction), which is slow and harness-dependent. A source-shape pin is + * the right test scope for a purely structural change — and is exactly + * how downstream readers grep for the regression anyway. + */ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PARSE_IMPL_PATH = path.resolve( + __dirname, + '../../src/core/ingestion/pipeline-phases/parse-impl.ts', +); + +describe('parse-impl E1 dual-emission shape (U2)', () => { + const source = fs.readFileSync(PARSE_IMPL_PATH, 'utf-8'); + + it('has a standalone `if (isDev)` branch emitting the original emoji line', () => { + expect(/if \(isDev\) \{\s*logger\.info\(`🔗 E1: Seeded \$\{enrichedCount\}/.test(source)).toBe( + true, + ); + }); + + it('has a standalone `if (deferredProfile)` branch emitting the [deferred-profile] line', () => { + expect( + /if \(deferredProfile\) \{\s*logDeferredProfile\(`E1: seeded \$\{enrichedCount\}/.test( + source, + ), + ).toBe(true); + }); + + it('does not chain the E1 branches via `else if`', () => { + // Tight regex anchored to the closing `}` of the isDev branch — confirms + // the very next token is `if` (independent branch) not `else if` (mutually + // exclusive). Unrelated `else if (deferredProfile)` later in the file + // (e.g., the buildHeritageMap-skipped log) is outside this window. + expect( + /if \(isDev\) \{\s*logger\.info\(`🔗 E1: Seeded[^`]+`\);\s*\}\s*if \(deferredProfile\)/.test( + source, + ), + ).toBe(true); + }); +});