GitNexus/gitnexus/test/unit/group/grpc-extractor.test.ts
Hector Prats d69eadfb7f
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Release Candidate / Check if release candidate should run (push) Waiting to run
Release Candidate / ci (push) Blocked by required conditions
Release Candidate / Publish release candidate to npm (push) Blocked by required conditions
Release Candidate / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
fix(windows): 32767-char tree-sitter crash + VECTOR extension SIGSEGV (#1433)
* fix(windows): 32767-char tree-sitter crash + VECTOR extension SIGSEGV

tree-sitter 0.21.x on Windows crashes with SIGSEGV when parsing source
strings longer than 32 767 chars (signed 16-bit integer overflow in the
native binding). Five call sites passed raw file content without any
length guard:

  - captures.ts (C# scope extraction)
  - namespace-siblings.ts (extractFileStructure)
  - parse-worker.ts (worker thread parse path)
  - parsing-processor.ts (sequential parse fallback)

Fix: truncate at the last newline before the limit so the fragment stays
syntactically coherent. Files truncated mid-class produce ERROR roots;
captures.ts returns [] for any ERROR-root tree so the legacy DAG handles
the file silently without orphaned scope errors.

Additional C# scope fixes:
  - scope-tree.ts: Module scopes may share the same range as a top-level
    namespace_declaration (files with no leading `using` directives). The
    rangeStrictlyContains check rejects equal ranges. Added
    rangeNonStrictlyContains for Module parents.
  - scope-extractor.ts: pass1BuildScopes stack-pop used strict containment;
    same Module == Namespace range case caused orphaned scopes. Added
    moduleAwareContains helper.
  - scope-extractor-bridge.ts: empty captures from ERROR-root files still
    called extractScope -> "no Module scope found" warning. Added early
    return for empty/non-array captures.
  - namespace-siblings.ts: three sites pushed onto binding arrays frozen by
    finalize-algorithm. Fixed with spread-copy before mutation.

lbug-adapter.ts: INSTALL VECTOR in loadVectorExtension calls the KuzuDB
native extension installer, which crashes with SIGSEGV on Windows via an
unhandled error path in native code. JS try/catch cannot intercept native
signals. Skip extension loading on win32 — vector/embedding search is
unavailable on Windows but all graph index queries work correctly.

Verified on: Windows 11, Node.js 24, gitnexus 1.6.3, pcf8-game codebase
(61 757 nodes / 111 796 edges / 300 flows after fix).

* fix(windows): skip FTS extension load in pool-adapter on Windows to prevent SIGSEGV

LOAD EXTENSION fts crashes the process with SIGSEGV on Windows when the
FTS extension binary is not installed locally. This is an @ladybugdb/core
native bug — the extension loader hits an unhandled error path that raises
a native signal instead of a JS exception, so try/catch cannot protect here.

Add a process.platform === 'win32' guard in both doInitLbug and
initLbugWithDb. When skipped, bm25-index.js catches the resulting
Kuzu catalog errors (CREATE_FTS_INDEX not defined) and returns empty
BM25 results gracefully. All graph queries (cypher, context, impact)
are unaffected.

This is patch 9 of the Windows fix series for gitnexus on Windows:
patch 8 (same PR) already fixed INSTALL VECTOR SIGSEGV in lbug-adapter.ts.
pool-adapter.ts is the separate MCP-server code path that was not covered.

* fix: address codeql findings on PR #1433

The four `lastIndexOf('\n', ...)` calls were committed with a literal
newline inside the single-quoted string instead of the `\n` escape, so
the files do not parse — `tsc` and CodeQL both flagged them. Replace
the embedded newline with `'\n'`.

Also remove the two helpers that were superseded during review and
became dead code: `rangeNonStrictlyContains` in scope-tree.ts (the
equal-range carve-out is handled by `rangeStrictlyContains` +
`rangesEqual` in `canParentScope`) and `moduleAwareContains` in
scope-extractor.ts (`pass1BuildScopes` calls `canParentScope` directly).

* fix(windows): replace 32767-char truncation with chunked-input parsing

The tree-sitter 0.21.x Node binding crashes (SIGSEGV) on Windows when
parser.parse(string, ...) is handed a JS string longer than 32 767 chars.
The crash is in the bindings V8 string-to-buffer conversion and cannot
be intercepted from JS. Previous mitigation truncated source at the last
newline before that boundary, silently losing the file tail and producing
ERROR-root trees from mid-class cuts.

Switch to the callback (Parser.Input) overload via a new parseSourceSafe
helper. tree-sitter pulls source in 16 KiB chunks via repeated callback
invocations, bypassing the broken conversion path. Files are parsed in
full, no data loss, no platform-specific code path.

Removes the now-unnecessary ERROR-root short-circuit in csharp/captures.ts
and the empty-captures shim in scope-extractor-bridge.ts; both existed only
to swallow truncation-induced parse failures.

* fix(windows): cover all parse sites and correct vector-extension state

Address adversarial review on PR #1433:

1. Extend parseSourceSafe to all remaining parser.parse() call sites that
   handle full file content. The first commit only converted the four
   sites with active truncation hacks; cache-miss paths in
   call-processor (x2), heritage-processor (x2), import-processor, and
   the Go/Python/TypeScript captures + Go range-binding still called
   parser.parse() directly. On Windows those would still SIGSEGV for
   files > 32767 chars.

2. Stop setting vectorExtensionLoaded = true on the win32 short-circuit
   in lbug-adapter.ts. The flag means "successfully loaded" and is
   checked by an early-return at the top of loadVectorExtension; setting
   it on the skip path made the second call return true and let
   QUERY_VECTOR_INDEX run against a DB without the extension.

3. Drop the placeholder issues/... URL in the same comment.

4. Add unit tests for parseSourceSafe at boundary values: 16 KiB
   (direct/callback boundary), the 32 767 Windows crash boundary,
   single-line > chunk size, CRLF near boundary, and large all-Chinese
   source. Confirms the callback path is correct for non-ASCII content,
   which is also exercised by the existing csharp-captures large-file
   test.

Researched the chunking concern: tree-sitter Node binding sets
TSInputEncodingUTF16 and divides byte_index by 2 in ByteCountToJS before
calling the JS callback, so the index argument is a UTF-16 code-unit
offset — matching String.prototype.slice. Splitting tokens across chunks
is safe by API contract; the lexer is chunk-agnostic.

* fix(windows): extend parseSourceSafe to group/embeddings + lint enforcement

Closes the remaining Windows SIGSEGV exposure flagged by the Codex
adversarial review on PR #1433. Six pre-existing parser.parse(content)
call sites bypassed parseSourceSafe and could crash the process on
Windows when a contract IDL, route file, or embedding-target source
exceeded 32 767 chars. Adds a lint rule so the regression vector closes
permanently.

Production code:
- Relocate parseSourceSafe from ingestion/utils/ to core/tree-sitter/
  so group/ and embeddings/ can import without crossing into ingestion
  internals. core/tree-sitter/ already houses parser-loader.ts and is
  the natural shared facade. All 11 existing importers updated; no shim
  left behind in the old location.
- Route through parseSourceSafe in 5 group extractors (grpc, thrift,
  http-route, include, tree-sitter-scanner) and the embeddings
  ensureAndParse helper.
- The seventh direct .parse() call in grpc-patterns/proto.ts:49 is a
  module-load grammar smoke test parsing a 36-char literal. Trivially
  safe by inspection, intentionally direct, filtered out by the lint
  rule via the string-literal-arg skip.

Tests:
- 5 caller-side regression tests with a vi.spyOn assertion on
  parseSourceSafe. The spy is what catches a regression: parser.parse
  on a 40 000-char input succeeds on Linux/macOS, so a "no throw"
  assertion alone would silently pass with the bypass reintroduced.
- The vi.mock boilerplate is centralised in
  gitnexus/test/helpers/parse-source-safe-mock.ts, dynamic-imported
  inside each mock factory so vitest's hoister does not race the
  static import binding.

Lint:
- New custom ESLint rule gitnexus/require-safe-parse, scoped to
  gitnexus/src/core/**, fails on direct <parser>.parse(<non-literal>,
  ...) calls and auto-fixes them to parseSourceSafe(<parser>, ...).
  Skips JSON/URL/marked/Number/Math, string-literal first args
  (smoke tests), test files, and the helper itself. Auto-fix rewrites
  the call site only; the developer adds the import after tsc
  surfaces the missing identifier — same tradeoff as
  unused-imports/no-unused-imports.

Plan: docs/plans/2026-05-10-001-fix-windows-parse-safety-group-and-embeddings-plan.md

* fix(test): use mkdtempSync in http-route-extractor regression test

Address CodeQL js/insecure-temporary-file warning on the new Windows-
SIGSEGV regression test. The test was using path.join(tmpDir, "large-input")
which, when nested inside a Date.now()-based parent tmpDir, lets CodeQL flag
the directory as a predictable-name temp file with race-condition risk.
Switch to fs.mkdtempSync(path.join(tmpDir, "large-input-")) so the suffix
is a secure unique random string.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-10 16:00:36 +01:00

1122 lines
38 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import fsp from 'node:fs/promises';
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 {
GrpcExtractor,
buildProtoMap,
resolveProtoConflict,
serviceContractId,
} from '../../../src/core/group/extractors/grpc-extractor.js';
import type { ProtoServiceInfo } from '../../../src/core/group/extractors/grpc-extractor.js';
import type { RepoHandle } from '../../../src/core/group/types.js';
import { _captureLogger } from '../../../src/core/logger.js';
describe('GrpcExtractor', () => {
let tmpDir: string;
let extractor: GrpcExtractor;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-grpc-'));
extractor = new GrpcExtractor();
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writeFile(relPath: string, content: string): void {
const full = path.join(tmpDir, relPath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
const makeRepo = (repoPath: string): RepoHandle => ({
id: 'test-repo',
path: 'test/app',
repoPath,
storagePath: path.join(repoPath, '.gitnexus'),
});
describe('proto file parsing', () => {
it('test_extract_proto_service_single_rpc_returns_provider', async () => {
writeFile(
'proto/auth.proto',
`syntax = "proto3";
package auth;
service AuthService {
rpc Login (LoginRequest) returns (LoginResponse);
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers).toHaveLength(1);
expect(providers[0].contractId).toBe('grpc::auth.AuthService/Login');
expect(providers[0].confidence).toBe(0.85);
expect(providers[0].symbolRef.filePath).toBe('proto/auth.proto');
});
it('test_extract_proto_service_multiple_rpcs_returns_all', async () => {
writeFile(
'api/user.proto',
`syntax = "proto3";
package hr.user.v1;
service UserService {
rpc GetUser (GetUserRequest) returns (UserResponse);
rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
rpc DeleteUser (DeleteUserRequest) returns (Empty);
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers).toHaveLength(3);
const ids = providers.map((c) => c.contractId).sort();
expect(ids).toEqual([
'grpc::hr.user.v1.UserService/DeleteUser',
'grpc::hr.user.v1.UserService/GetUser',
'grpc::hr.user.v1.UserService/ListUsers',
]);
});
it('test_extract_proto_without_package_uses_service_only', async () => {
writeFile(
'service.proto',
`syntax = "proto3";
service HealthCheck {
rpc Check (HealthRequest) returns (HealthResponse);
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
expect(contracts).toHaveLength(1);
expect(contracts[0].contractId).toBe('grpc::HealthCheck/Check');
});
it('test_extract_proto_with_google_api_http_nested_braces', async () => {
writeFile(
'api/gateway.proto',
`syntax = "proto3";
package gateway.v1;
import "google/api/annotations.proto";
service GatewayService {
rpc GetUser (GetUserRequest) returns (UserResponse) {
option (google.api.http) = {
get: "/v1/users/{user_id}"
};
}
rpc CreateUser (CreateUserRequest) returns (UserResponse) {
option (google.api.http) = {
post: "/v1/users"
body: "*"
};
}
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/gateway.proto',
);
expect(providers).toHaveLength(2);
const ids = providers.map((c) => c.contractId).sort();
expect(ids).toEqual([
'grpc::gateway.v1.GatewayService/CreateUser',
'grpc::gateway.v1.GatewayService/GetUser',
]);
});
it('test_extract_proto_with_multiple_services', async () => {
writeFile(
'api/multi.proto',
`syntax = "proto3";
package multi;
service ServiceA {
rpc MethodA (Req) returns (Res);
}
service ServiceB {
rpc MethodB1 (Req) returns (Res);
rpc MethodB2 (Req) returns (Res);
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/multi.proto',
);
expect(providers).toHaveLength(3);
const ids = providers.map((c) => c.contractId).sort();
expect(ids).toEqual([
'grpc::multi.ServiceA/MethodA',
'grpc::multi.ServiceB/MethodB1',
'grpc::multi.ServiceB/MethodB2',
]);
});
it('test_extract_proto_with_nested_option_blocks_in_rpc', async () => {
writeFile(
'api/nested.proto',
`syntax = "proto3";
package nested;
service DeepService {
rpc DeepMethod (Req) returns (Res) {
option (google.api.http) = {
post: "/v1/deep"
body: "*"
additional_bindings {
get: "/v1/deep/{id}"
}
};
}
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/nested.proto',
);
expect(providers).toHaveLength(1);
expect(providers[0].contractId).toBe('grpc::nested.DeepService/DeepMethod');
});
it('test_extract_proto_malformed_unclosed_brace_skips_service', async () => {
writeFile(
'api/broken.proto',
`syntax = "proto3";
package broken;
service IncompleteService {
rpc SomeMethod (Req) returns (Res);
// Missing closing brace — EOF before depth returns to 0
`,
);
// Should not throw; incomplete service is silently skipped
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/broken.proto',
);
// The old regex would find partial match; the new parser should skip it
expect(providers).toHaveLength(0);
});
it('test_extract_proto_ignores_braces_inside_string_literals', async () => {
// Regression for a known parser limitation: braces inside string
// literals used to be counted as real service-body braces, which
// would terminate the service early and drop methods after the
// offending string.
writeFile(
'api/strings.proto',
`syntax = "proto3";
package strings;
service TrickyService {
rpc First (Req) returns (Res) {
option (google.api.http).additional_bindings = {
post: "/v1/first";
};
}
// Previously the "{" inside this literal would close the service body.
option deprecated_reason = "use NewService { instead";
rpc Second (Req) returns (Res);
rpc Third (Req) returns (Res);
}
`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const protoProviders = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/strings.proto',
);
// All three methods must be extracted even though a string literal
// contains an unbalanced "{".
expect(protoProviders.map((c) => c.symbolName).sort()).toEqual([
'TrickyService.First',
'TrickyService.Second',
'TrickyService.Third',
]);
});
it('test_extract_proto_ignores_braces_inside_comments', async () => {
writeFile(
'api/commented.proto',
`syntax = "proto3";
package commented;
service Svc {
// TODO: move { or } from this comment — parser used to count them
/* A block comment with { unbalanced braces } */
rpc Alpha (Req) returns (Res);
// }} end of the method block (in comment)
rpc Beta (Req) returns (Res);
}
`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const protoProviders = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/commented.proto',
);
expect(protoProviders.map((c) => c.symbolName).sort()).toEqual(['Svc.Alpha', 'Svc.Beta']);
});
});
describe('Go server detection', () => {
it('test_extract_go_register_server_returns_provider', async () => {
writeFile(
'cmd/server/main.go',
`package main
import pb "example.com/proto/auth"
func main() {
srv := grpc.NewServer()
pb.RegisterAuthServiceServer(srv, &authServer{})
srv.Serve(lis)
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.length).toBeGreaterThanOrEqual(1);
expect(providers[0].contractId).toContain('grpc::');
expect(providers[0].contractId).toContain('AuthService');
expect(providers[0].confidence).toBe(0.65);
});
it('test_extract_go_unimplemented_server_returns_provider', async () => {
writeFile(
'internal/server.go',
`package server
type authServer struct {
pb.UnimplementedAuthServiceServer
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.length).toBeGreaterThanOrEqual(1);
expect(providers[0].contractId).toContain('AuthService');
});
});
describe('Go client detection', () => {
it('test_extract_go_new_client_returns_consumer', async () => {
writeFile(
'internal/client.go',
`package client
import pb "example.com/proto/auth"
func NewAuthClient(conn *grpc.ClientConn) pb.AuthServiceClient {
return pb.NewAuthServiceClient(conn)
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers.length).toBeGreaterThanOrEqual(1);
expect(consumers[0].contractId).toContain('AuthService');
expect(consumers[0].confidence).toBe(0.55);
});
});
describe('Java detection', () => {
it('test_extract_java_grpc_service_annotation_returns_provider', async () => {
writeFile(
'src/main/java/AuthGrpcService.java',
`@GrpcService
public class AuthGrpcService extends AuthServiceGrpc.AuthServiceImplBase {
@Override
public void login(LoginRequest req, StreamObserver<LoginResponse> obs) {}
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.length).toBeGreaterThanOrEqual(1);
expect(providers[0].contractId).toContain('AuthService');
expect(providers[0].confidence).toBe(0.65);
});
it('test_extract_java_blocking_stub_returns_consumer', async () => {
writeFile(
'src/main/java/AuthClient.java',
`public class AuthClient {
private final AuthServiceGrpc.AuthServiceBlockingStub stub;
public AuthClient(ManagedChannel ch) {
this.stub = AuthServiceGrpc.newBlockingStub(ch);
}
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers.length).toBeGreaterThanOrEqual(1);
expect(consumers[0].contractId).toContain('AuthService');
expect(consumers[0].confidence).toBe(0.55);
});
});
describe('Python detection', () => {
it('test_extract_python_add_servicer_returns_provider', async () => {
writeFile(
'server.py',
`import grpc
from proto import auth_pb2_grpc
def serve():
server = grpc.server(futures.ThreadPoolExecutor())
auth_pb2_grpc.add_AuthServiceServicer_to_server(AuthServicer(), server)
server.start()`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.length).toBeGreaterThanOrEqual(1);
expect(providers[0].contractId).toContain('AuthService');
expect(providers[0].confidence).toBe(0.65);
});
it('test_extract_python_stub_returns_consumer', async () => {
writeFile(
'client.py',
`import grpc
from proto import auth_pb2_grpc
channel = grpc.insecure_channel('localhost:50051')
stub = auth_pb2_grpc.AuthServiceStub(channel)`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers.length).toBeGreaterThanOrEqual(1);
expect(consumers[0].contractId).toContain('AuthService');
expect(consumers[0].confidence).toBe(0.55);
});
});
describe('TypeScript/Node detection', () => {
it('test_extract_ts_grpc_method_decorator_returns_provider', async () => {
writeFile(
'src/auth.controller.ts',
`import { GrpcMethod } from '@nestjs/microservices';
export class AuthController {
@GrpcMethod('AuthService', 'Login')
login(data: LoginRequest): LoginResponse {
return {};
}
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.length).toBeGreaterThanOrEqual(1);
expect(providers[0].contractId).toContain('AuthService');
expect(providers[0].contractId).toContain('Login');
expect(providers[0].confidence).toBe(0.8);
});
it('test_extract_ts_grpc_client_decorator_returns_consumer', async () => {
writeFile(
'proto/auth.proto',
`syntax = "proto3";
package auth.v1;
service AuthService {
rpc Login (LoginRequest) returns (LoginResponse);
}`,
);
writeFile(
'src/auth.client.ts',
`import { GrpcClient } from '@nestjs/microservices';
import type { AuthServiceClient } from './generated/auth';
export class AuthGateway {
@GrpcClient({ package: 'auth.v1', protoPath: 'proto/auth.proto' })
private readonly authClient!: AuthServiceClient;
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*');
});
it('test_extract_ts_getService_without_decorator_returns_consumer', async () => {
writeFile(
'proto/auth.proto',
`syntax = "proto3";
package auth.v1;
service AuthService {
rpc Login (LoginRequest) returns (LoginResponse);
}`,
);
writeFile(
'src/auth.client.ts',
`import type { ClientGrpc } from '@nestjs/microservices';
export function createAuthClient(client: ClientGrpc) {
return client.getService<AuthService>('AuthService');
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*');
});
it('test_extract_ts_generated_client_constructor_returns_consumer', async () => {
writeFile(
'proto/auth.proto',
`syntax = "proto3";
package auth.v1;
service AuthService {
rpc Login (LoginRequest) returns (LoginResponse);
}`,
);
writeFile(
'src/auth.client.ts',
`import { credentials } from '@grpc/grpc-js';
import { AuthServiceClient } from './generated/auth';
export const authClient = new AuthServiceClient('localhost:50051', credentials.createInsecure());`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*');
});
it('test_extract_ts_non_service_client_constructor_is_ignored', async () => {
writeFile(
'proto/auth.proto',
`syntax = "proto3";
package auth.v1;
service AuthService {
rpc Login (LoginRequest) returns (LoginResponse);
}`,
);
writeFile(
'src/auth.client.ts',
`import { AuthClient } from './generated/auth';
export const authClient = new AuthClient('localhost:50051');`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(0);
});
it('test_extract_ts_loadPackageDefinition_constructor_returns_consumer', async () => {
writeFile(
'proto/auth.proto',
`syntax = "proto3";
package auth.v1;
service AuthService {
rpc Login (LoginRequest) returns (LoginResponse);
}`,
);
writeFile(
'src/auth.client.ts',
`import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
const definition = protoLoader.loadSync('proto/auth.proto');
const authProto = grpc.loadPackageDefinition(definition) as any;
export const authClient = new authProto.auth.v1.AuthService(
'localhost:50051',
grpc.credentials.createInsecure(),
);`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*');
});
it('test_extract_ts_duplicate_consumer_patterns_in_one_file_dedupes_deterministically', async () => {
writeFile(
'proto/auth.proto',
`syntax = "proto3";
package auth.v1;
service AuthService {
rpc Login (LoginRequest) returns (LoginResponse);
}`,
);
writeFile(
'src/auth.client.ts',
`import * as grpc from '@grpc/grpc-js';
import type { ClientGrpc } from '@nestjs/microservices';
import { AuthServiceClient } from './generated/auth';
export class AuthGateway {
constructor(private readonly client: ClientGrpc) {}
connect() {
this.client.getService<AuthService>('AuthService');
return new AuthServiceClient('localhost:50051', grpc.credentials.createInsecure());
}
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers).toHaveLength(1);
expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*');
});
});
describe('edge cases', () => {
it('test_extract_empty_repo_returns_empty', async () => {
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
expect(contracts).toHaveLength(0);
});
it('test_extract_repo_without_grpc_returns_empty', async () => {
writeFile('src/index.ts', 'console.log("hello")');
writeFile('package.json', '{}');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
expect(contracts).toHaveLength(0);
});
});
// ─── #1185: gRPC extractor must honour .gitnexusignore ──────────────
//
// Both the `.proto` glob (in `buildProtoContext`) and the source-scan
// glob (in `extract`) used a hardcoded ignore array that bypassed
// `IgnoreService`. Both globs now consume the shared filter (mirrors
// `filesystem-walker.ts`) so any `.gitnexusignore` pattern is
// honoured. The single test below exercises BOTH paths in the same
// run: a `.proto` under `mentor_env/` (proto-context build) AND a
// Python `_pb2_grpc.<Name>Stub` consumer under `mentor_env/`
// (source-scan path) — neither produces a contract.
describe('respects .gitnexusignore (#1185)', () => {
it('proto + source globs both skip files matched by .gitnexusignore', async () => {
// Control: a regular .proto in a non-ignored dir.
writeFile(
'proto/auth.proto',
`syntax = "proto3";
package auth;
service AuthService {
rpc Login (LoginRequest) returns (LoginResponse);
}`,
);
// Vendored proto under a venv-style dir — exercises proto-context glob.
writeFile(
'mentor_env/lib/leaked.proto',
`syntax = "proto3";
package leaked;
service LeakedService {
rpc Ping (PingRequest) returns (PingResponse);
}`,
);
// Vendored Python consumer under the same venv-style dir —
// exercises the second glob in `extract()` (source-scan path).
// Mirrors the canonical pattern from
// `test_extract_python_stub_returns_consumer` above; without the
// `.gitnexusignore` filter this WOULD emit a `grpc::*/LeakedService`
// consumer contract.
writeFile(
'mentor_env/lib/leaked_consumer.py',
`import grpc
from proto import leaked_pb2_grpc
channel = grpc.insecure_channel('localhost:50051')
stub = leaked_pb2_grpc.LeakedServiceStub(channel)`,
);
writeFile('.gitnexusignore', 'mentor_env/\n');
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
// Control proto provider is still emitted.
expect(contracts.find((c) => c.contractId === 'grpc::auth.AuthService/Login')).toBeDefined();
// Defence-in-depth: no contract — provider OR consumer — has a
// `symbolRef` path under the ignored directory. Catches both globs
// at once.
expect(contracts.some((c) => c.symbolRef?.filePath?.startsWith('mentor_env/'))).toBe(false);
// Specific assertions per glob path.
expect(
contracts.find((c) => c.contractId === 'grpc::leaked.LeakedService/Ping'),
).toBeUndefined();
expect(
contracts.some((c) => c.role === 'consumer' && /LeakedService/.test(c.contractId)),
).toBe(false);
});
});
describe('Windows SIGSEGV regression — large input must route through parseSourceSafe', () => {
it('routes >32 767-char source file through parseSourceSafe (not direct parser.parse)', async () => {
parseSourceSafeSpy.mockClear();
// Synthesize a >40 000-char source file in a language whose grpc plugin
// is always available (Go has no optional grammar — the Go plugin is
// unconditionally wired in grpc-patterns/index.ts). Direct
// parser.parse(content) on an input this size SIGSEGVs the process on
// Windows; parseSourceSafe routes through the chunked-callback path and
// works on every platform. The spy assertion is what catches the
// regression — a "no throw" assertion alone is satisfied by the bypass
// on Linux/macOS where parser.parse(40 000 chars) succeeds.
const padding = Array.from(
{ length: 600 },
(_, i) => `func helper${i}() string { return "padding-${i}-aaaaaaaaaaaaaaaaaaaaaa" }\n`,
).join('');
const largeGo = `package big\n\n${padding}\n`;
expect(largeGo.length).toBeGreaterThan(40_000);
writeFile('server/big.go', largeGo);
await extractor.extract(null, tmpDir, makeRepo(tmpDir));
expect(parseSourceSafeSpy).toHaveBeenCalled();
});
});
});
describe('buildProtoMap', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'proto-test-'));
});
afterEach(async () => {
await fsp.rm(tmpDir, { recursive: true, force: true });
});
it('test_buildProtoMap_single_proto_parses_package_service_methods', async () => {
const protoContent = `
syntax = "proto3";
package com.example;
service UserService {
rpc GetUser (GetUserRequest) returns (GetUserResponse);
rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
}`;
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
await fsp.writeFile(path.join(tmpDir, 'proto', 'user.proto'), protoContent);
const map = await buildProtoMap(tmpDir);
expect(map.has('UserService')).toBe(true);
const entries = map.get('UserService')!;
expect(entries).toHaveLength(1);
expect(entries[0].package).toBe('com.example');
expect(entries[0].serviceName).toBe('UserService');
expect(entries[0].methods).toEqual(['GetUser', 'ListUsers']);
expect(entries[0].protoPath).toBe('proto/user.proto');
});
it('test_buildProtoMap_no_package_declaration', async () => {
const protoContent = `
syntax = "proto3";
service Foo { rpc Bar (Req) returns (Res); }`;
await fsp.writeFile(path.join(tmpDir, 'foo.proto'), protoContent);
const map = await buildProtoMap(tmpDir);
const entries = map.get('Foo')!;
expect(entries[0].package).toBe('');
});
it('test_buildProtoMap_no_protos_returns_empty', async () => {
const map = await buildProtoMap(tmpDir);
expect(map.size).toBe(0);
});
it('test_buildProtoMap_conflicting_names', async () => {
await fsp.mkdir(path.join(tmpDir, 'a'), { recursive: true });
await fsp.mkdir(path.join(tmpDir, 'b'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'a', 'svc.proto'),
'package pkg.a;\nservice Svc { rpc Do (R) returns (R); }',
);
await fsp.writeFile(
path.join(tmpDir, 'b', 'svc.proto'),
'package pkg.b;\nservice Svc { rpc Do (R) returns (R); }',
);
const map = await buildProtoMap(tmpDir);
expect(map.get('Svc')).toHaveLength(2);
});
it('test_buildProtoMap_imported_package_is_inherited_for_split_service_definition', async () => {
await fsp.mkdir(path.join(tmpDir, 'proto', 'shared'), { recursive: true });
await fsp.mkdir(path.join(tmpDir, 'proto', 'services'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'proto', 'shared', 'package.proto'),
'package auth.v1;\nmessage LoginRequest {}',
);
await fsp.writeFile(
path.join(tmpDir, 'proto', 'services', 'auth.proto'),
'import "../shared/package.proto";\nservice AuthService { rpc Login (LoginRequest) returns (LoginRequest); }',
);
const map = await buildProtoMap(tmpDir);
const entries = map.get('AuthService')!;
expect(entries).toHaveLength(1);
expect(entries[0].package).toBe('auth.v1');
});
});
describe('resolveProtoConflict', () => {
const makeInfo = (pkg: string, protoPath: string): ProtoServiceInfo => ({
package: pkg,
serviceName: 'Svc',
methods: ['Do'],
protoPath,
});
it('test_single_candidate_returns_it', () => {
const result = resolveProtoConflict('Svc', 'src/main.go', [makeInfo('pkg', 'proto/svc.proto')]);
expect(result?.package).toBe('pkg');
});
it('test_multiple_candidates_picks_closest_directory', () => {
const candidates = [
makeInfo('far', 'other/dir/svc.proto'),
makeInfo('close', 'src/proto/svc.proto'),
];
const result = resolveProtoConflict('Svc', 'src/server.go', candidates);
expect(result?.package).toBe('close');
});
it('test_centralized_proto_layout_prefers_shared_path_segments_over_prefix_only', () => {
const candidates = [
makeInfo('billing', 'proto/services/billing/svc.proto'),
makeInfo('auth', 'proto/services/auth/svc.proto'),
];
const result = resolveProtoConflict('Svc', 'services/auth/src/server.ts', candidates);
expect(result?.package).toBe('auth');
});
it('test_no_candidates_returns_null', () => {
expect(resolveProtoConflict('Svc', 'src/main.go', [])).toBeNull();
});
it('test_all_zero_tie_returns_null', () => {
const cap = _captureLogger();
const candidates = [
makeInfo('pkgA', 'totally/unrelated/a/svc.proto'),
makeInfo('pkgB', 'completely/different/b/svc.proto'),
];
const result = resolveProtoConflict('Svc', 'src/main.go', candidates);
expect(result).toBeNull();
cap.restore();
});
it('test_positive_score_tie_returns_null', () => {
const cap = _captureLogger();
// Both candidates share `src/proto` with the source dir — equal shared runs.
const candidates = [
makeInfo('pkgA', 'src/proto/a/svc.proto'),
makeInfo('pkgB', 'src/proto/b/svc.proto'),
];
const result = resolveProtoConflict('Svc', 'src/proto/main.go', candidates);
expect(result).toBeNull();
cap.restore();
});
it('test_three_way_zero_tie_returns_null', () => {
const cap = _captureLogger();
const candidates = [
makeInfo('pkgA', 'aaa/svc.proto'),
makeInfo('pkgB', 'bbb/svc.proto'),
makeInfo('pkgC', 'ccc/svc.proto'),
];
const result = resolveProtoConflict('Svc', 'src/main.go', candidates);
expect(result).toBeNull();
cap.restore();
});
it('test_unique_winner_among_ties', () => {
// Winner with shared run 2 (services/auth), two losers with score 0.
const candidates = [
makeInfo('winner', 'services/auth/proto/svc.proto'),
makeInfo('loserA', 'totally/unrelated/a/svc.proto'),
makeInfo('loserB', 'elsewhere/b/svc.proto'),
];
const result = resolveProtoConflict('Svc', 'services/auth/src/server.ts', candidates);
expect(result?.package).toBe('winner');
});
it('test_ambiguous_emits_single_warn_with_service_and_paths', () => {
const cap = _captureLogger();
const candidates = [
makeInfo('pkgA', 'totally/unrelated/a/svc.proto'),
makeInfo('pkgB', 'completely/different/b/svc.proto'),
];
resolveProtoConflict('MyService', 'src/main.go', candidates);
expect(cap.records().length).toBe(1);
const msg = String(String(cap.records()[0]?.msg ?? ''));
expect(msg).toContain('MyService');
expect(msg).toContain('src/main.go');
expect(msg).toContain('totally/unrelated/a/svc.proto');
expect(msg).toContain('completely/different/b/svc.proto');
cap.restore();
});
});
describe('GrpcExtractor.extract ambiguous proto resolution', () => {
let tmpDir: string;
let extractor: GrpcExtractor;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-grpc-ambig-'));
extractor = new GrpcExtractor();
});
afterEach(async () => {
await fsp.rm(tmpDir, { recursive: true, force: true });
});
const makeRepo = (repoPath: string): RepoHandle => ({
id: 'test-repo',
path: '',
repoPath,
storagePath: '',
});
it('test_ambiguous_short_name_across_unrelated_protos_yields_no_source_contract', async () => {
const cap = _captureLogger();
// Two unrelated proto files defining the same short name `UserService` in
// unrelated directories, neither sharing path segments with the Go source.
await fsp.mkdir(path.join(tmpDir, 'billing-team', 'proto'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'billing-team', 'proto', 'user.proto'),
'package billing.v1;\nservice UserService { rpc GetUser (R) returns (R); }',
);
await fsp.mkdir(path.join(tmpDir, 'auth-team', 'proto'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'auth-team', 'proto', 'user.proto'),
'package auth.v1;\nservice UserService { rpc GetUser (R) returns (R); }',
);
// Consumer in an unrelated directory.
await fsp.mkdir(path.join(tmpDir, 'apps', 'gateway'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'apps', 'gateway', 'client.go'),
'package main\nfunc init() { client := pb.NewUserServiceClient(conn) }',
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
// No source-attributed contract for UserService should be emitted.
const sourceContracts = contracts.filter(
(c) => c.meta.source === 'go_client' && c.meta.service === 'UserService',
);
expect(sourceContracts).toHaveLength(0);
expect(cap.records().length).toBeGreaterThan(0);
cap.restore();
});
});
describe('serviceContractId', () => {
it('test_with_package', () => {
expect(serviceContractId('com.example', 'UserService')).toBe('grpc::com.example.UserService/*');
});
it('test_without_package', () => {
expect(serviceContractId('', 'UserService')).toBe('grpc::UserService/*');
});
});
describe('proto-aware source scanners', () => {
let tmpDir: string;
let extractor: GrpcExtractor;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'scanner-test-'));
extractor = new GrpcExtractor();
});
afterEach(async () => {
await fsp.rm(tmpDir, { recursive: true, force: true });
});
const makeRepo = (repoPath: string): RepoHandle => ({
id: 'test-repo',
path: '',
repoPath,
storagePath: '',
});
it('test_go_provider_with_proto_uses_canonical_service_id', async () => {
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'proto', 'user.proto'),
'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }',
);
await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'src', 'server.go'),
'package main\nfunc init() { pb.RegisterUserServiceServer(srv, &impl{}) }',
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const goProvider = contracts.find((c) => c.meta.source === 'go_register');
expect(goProvider).toBeDefined();
expect(goProvider!.contractId).toBe('grpc::com.example.UserService/*');
expect(goProvider!.confidence).toBe(0.8);
});
it('test_go_provider_without_proto_reduced_confidence', async () => {
await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'src', 'server.go'),
'package main\nfunc init() { pb.RegisterFooServer(srv, &impl{}) }',
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const goProvider = contracts.find((c) => c.meta.source === 'go_register');
expect(goProvider).toBeDefined();
expect(goProvider!.contractId).toBe('grpc::Foo/*');
expect(goProvider!.confidence).toBe(0.65);
});
it('test_go_consumer_with_proto_uses_canonical_service_id', async () => {
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'proto', 'user.proto'),
'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }',
);
await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'src', 'client.go'),
'package main\nfunc init() { client := pb.NewUserServiceClient(conn) }',
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const goConsumer = contracts.find((c) => c.meta.source === 'go_client');
expect(goConsumer).toBeDefined();
expect(goConsumer!.contractId).toBe('grpc::com.example.UserService/*');
expect(goConsumer!.confidence).toBe(0.75);
});
it('test_java_provider_with_proto_uses_canonical_service_id', async () => {
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'proto', 'user.proto'),
'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }',
);
await fsp.mkdir(path.join(tmpDir, 'src', 'main', 'java'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'src', 'main', 'java', 'UserGrpcService.java'),
`@GrpcService
public class UserGrpcService extends UserServiceGrpc.UserServiceImplBase {
@Override
public void getUser(GetUserRequest req, StreamObserver<GetUserResponse> obs) {}
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const javaProvider = contracts.find((c) => c.meta.source === 'java_grpc_service');
expect(javaProvider).toBeDefined();
expect(javaProvider!.contractId).toBe('grpc::com.example.UserService/*');
expect(javaProvider!.confidence).toBe(0.8);
});
it('test_python_consumer_with_proto_uses_canonical_service_id', async () => {
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'proto', 'user.proto'),
'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }',
);
await fsp.writeFile(
path.join(tmpDir, 'client.py'),
`import grpc
channel = grpc.insecure_channel('localhost:50051')
stub = UserServiceStub(channel)`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const pyConsumer = contracts.find((c) => c.meta.source === 'python_stub');
expect(pyConsumer).toBeDefined();
expect(pyConsumer!.contractId).toBe('grpc::com.example.UserService/*');
expect(pyConsumer!.confidence).toBe(0.75);
});
it('test_ts_provider_with_proto_adds_package', async () => {
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'proto', 'user.proto'),
'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }',
);
await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'src', 'controller.ts'),
"@GrpcMethod('UserService', 'GetUser')\nasync getUser() {}",
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const tsProvider = contracts.find((c) => c.meta.source === 'ts_grpc_method');
expect(tsProvider).toBeDefined();
expect(tsProvider!.contractId).toBe('grpc::com.example.UserService/GetUser');
expect(tsProvider!.confidence).toBe(0.8);
});
it('test_proto_provider_inherits_package_from_imported_definition', async () => {
await fsp.mkdir(path.join(tmpDir, 'proto', 'shared'), { recursive: true });
await fsp.mkdir(path.join(tmpDir, 'proto', 'services'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'proto', 'shared', 'package.proto'),
'package auth.v1;\nmessage LoginRequest {}',
);
await fsp.writeFile(
path.join(tmpDir, 'proto', 'services', 'auth.proto'),
`syntax = "proto3";
import "../shared/package.proto";
service AuthService {
rpc Login (LoginRequest) returns (LoginRequest);
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const protoProvider = contracts.find(
(c) => c.symbolRef.filePath === 'proto/services/auth.proto',
);
expect(protoProvider).toBeDefined();
expect(protoProvider!.contractId).toBe('grpc::auth.v1.AuthService/Login');
});
});