diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json
index ee80faecf..a746292e8 100644
--- a/gitnexus/package-lock.json
+++ b/gitnexus/package-lock.json
@@ -65,6 +65,7 @@
"optionalDependencies": {
"tree-sitter-dart": "github:UserNobody14/tree-sitter-dart#80e23c07b64494f7e21090bb3450223ef0b192f4",
"tree-sitter-kotlin": "^0.3.8",
+ "tree-sitter-proto": "file:./vendor/tree-sitter-proto",
"tree-sitter-swift": "^0.6.0"
}
},
@@ -5296,6 +5297,10 @@
"node": "^18 || ^20 || >= 21"
}
},
+ "node_modules/tree-sitter-proto": {
+ "resolved": "vendor/tree-sitter-proto",
+ "link": true
+ },
"node_modules/tree-sitter-python": {
"version": "0.23.4",
"resolved": "https://registry.npmjs.org/tree-sitter-python/-/tree-sitter-python-0.23.4.tgz",
@@ -5877,6 +5882,29 @@
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
+ },
+ "vendor/tree-sitter-proto": {
+ "version": "0.4.1",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "node-addon-api": "^8.0.0",
+ "node-gyp-build": "^4.8.0"
+ },
+ "peerDependencies": {
+ "tree-sitter": ">=0.21.0"
+ }
+ },
+ "vendor/tree-sitter-proto/node_modules/node-addon-api": {
+ "version": "8.7.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
+ "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": "^18 || ^20 || >= 21"
+ }
}
}
}
diff --git a/gitnexus/package.json b/gitnexus/package.json
index 864f28101..435f9b325 100644
--- a/gitnexus/package.json
+++ b/gitnexus/package.json
@@ -87,6 +87,7 @@
"optionalDependencies": {
"tree-sitter-dart": "github:UserNobody14/tree-sitter-dart#80e23c07b64494f7e21090bb3450223ef0b192f4",
"tree-sitter-kotlin": "^0.3.8",
+ "tree-sitter-proto": "file:./vendor/tree-sitter-proto",
"tree-sitter-swift": "^0.6.0"
},
"devDependencies": {
diff --git a/gitnexus/src/core/group/PIPELINE.md b/gitnexus/src/core/group/PIPELINE.md
new file mode 100644
index 000000000..7730b48e7
--- /dev/null
+++ b/gitnexus/src/core/group/PIPELINE.md
@@ -0,0 +1,139 @@
+# Group Analysis Pipeline
+
+Flow chart of the cross-repo contract extraction + matching pipeline.
+This covers what runs **inside this PR** (extractors + manifest) and
+the downstream handoff to the bridge storage (PR #795) and
+cross-impact query (PR #606).
+
+## High-level overview
+
+```mermaid
+flowchart TD
+ A[group.yaml] --> B[GroupConfig parser]
+ B --> C{For each repo
in group}
+ C --> D[Per-repo LadybugDB
indexed by main pipeline]
+
+ D --> E1[TopicExtractor]
+ D --> E2[HttpRouteExtractor]
+ D --> E3[GrpcExtractor]
+
+ E1 --> F[ExtractedContract array
per repo]
+ E2 --> F
+ E3 --> F
+
+ B --> M[ManifestExtractor]
+ M --> G[Manifest contracts
+ cross-links]
+
+ F --> H[Contract matching
exact + wildcard]
+ G --> H
+
+ H --> I[(bridge.lbug
#795)]
+
+ I --> J[runGroupImpact
#606]
+ J --> K[CrossRepoImpact]
+```
+
+## Per-repo extractor pipeline
+
+Each extractor under `src/core/group/extractors/` follows the same
+two-strategy shape:
+
+```mermaid
+flowchart TD
+ R[RepoHandle + CypherExecutor
for this repo] --> S{Graph-assisted
Strategy A
available?}
+
+ S -->|yes| A1[Cypher query against
per-repo LadybugDB]
+ A1 --> A2{non-empty
result?}
+ A2 -->|yes| OUT[ExtractedContract array]
+ A2 -->|no| B1
+
+ S -->|no| B1[Source-scan Strategy B]
+ B1 --> B2[glob repo source files]
+ B2 --> B3{ext in registry?}
+ B3 -->|yes| B4[Per-language plugin
scan parsed tree]
+ B3 -->|no| SKIP[skip file]
+ B4 --> OUT
+
+ SKIP --> B2
+```
+
+**Strategy A** (graph-assisted) uses Cypher over edges already produced
+by the main ingestion pipeline:
+- HTTP: `HANDLES_ROUTE` / `FETCHES` edges from `(File)-[]->(Route)`
+- topic: none (pipeline doesn't yet produce topic nodes — Strategy B only)
+- gRPC: none (Strategy B + proto map only)
+
+**Strategy B** (source-scan) is 100% tree-sitter based after this PR.
+Each `*-patterns/.ts` plugin owns its grammar + S-expression
+queries; the top-level orchestrator imports neither.
+
+## Plugin architecture
+
+```mermaid
+flowchart LR
+ O[Orchestrator
topic|http|grpc-extractor.ts] --> REG[REGISTRY
*-patterns/index.ts]
+ REG --> P1[java.ts
tree-sitter-java]
+ REG --> P2[go.ts
tree-sitter-go]
+ REG --> P3[python.ts
tree-sitter-python]
+ REG --> P4[node.ts
JS + TS + TSX]
+ REG --> P5[php.ts
tree-sitter-php
HTTP only]
+ REG --> P6[proto.ts
tree-sitter-proto
gRPC only, optional]
+
+ P1 --> SCAN[tree-sitter-scanner.ts
compilePatterns + runCompiledPatterns]
+ P2 --> SCAN
+ P3 --> SCAN
+ P4 --> SCAN
+ P5 --> SCAN
+ P6 --> SCAN
+
+ SCAN --> DET[Detection objects
TopicMeta / HttpDetection / GrpcDetection]
+ DET --> O
+ O --> CT[ExtractedContract array]
+```
+
+The orchestrator never imports a grammar. Adding a new language /
+framework = drop one file in `*-patterns/`, register it in
+`index.ts`. No orchestrator edits required.
+
+## Manifest extraction
+
+```mermaid
+flowchart TD
+ Y[group.yaml links] --> ME[ManifestExtractor]
+ ME --> LOOP{for each link}
+ LOOP --> RES[resolveSymbol
label-scoped Cypher]
+ RES --> OK{found?}
+ OK -->|yes| REF[real symbol uid + ref]
+ OK -->|no| SYN[synthetic uid
manifest::repo::cid]
+
+ REF --> EMIT[emit provider + consumer
Contract objects
+ CrossLink]
+ SYN --> EMIT
+
+ EMIT --> BRIDGE[(bridge.lbug
#795)]
+```
+
+Label-scoped queries in `resolveSymbol` keep accidental cross-matches
+out:
+- `topic` → `(n:Function|Method|Class|Interface)`
+- `grpc` method → `(n:Function|Method)`, service → `(n:Class|Interface)`
+- `lib` → `(n:Package|Module)`
+
+## Cross-impact query (PR #606)
+
+```mermaid
+flowchart TD
+ U[User changes symbol S
in repo R] --> LI[Local impact engine
per-repo uid expansion]
+ LI --> IDS[Affected uid set]
+
+ IDS --> BR[Bridge query
MATCH Contract WHERE uid IN ids]
+ BR --> CL[CrossLink traversal]
+ CL --> OTHER[Matching contract in
other repo]
+
+ OTHER --> FE[Fan-out impact
to consuming repo]
+ FE --> OUT[CrossRepoImpact
per affected repo]
+```
+
+The bridge stores every extracted contract keyed by `symbolUid`.
+Manifest-sourced contracts use the synthetic uid form so both sides
+of the `(local impact) ↔ (bridge query)` join derive the same uid
+without coordinating through any shared state.
diff --git a/gitnexus/src/core/group/extractors/fs-utils.ts b/gitnexus/src/core/group/extractors/fs-utils.ts
new file mode 100644
index 000000000..384f63203
--- /dev/null
+++ b/gitnexus/src/core/group/extractors/fs-utils.ts
@@ -0,0 +1,23 @@
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+
+/**
+ * Safely read a file inside a repo, rejecting any path that escapes
+ * `repoPath` via `..` traversal or absolute segments. Returns `null` if
+ * the path is outside the repo or the file can't be read.
+ *
+ * Used by every source-scan extractor under this directory. Kept as a
+ * single shared implementation so the path-traversal guard (security-
+ * sensitive) lives in exactly one place.
+ */
+export function readSafe(repoPath: string, rel: string): string | null {
+ const abs = path.resolve(repoPath, rel);
+ const base = path.resolve(repoPath);
+ const relToBase = path.relative(base, abs);
+ if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null;
+ try {
+ return fs.readFileSync(abs, 'utf-8');
+ } catch {
+ return null;
+ }
+}
diff --git a/gitnexus/src/core/group/extractors/grpc-extractor.ts b/gitnexus/src/core/group/extractors/grpc-extractor.ts
index b4cefadc5..c6af9138a 100644
--- a/gitnexus/src/core/group/extractors/grpc-extractor.ts
+++ b/gitnexus/src/core/group/extractors/grpc-extractor.ts
@@ -1,20 +1,38 @@
-import * as fs from 'node:fs';
import * as path from 'node:path';
import { glob } from 'glob';
+import Parser from 'tree-sitter';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
+import { readSafe } from './fs-utils.js';
+import {
+ GRPC_SCAN_GLOB,
+ getPluginForFile,
+ hasProtoPlugin,
+ type GrpcDetection,
+} from './grpc-patterns/index.js';
-function readSafe(repoPath: string, rel: string): string | null {
- const abs = path.resolve(repoPath, rel);
- const base = path.resolve(repoPath);
- const relToBase = path.relative(base, abs);
- if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null;
- try {
- return fs.readFileSync(abs, 'utf-8');
- } catch {
- return null;
- }
-}
+/**
+ * Language-agnostic orchestrator for gRPC (provider + consumer) contract
+ * extraction.
+ *
+ * Two parts:
+ *
+ * 1. **`.proto` parsing** — tree-sitter when `tree-sitter-proto` is
+ * installed (optionalDependency vendored in `vendor/tree-sitter-proto/`),
+ * via the `.proto` entry in `grpc-patterns/` and `hasProtoPlugin`.
+ * When the grammar isn't available (platform incompatibility, native
+ * build failure) the orchestrator falls back to the in-process
+ * string-sanitizing parser defined below (`stripProtoCommentsAndStrings`
+ * + `extractServiceBlocks`). The fallback preserves offsets so any
+ * downstream regex scans run against a sanitized copy without
+ * affecting line numbers of the original.
+ *
+ * 2. **Source-scan providers / consumers** — delegated to per-language
+ * plugins in `./grpc-patterns/`. The orchestrator imports NO
+ * tree-sitter grammars or query strings — each plugin owns its own.
+ */
+
+// ─── .proto fallback parser (used only when tree-sitter-proto is absent) ───
function contractId(pkg: string, service: string, method: string): string {
const prefix = pkg ? `${pkg}.${service}` : service;
@@ -25,20 +43,110 @@ function serviceOnlyContractId(serviceName: string): string {
return `grpc::${serviceName}/*`;
}
+/**
+ * Replace all .proto comments and string literals with spaces, preserving the
+ * original length and character offsets of the input. This lets downstream
+ * regex / brace-depth parsers run on a "sanitized" copy without having to
+ * understand proto syntax, while any RegExp.exec/index-based lookups that
+ * were already positional against `content` continue to work against the
+ * original string.
+ *
+ * Supported comment forms: `// line comment`, `/* block comment * /`.
+ * Supported strings: double-quoted ("…") and single-quoted ('…') with `\`
+ * escape handling. Raw/unterminated strings are not supported — we stop
+ * on a line break for line-style comments and on EOF for unterminated
+ * strings/blocks, which matches how most real proto files parse.
+ */
+function stripProtoCommentsAndStrings(content: string): string {
+ const out = new Array(content.length);
+ let i = 0;
+ while (i < content.length) {
+ const ch = content[i];
+ const next = content[i + 1];
+
+ // Line comment: // ... \n
+ if (ch === '/' && next === '/') {
+ out[i] = ' ';
+ out[i + 1] = ' ';
+ i += 2;
+ while (i < content.length && content[i] !== '\n') {
+ out[i] = content[i] === '\r' ? '\r' : ' ';
+ i++;
+ }
+ continue;
+ }
+
+ // Block comment: /* ... */
+ if (ch === '/' && next === '*') {
+ out[i] = ' ';
+ out[i + 1] = ' ';
+ i += 2;
+ while (i < content.length) {
+ if (content[i] === '*' && content[i + 1] === '/') {
+ out[i] = ' ';
+ out[i + 1] = ' ';
+ i += 2;
+ break;
+ }
+ // Preserve newlines so line numbers stay stable for downstream code.
+ out[i] = content[i] === '\n' || content[i] === '\r' ? content[i] : ' ';
+ i++;
+ }
+ continue;
+ }
+
+ // String literal: "..." or '...'
+ if (ch === '"' || ch === "'") {
+ const quote = ch;
+ out[i] = ' '; // replace opening quote
+ i++;
+ while (i < content.length) {
+ const c = content[i];
+ if (c === '\\' && i + 1 < content.length) {
+ // Skip escaped pair (e.g. \" \n \\)
+ out[i] = ' ';
+ out[i + 1] = ' ';
+ i += 2;
+ continue;
+ }
+ if (c === quote) {
+ out[i] = ' ';
+ i++;
+ break;
+ }
+ // Preserve newlines; proto technically disallows unescaped newlines
+ // inside strings, but real files occasionally have them.
+ out[i] = c === '\n' || c === '\r' ? c : ' ';
+ i++;
+ }
+ continue;
+ }
+
+ out[i] = ch;
+ i++;
+ }
+ return out.join('');
+}
+
function extractServiceBlocks(content: string): Array<{ name: string; body: string }> {
const results: Array<{ name: string; body: string }> = [];
- // v1: brace-depth only — braces inside comments or string literals are not filtered (see spec Fix 2)
+ // Sanitize comments and string literals so braces inside them don't
+ // throw off the depth counter. The sanitized copy has the same length
+ // and offsets as the original, so we use it ONLY to scan for service
+ // headers and braces; the service body we return is sliced from the
+ // ORIGINAL content to preserve exact source text for downstream use.
+ const sanitized = stripProtoCommentsAndStrings(content);
const headerRe = /service\s+(\w+)\s*\{/g;
let headerMatch: RegExpExecArray | null;
- while ((headerMatch = headerRe.exec(content)) !== null) {
+ while ((headerMatch = headerRe.exec(sanitized)) !== null) {
const serviceName = headerMatch[1];
const bodyStart = headerMatch.index + headerMatch[0].length;
let depth = 1;
let pos = bodyStart;
- while (pos < content.length && depth > 0) {
- const ch = content[pos];
+ while (pos < sanitized.length && depth > 0) {
+ const ch = sanitized[pos];
if (ch === '{') depth++;
else if (ch === '}') depth--;
pos++;
@@ -75,6 +183,165 @@ function makeContract(
};
}
+export interface ProtoServiceInfo {
+ package: string;
+ serviceName: string;
+ methods: string[];
+ protoPath: string;
+}
+
+function normalizeProtoPath(rel: string): string {
+ return rel.replace(/\\/g, '/');
+}
+
+function extractProtoImports(content: string): string[] {
+ const imports: string[] = [];
+ const re = /^\s*import\s+"([^"]+)"\s*;/gm;
+ let match: RegExpExecArray | null;
+ while ((match = re.exec(content)) !== null) {
+ imports.push(match[1]);
+ }
+ return imports;
+}
+
+function longestSharedSegmentRun(aPath: string, bPath: string): number {
+ const a = aPath.split('/').filter(Boolean);
+ const b = bPath.split('/').filter(Boolean);
+ let best = 0;
+
+ for (let i = 0; i < a.length; i++) {
+ for (let j = 0; j < b.length; j++) {
+ let run = 0;
+ while (a[i + run] && b[j + run] && a[i + run] === b[j + run]) {
+ run++;
+ }
+ if (run > best) best = run;
+ }
+ }
+
+ return best;
+}
+
+async function buildProtoContext(repoPath: string): Promise<{
+ packagesByProto: Map;
+ servicesByName: Map;
+}> {
+ const servicesByName = new Map();
+ const protoFiles = await glob('**/*.proto', {
+ cwd: repoPath,
+ absolute: false,
+ nodir: true,
+ ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'],
+ });
+ const contents = new Map();
+
+ for (const rel of protoFiles) {
+ const content = readSafe(repoPath, rel);
+ if (!content) continue;
+ contents.set(normalizeProtoPath(rel), content);
+ }
+
+ const packagesByProto = new Map();
+
+ const resolvePackage = (protoPath: string, seen = new Set()): string => {
+ if (packagesByProto.has(protoPath)) return packagesByProto.get(protoPath) ?? '';
+ if (seen.has(protoPath)) return '';
+
+ const content = contents.get(protoPath);
+ if (!content) return '';
+
+ seen.add(protoPath);
+ const pkgMatch = content.match(/^\s*package\s+([\w.]+)\s*;/m);
+ if (pkgMatch?.[1]) {
+ packagesByProto.set(protoPath, pkgMatch[1]);
+ return pkgMatch[1];
+ }
+
+ for (const importPath of extractProtoImports(content)) {
+ const normalizedImport = normalizeProtoPath(importPath);
+ const candidates = [
+ normalizeProtoPath(
+ path.posix.normalize(path.posix.join(path.posix.dirname(protoPath), normalizedImport)),
+ ),
+ normalizedImport,
+ ];
+ for (const candidate of candidates) {
+ if (!contents.has(candidate)) continue;
+ const inheritedPackage = resolvePackage(candidate, seen);
+ if (inheritedPackage) {
+ packagesByProto.set(protoPath, inheritedPackage);
+ return inheritedPackage;
+ }
+ }
+ }
+
+ packagesByProto.set(protoPath, '');
+ return '';
+ };
+
+ for (const rel of protoFiles) {
+ const normalizedRel = normalizeProtoPath(rel);
+ const content = contents.get(normalizedRel);
+ if (!content) continue;
+ const pkg = resolvePackage(normalizedRel);
+
+ const serviceBlocks = extractServiceBlocks(content);
+ for (const block of serviceBlocks) {
+ const rpcRe = /rpc\s+(\w+)\s*\(/g;
+ const methods: string[] = [];
+ let m: RegExpExecArray | null;
+ while ((m = rpcRe.exec(block.body)) !== null) {
+ methods.push(m[1]);
+ }
+ const info: ProtoServiceInfo = {
+ package: pkg,
+ serviceName: block.name,
+ methods,
+ protoPath: normalizedRel,
+ };
+ const existing = servicesByName.get(block.name) ?? [];
+ existing.push(info);
+ servicesByName.set(block.name, existing);
+ }
+ }
+
+ return { packagesByProto, servicesByName };
+}
+
+export async function buildProtoMap(repoPath: string): Promise