gitnexus 1.6.4-rc.80 → 1.6.4-rc.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/analyze.js +27 -0
- package/dist/cli/mcp.d.ts +21 -0
- package/dist/cli/mcp.js +42 -13
- package/dist/cli/optional-grammars.d.ts +47 -0
- package/dist/cli/optional-grammars.js +78 -0
- package/dist/cli/setup.js +21 -6
- package/dist/core/embeddings/embedder.js +8 -8
- package/dist/core/embeddings/embedding-pipeline.js +10 -10
- package/dist/core/lbug/extension-loader.js +1 -1
- package/dist/core/lbug/lbug-adapter.js +3 -3
- package/dist/core/lbug/pool-adapter.d.ts +1 -4
- package/dist/core/lbug/pool-adapter.js +19 -5
- package/dist/core/tree-sitter/parser-loader.js +4 -4
- package/dist/mcp/compatible-stdio-transport.js +7 -1
- package/dist/mcp/core/lbug-adapter.d.ts +7 -1
- package/dist/mcp/core/lbug-adapter.js +7 -1
- package/dist/mcp/server.js +18 -7
- package/dist/mcp/stdio-capture.d.ts +40 -0
- package/dist/mcp/stdio-capture.js +53 -0
- package/dist/mcp/stdio-context.d.ts +47 -0
- package/dist/mcp/stdio-context.js +145 -0
- package/package.json +2 -1
- package/scripts/build-tree-sitter-dart.cjs +11 -0
- package/scripts/build-tree-sitter-proto.cjs +11 -0
package/dist/cli/analyze.js
CHANGED
|
@@ -16,6 +16,8 @@ import { getStoragePaths, getGlobalRegistryPath, RegistryNameCollisionError, Ana
|
|
|
16
16
|
import { getGitRoot, hasGitDir } from '../storage/git.js';
|
|
17
17
|
import { runFullAnalysis } from '../core/run-analyze.js';
|
|
18
18
|
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
|
|
19
|
+
import { warnMissingOptionalGrammars } from './optional-grammars.js';
|
|
20
|
+
import { glob } from 'glob';
|
|
19
21
|
import fs from 'fs/promises';
|
|
20
22
|
// Capture stderr.write at module load BEFORE anything (LadybugDB native
|
|
21
23
|
// init, progress bar, console redirection) can monkey-patch it. The
|
|
@@ -173,6 +175,31 @@ export const analyzeCommand = async (inputPath, options) => {
|
|
|
173
175
|
if (!repoHasGit) {
|
|
174
176
|
console.log(' Warning: no .git directory found \u2014 commit-tracking and incremental updates disabled.\n');
|
|
175
177
|
}
|
|
178
|
+
// If the target repo contains files an optional grammar would parse but
|
|
179
|
+
// that grammar's native binding is absent, warn before analysis so users
|
|
180
|
+
// learn why those files end up unparsed instead of silently getting a
|
|
181
|
+
// degraded index.
|
|
182
|
+
try {
|
|
183
|
+
const matches = await glob(['**/*.dart', '**/*.proto'], {
|
|
184
|
+
cwd: repoPath,
|
|
185
|
+
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
|
|
186
|
+
dot: false,
|
|
187
|
+
nodir: true,
|
|
188
|
+
absolute: false,
|
|
189
|
+
});
|
|
190
|
+
if (matches.length > 0) {
|
|
191
|
+
const present = new Set();
|
|
192
|
+
for (const m of matches) {
|
|
193
|
+
const ext = path.extname(m).toLowerCase();
|
|
194
|
+
if (ext)
|
|
195
|
+
present.add(ext);
|
|
196
|
+
}
|
|
197
|
+
warnMissingOptionalGrammars({ context: 'analyze', relevantExtensions: present });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Best-effort warning \u2014 never block analyze on the precheck.
|
|
202
|
+
}
|
|
176
203
|
// KuzuDB migration cleanup is handled by runFullAnalysis internally.
|
|
177
204
|
// Note: --skills is handled after runFullAnalysis using the returned pipelineResult.
|
|
178
205
|
if (process.env.GITNEXUS_NO_GITIGNORE) {
|
package/dist/cli/mcp.d.ts
CHANGED
|
@@ -4,5 +4,26 @@
|
|
|
4
4
|
* Starts the MCP server in standalone mode.
|
|
5
5
|
* Loads all indexed repos from the global registry.
|
|
6
6
|
* No longer depends on cwd — works from any directory.
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT: this module's static-import closure is intentionally tiny
|
|
9
|
+
* (one chain: `mcp/stdio-context.js` → `mcp/stdio-capture.js`, which is a
|
|
10
|
+
* leaf with zero non-`node:` imports). All heavy backend modules
|
|
11
|
+
* (`startMCPServer`, `LocalBackend`, `warnMissingOptionalGrammars`) load
|
|
12
|
+
* via `await import(...)` AFTER `installGlobalStdoutSentinel()` runs.
|
|
13
|
+
*
|
|
14
|
+
* This closes the ESM-evaluation-order window where native init banners
|
|
15
|
+
* from `@ladybugdb/core` (or any future heavy import) could reach raw
|
|
16
|
+
* stdout before the sentinel exists. Codex's adversarial review on
|
|
17
|
+
* PR #1383 found that even with the sentinel-install call as the first
|
|
18
|
+
* statement of `mcpCommand`, ESM evaluates static imports of THIS module
|
|
19
|
+
* before the function body runs — so any native side effects during
|
|
20
|
+
* those imports happen before the sentinel can intercept them.
|
|
21
|
+
*
|
|
22
|
+
* If you find yourself adding a static `import` to this file, ask
|
|
23
|
+
* whether the imported module (or anything it transitively imports)
|
|
24
|
+
* touches `process.stdout` or loads a native binding at module init. If
|
|
25
|
+
* either is true, switch it to a dynamic `await import(...)` inside
|
|
26
|
+
* `mcpCommand` after the sentinel install. The regression test at
|
|
27
|
+
* `gitnexus/test/integration/mcp/import-closure.test.ts` enforces this.
|
|
7
28
|
*/
|
|
8
29
|
export declare const mcpCommand: () => Promise<void>;
|
package/dist/cli/mcp.js
CHANGED
|
@@ -4,21 +4,50 @@
|
|
|
4
4
|
* Starts the MCP server in standalone mode.
|
|
5
5
|
* Loads all indexed repos from the global registry.
|
|
6
6
|
* No longer depends on cwd — works from any directory.
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT: this module's static-import closure is intentionally tiny
|
|
9
|
+
* (one chain: `mcp/stdio-context.js` → `mcp/stdio-capture.js`, which is a
|
|
10
|
+
* leaf with zero non-`node:` imports). All heavy backend modules
|
|
11
|
+
* (`startMCPServer`, `LocalBackend`, `warnMissingOptionalGrammars`) load
|
|
12
|
+
* via `await import(...)` AFTER `installGlobalStdoutSentinel()` runs.
|
|
13
|
+
*
|
|
14
|
+
* This closes the ESM-evaluation-order window where native init banners
|
|
15
|
+
* from `@ladybugdb/core` (or any future heavy import) could reach raw
|
|
16
|
+
* stdout before the sentinel exists. Codex's adversarial review on
|
|
17
|
+
* PR #1383 found that even with the sentinel-install call as the first
|
|
18
|
+
* statement of `mcpCommand`, ESM evaluates static imports of THIS module
|
|
19
|
+
* before the function body runs — so any native side effects during
|
|
20
|
+
* those imports happen before the sentinel can intercept them.
|
|
21
|
+
*
|
|
22
|
+
* If you find yourself adding a static `import` to this file, ask
|
|
23
|
+
* whether the imported module (or anything it transitively imports)
|
|
24
|
+
* touches `process.stdout` or loads a native binding at module init. If
|
|
25
|
+
* either is true, switch it to a dynamic `await import(...)` inside
|
|
26
|
+
* `mcpCommand` after the sentinel install. The regression test at
|
|
27
|
+
* `gitnexus/test/integration/mcp/import-closure.test.ts` enforces this.
|
|
7
28
|
*/
|
|
8
|
-
import {
|
|
9
|
-
import { LocalBackend } from '../mcp/local/local-backend.js';
|
|
29
|
+
import { installGlobalStdoutSentinel } from '../mcp/stdio-context.js';
|
|
10
30
|
export const mcpCommand = async () => {
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
// Install the global stdout sentinel as the very first thing — before
|
|
32
|
+
// ANY other module loads. The static-import closure above is leaf-only
|
|
33
|
+
// (stdio-context → stdio-capture, zero non-`node:` deps), so this is
|
|
34
|
+
// also the first chance any code in this process has to write to stdout.
|
|
35
|
+
installGlobalStdoutSentinel();
|
|
36
|
+
// uncaughtException/unhandledRejection handlers are owned by
|
|
37
|
+
// startMCPServer (gitnexus/src/mcp/server.ts) so the server's shutdown
|
|
38
|
+
// path runs cleanly with full stack traces. Registering duplicates here
|
|
39
|
+
// would only produce noisy double-logging on the same exception.
|
|
40
|
+
// Now safe to dynamically import the heavy backend modules. Anything
|
|
41
|
+
// they emit to stdout during evaluation will route through the sentinel.
|
|
42
|
+
const [{ startMCPServer }, { LocalBackend }] = await Promise.all([
|
|
43
|
+
import('../mcp/server.js'),
|
|
44
|
+
import('../mcp/local/local-backend.js'),
|
|
45
|
+
]);
|
|
46
|
+
// Missing-optional-grammar warnings are intentionally NOT emitted here.
|
|
47
|
+
// `gitnexus analyze` already warns at index time, filtered by the repo's
|
|
48
|
+
// actual extensions, and a repo can only be served by MCP after analyze
|
|
49
|
+
// has run. Repeating an unconditional warning at every MCP startup is
|
|
50
|
+
// pure noise for users whose indexed repos don't use Dart/Proto.
|
|
22
51
|
// Initialize multi-repo backend from registry.
|
|
23
52
|
// The server starts even with 0 repos — tools call refreshRepos() lazily,
|
|
24
53
|
// so repos indexed after the server starts are discovered automatically.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional grammar availability check.
|
|
3
|
+
*
|
|
4
|
+
* tree-sitter-dart and tree-sitter-proto are optionalDependencies that
|
|
5
|
+
* require a `node-gyp rebuild` at install time. The build can be skipped
|
|
6
|
+
* via GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or it can
|
|
7
|
+
* silently soft-fail when the C++ toolchain is missing.
|
|
8
|
+
*
|
|
9
|
+
* Either path produces the same observable: the .node binding is absent
|
|
10
|
+
* at runtime. This helper detects that condition and surfaces a single
|
|
11
|
+
* stderr line per missing grammar so users learn why .dart/.proto support
|
|
12
|
+
* is unavailable instead of silently getting a degraded index.
|
|
13
|
+
*/
|
|
14
|
+
export interface MissingGrammar {
|
|
15
|
+
name: string;
|
|
16
|
+
extensions: string[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Returns the list of optional grammars whose native binding cannot be
|
|
20
|
+
* loaded. Actually `require()`s the package — `require.resolve` would
|
|
21
|
+
* locate the entry path even when the `.node` binding is absent (the
|
|
22
|
+
* `file:` package directory is installed regardless of postinstall
|
|
23
|
+
* outcome), giving false negatives for the exact users we want to warn:
|
|
24
|
+
* those who installed with `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` or whose
|
|
25
|
+
* native rebuild soft-failed for missing toolchain.
|
|
26
|
+
*
|
|
27
|
+
* Node's module cache memoizes `require()` for us — calling this multiple
|
|
28
|
+
* times is cheap. The catch distinguishes "missing" (MODULE_NOT_FOUND or
|
|
29
|
+
* the typical node-gyp-build "could not find any binding" pattern) from
|
|
30
|
+
* "broken" (SyntaxError, EACCES, native crash). Broken bindings surface a
|
|
31
|
+
* separate stderr line so users get an actionable message instead of a
|
|
32
|
+
* misleading "reinstall" hint.
|
|
33
|
+
*/
|
|
34
|
+
export declare function detectMissingOptionalGrammars(): MissingGrammar[];
|
|
35
|
+
/**
|
|
36
|
+
* Log a one-line stderr warning for each missing grammar. Safe to call
|
|
37
|
+
* unconditionally — silent if all grammars are present.
|
|
38
|
+
*
|
|
39
|
+
* `relevantExtensions`, if provided, filters the warning to grammars whose
|
|
40
|
+
* extensions appear in the set (e.g. an analyze run can pass the set of
|
|
41
|
+
* extensions actually present in the target repo so users without any
|
|
42
|
+
* .dart/.proto files don't see noise).
|
|
43
|
+
*/
|
|
44
|
+
export declare function warnMissingOptionalGrammars(opts?: {
|
|
45
|
+
context?: string;
|
|
46
|
+
relevantExtensions?: ReadonlySet<string>;
|
|
47
|
+
}): void;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional grammar availability check.
|
|
3
|
+
*
|
|
4
|
+
* tree-sitter-dart and tree-sitter-proto are optionalDependencies that
|
|
5
|
+
* require a `node-gyp rebuild` at install time. The build can be skipped
|
|
6
|
+
* via GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or it can
|
|
7
|
+
* silently soft-fail when the C++ toolchain is missing.
|
|
8
|
+
*
|
|
9
|
+
* Either path produces the same observable: the .node binding is absent
|
|
10
|
+
* at runtime. This helper detects that condition and surfaces a single
|
|
11
|
+
* stderr line per missing grammar so users learn why .dart/.proto support
|
|
12
|
+
* is unavailable instead of silently getting a degraded index.
|
|
13
|
+
*/
|
|
14
|
+
import { createRequire } from 'module';
|
|
15
|
+
const _require = createRequire(import.meta.url);
|
|
16
|
+
const OPTIONAL_GRAMMARS = [
|
|
17
|
+
{ name: 'tree-sitter-dart', pkg: 'tree-sitter-dart', extensions: ['.dart'] },
|
|
18
|
+
{ name: 'tree-sitter-proto', pkg: 'tree-sitter-proto', extensions: ['.proto'] },
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Returns the list of optional grammars whose native binding cannot be
|
|
22
|
+
* loaded. Actually `require()`s the package — `require.resolve` would
|
|
23
|
+
* locate the entry path even when the `.node` binding is absent (the
|
|
24
|
+
* `file:` package directory is installed regardless of postinstall
|
|
25
|
+
* outcome), giving false negatives for the exact users we want to warn:
|
|
26
|
+
* those who installed with `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` or whose
|
|
27
|
+
* native rebuild soft-failed for missing toolchain.
|
|
28
|
+
*
|
|
29
|
+
* Node's module cache memoizes `require()` for us — calling this multiple
|
|
30
|
+
* times is cheap. The catch distinguishes "missing" (MODULE_NOT_FOUND or
|
|
31
|
+
* the typical node-gyp-build "could not find any binding" pattern) from
|
|
32
|
+
* "broken" (SyntaxError, EACCES, native crash). Broken bindings surface a
|
|
33
|
+
* separate stderr line so users get an actionable message instead of a
|
|
34
|
+
* misleading "reinstall" hint.
|
|
35
|
+
*/
|
|
36
|
+
export function detectMissingOptionalGrammars() {
|
|
37
|
+
const missing = [];
|
|
38
|
+
for (const g of OPTIONAL_GRAMMARS) {
|
|
39
|
+
try {
|
|
40
|
+
_require(g.pkg);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
const code = err?.code;
|
|
44
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
45
|
+
const looksMissing = code === 'MODULE_NOT_FOUND' ||
|
|
46
|
+
code === 'ERR_MODULE_NOT_FOUND' ||
|
|
47
|
+
/could not find|no native build|prebuilds/i.test(msg);
|
|
48
|
+
if (!looksMissing) {
|
|
49
|
+
// Present but broken — surface so the user doesn't get a misleading
|
|
50
|
+
// "reinstall" recovery message that wouldn't actually help.
|
|
51
|
+
console.error(`GitNexus: optional grammar "${g.name}" is installed but failed to load (${msg.slice(0, 200)}). ${g.extensions.join('/')} files will not be parsed.`);
|
|
52
|
+
}
|
|
53
|
+
missing.push({ name: g.name, extensions: g.extensions });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return missing;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Log a one-line stderr warning for each missing grammar. Safe to call
|
|
60
|
+
* unconditionally — silent if all grammars are present.
|
|
61
|
+
*
|
|
62
|
+
* `relevantExtensions`, if provided, filters the warning to grammars whose
|
|
63
|
+
* extensions appear in the set (e.g. an analyze run can pass the set of
|
|
64
|
+
* extensions actually present in the target repo so users without any
|
|
65
|
+
* .dart/.proto files don't see noise).
|
|
66
|
+
*/
|
|
67
|
+
export function warnMissingOptionalGrammars(opts) {
|
|
68
|
+
const missing = detectMissingOptionalGrammars();
|
|
69
|
+
if (missing.length === 0)
|
|
70
|
+
return;
|
|
71
|
+
const ctx = opts?.context ? ` [${opts.context}]` : '';
|
|
72
|
+
for (const g of missing) {
|
|
73
|
+
if (opts?.relevantExtensions && !g.extensions.some((e) => opts.relevantExtensions.has(e))) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
console.error(`GitNexus${ctx}: optional grammar "${g.name}" is unavailable — ${g.extensions.join('/')} files will not be parsed. Reinstall without GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (and ensure python3, make, g++) to enable.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/cli/setup.js
CHANGED
|
@@ -9,6 +9,7 @@ import fs from 'fs/promises';
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import os from 'os';
|
|
11
11
|
import { execFile, execFileSync } from 'child_process';
|
|
12
|
+
import { createRequire } from 'module';
|
|
12
13
|
import { promisify } from 'util';
|
|
13
14
|
import { fileURLToPath } from 'url';
|
|
14
15
|
import { glob } from 'glob';
|
|
@@ -17,6 +18,18 @@ import { getGlobalDir } from '../storage/repo-manager.js';
|
|
|
17
18
|
const __filename = fileURLToPath(import.meta.url);
|
|
18
19
|
const __dirname = path.dirname(__filename);
|
|
19
20
|
const execFileAsync = promisify(execFile);
|
|
21
|
+
// Pin the npx fallback to the installed version. Reason: setup.ts writes
|
|
22
|
+
// a config that persists in the user's editor and is invoked on every MCP
|
|
23
|
+
// connect. Pinning to the installed version means subsequent invocations
|
|
24
|
+
// skip the npm-registry metadata roundtrip (and stay reproducible until
|
|
25
|
+
// the user upgrades). Static configs and READMEs intentionally use
|
|
26
|
+
// `gitnexus@latest` since they're quickstart docs, not persisted state.
|
|
27
|
+
const _require = createRequire(import.meta.url);
|
|
28
|
+
const _pkg = _require('../../package.json');
|
|
29
|
+
if (typeof _pkg.version !== 'string' || !_pkg.version) {
|
|
30
|
+
throw new Error('gitnexus/package.json#version is missing or not a string — cannot generate MCP fallback config.');
|
|
31
|
+
}
|
|
32
|
+
const NPX_REF = `gitnexus@${_pkg.version}`;
|
|
20
33
|
/**
|
|
21
34
|
* Resolve the absolute path to the `gitnexus` binary if it's installed
|
|
22
35
|
* globally (or via npm -g / yarn global). Returns null when not found.
|
|
@@ -51,8 +64,10 @@ function resolveGitnexusBin() {
|
|
|
51
64
|
* The MCP server entry for all editors.
|
|
52
65
|
*
|
|
53
66
|
* Prefers the globally-installed `gitnexus` binary (starts in ~1 s) over
|
|
54
|
-
* `npx -y gitnexus
|
|
55
|
-
* >60 s, exceeding Claude Code's 30 s MCP connection timeout).
|
|
67
|
+
* `npx -y gitnexus@<version>` (cold-cache install of native deps can take
|
|
68
|
+
* >60 s, exceeding Claude Code's 30 s MCP connection timeout). The fallback
|
|
69
|
+
* version is read from gitnexus/package.json#version at module load so the
|
|
70
|
+
* persisted user config matches the installed package.
|
|
56
71
|
*
|
|
57
72
|
* Falls back to npx when the binary isn't on PATH — e.g. first-time
|
|
58
73
|
* users who ran `npx gitnexus analyze` but haven't done `npm i -g`.
|
|
@@ -66,12 +81,12 @@ function getMcpEntry() {
|
|
|
66
81
|
if (process.platform === 'win32') {
|
|
67
82
|
return {
|
|
68
83
|
command: 'cmd',
|
|
69
|
-
args: ['/c', 'npx', '-y',
|
|
84
|
+
args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
|
|
70
85
|
};
|
|
71
86
|
}
|
|
72
87
|
return {
|
|
73
88
|
command: 'npx',
|
|
74
|
-
args: ['-y',
|
|
89
|
+
args: ['-y', NPX_REF, 'mcp'],
|
|
75
90
|
};
|
|
76
91
|
}
|
|
77
92
|
/**
|
|
@@ -84,9 +99,9 @@ function getOpenCodeMcpEntry() {
|
|
|
84
99
|
return { type: 'local', command: [bin, 'mcp'] };
|
|
85
100
|
}
|
|
86
101
|
if (process.platform === 'win32') {
|
|
87
|
-
return { type: 'local', command: ['cmd', '/c', 'npx', '-y',
|
|
102
|
+
return { type: 'local', command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'] };
|
|
88
103
|
}
|
|
89
|
-
return { type: 'local', command: ['npx', '-y',
|
|
104
|
+
return { type: 'local', command: ['npx', '-y', NPX_REF, 'mcp'] };
|
|
90
105
|
}
|
|
91
106
|
/**
|
|
92
107
|
* Detect indentation style from file content.
|
|
@@ -139,7 +139,7 @@ export const initEmbedder = async (onProgress, config = {}, forceDevice) => {
|
|
|
139
139
|
applyHfEnvOverrides(env);
|
|
140
140
|
const isDev = process.env.NODE_ENV === 'development';
|
|
141
141
|
if (isDev) {
|
|
142
|
-
console.
|
|
142
|
+
console.error(`🧠 Loading embedding model: ${finalConfig.modelId}`);
|
|
143
143
|
}
|
|
144
144
|
const progressCallback = onProgress
|
|
145
145
|
? (data) => {
|
|
@@ -161,16 +161,16 @@ export const initEmbedder = async (onProgress, config = {}, forceDevice) => {
|
|
|
161
161
|
for (const device of devicesToTry) {
|
|
162
162
|
try {
|
|
163
163
|
if (isDev && device === 'dml') {
|
|
164
|
-
console.
|
|
164
|
+
console.error('🔧 Trying DirectML (DirectX12) GPU backend...');
|
|
165
165
|
}
|
|
166
166
|
else if (isDev && device === 'cuda') {
|
|
167
|
-
console.
|
|
167
|
+
console.error('🔧 Trying CUDA GPU backend...');
|
|
168
168
|
}
|
|
169
169
|
else if (isDev && device === 'cpu') {
|
|
170
|
-
console.
|
|
170
|
+
console.error('🔧 Using CPU backend...');
|
|
171
171
|
}
|
|
172
172
|
else if (isDev && device === 'wasm') {
|
|
173
|
-
console.
|
|
173
|
+
console.error('🔧 Using WASM backend (slower)...');
|
|
174
174
|
}
|
|
175
175
|
embedderInstance = await pipeline('feature-extraction', finalConfig.modelId, {
|
|
176
176
|
device: device,
|
|
@@ -190,15 +190,15 @@ export const initEmbedder = async (onProgress, config = {}, forceDevice) => {
|
|
|
190
190
|
: device === 'cuda'
|
|
191
191
|
? 'GPU (CUDA)'
|
|
192
192
|
: device.toUpperCase();
|
|
193
|
-
console.
|
|
194
|
-
console.
|
|
193
|
+
console.error(`✅ Using ${label} backend`);
|
|
194
|
+
console.error('✅ Embedding model loaded successfully');
|
|
195
195
|
}
|
|
196
196
|
return embedderInstance;
|
|
197
197
|
}
|
|
198
198
|
catch (deviceError) {
|
|
199
199
|
if (isDev && (device === 'cuda' || device === 'dml')) {
|
|
200
200
|
const gpuType = device === 'dml' ? 'DirectML' : 'CUDA';
|
|
201
|
-
console.
|
|
201
|
+
console.error(`⚠️ ${gpuType} not available, falling back to CPU...`);
|
|
202
202
|
}
|
|
203
203
|
// Continue to next device in list
|
|
204
204
|
if (device === devicesToTry[devicesToTry.length - 1]) {
|
|
@@ -112,7 +112,7 @@ const queryEmbeddableNodes = async (executeQuery) => {
|
|
|
112
112
|
}
|
|
113
113
|
catch (error) {
|
|
114
114
|
if (isDev) {
|
|
115
|
-
console.
|
|
115
|
+
console.error(`Query for ${label} nodes failed:`, error);
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
}
|
|
@@ -151,7 +151,7 @@ const createVectorIndex = async (executeQuery) => {
|
|
|
151
151
|
}
|
|
152
152
|
catch (error) {
|
|
153
153
|
if (isDev) {
|
|
154
|
-
console.
|
|
154
|
+
console.error('Vector index creation warning:', error);
|
|
155
155
|
}
|
|
156
156
|
return false;
|
|
157
157
|
}
|
|
@@ -176,7 +176,7 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
|
|
|
176
176
|
try {
|
|
177
177
|
const vectorAvailable = await ensureVectorExtensionAvailable();
|
|
178
178
|
if (!vectorAvailable && isDev)
|
|
179
|
-
console.
|
|
179
|
+
console.error(vectorUnavailableMessage);
|
|
180
180
|
// Phase 1: Load embedding model
|
|
181
181
|
onProgress({
|
|
182
182
|
phase: 'loading-model',
|
|
@@ -199,7 +199,7 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
|
|
|
199
199
|
modelDownloadPercent: 100,
|
|
200
200
|
});
|
|
201
201
|
if (isDev) {
|
|
202
|
-
console.
|
|
202
|
+
console.error('🔍 Querying embeddable nodes...');
|
|
203
203
|
}
|
|
204
204
|
// Phase 2: Query embeddable nodes
|
|
205
205
|
let nodes = await queryEmbeddableNodes(executeQuery);
|
|
@@ -237,7 +237,7 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
|
|
|
237
237
|
// (Kuzu forbids SET on vector-indexed properties; DELETE-then-INSERT is the sanctioned pattern)
|
|
238
238
|
if (staleNodeIds.length > 0) {
|
|
239
239
|
if (isDev) {
|
|
240
|
-
console.
|
|
240
|
+
console.error(`🔄 Deleting ${staleNodeIds.length} stale embedding rows for re-embed`);
|
|
241
241
|
}
|
|
242
242
|
try {
|
|
243
243
|
await executeWithReusedStatement(`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) DELETE e`, staleNodeIds.map((nodeId) => ({ nodeId })));
|
|
@@ -253,12 +253,12 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
|
|
|
253
253
|
}
|
|
254
254
|
}
|
|
255
255
|
if (isDev) {
|
|
256
|
-
console.
|
|
256
|
+
console.error(`📦 Incremental embeddings: ${beforeCount} total, ${existingEmbeddings.size} cached, ${staleNodeIds.length} stale, ${nodes.length} to embed`);
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
259
|
const totalNodes = nodes.length;
|
|
260
260
|
if (isDev) {
|
|
261
|
-
console.
|
|
261
|
+
console.error(`📊 Found ${totalNodes} embeddable nodes`);
|
|
262
262
|
}
|
|
263
263
|
if (totalNodes === 0) {
|
|
264
264
|
// Ensure the vector index exists even when no new nodes need embedding.
|
|
@@ -324,7 +324,7 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
|
|
|
324
324
|
}
|
|
325
325
|
catch (chunkErr) {
|
|
326
326
|
if (isDev) {
|
|
327
|
-
console.
|
|
327
|
+
console.error(`⚠️ AST chunking failed for ${node.label} "${node.name}" (${node.filePath}), falling back to character-based chunking:`, chunkErr);
|
|
328
328
|
}
|
|
329
329
|
chunks = characterChunk(node.content, startLine, endLine, chunkSize, overlap);
|
|
330
330
|
}
|
|
@@ -382,7 +382,7 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
|
|
|
382
382
|
totalNodes,
|
|
383
383
|
});
|
|
384
384
|
if (isDev) {
|
|
385
|
-
console.
|
|
385
|
+
console.error('📇 Creating vector index...');
|
|
386
386
|
}
|
|
387
387
|
const vectorIndexReady = await createVectorIndex(executeQuery);
|
|
388
388
|
onProgress({
|
|
@@ -392,7 +392,7 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
|
|
|
392
392
|
totalNodes,
|
|
393
393
|
});
|
|
394
394
|
if (isDev) {
|
|
395
|
-
console.
|
|
395
|
+
console.error(`✅ Embedding pipeline complete! (${totalChunks} chunks from ${totalNodes} nodes)`);
|
|
396
396
|
}
|
|
397
397
|
return {
|
|
398
398
|
nodesProcessed: totalNodes,
|
|
@@ -125,7 +125,7 @@ export class ExtensionManager {
|
|
|
125
125
|
}
|
|
126
126
|
const policy = opts.policy ?? this.options.policy ?? resolvePolicyFromEnv();
|
|
127
127
|
const timeoutMs = opts.installTimeoutMs ?? this.options.installTimeoutMs ?? getExtensionInstallTimeoutMs();
|
|
128
|
-
const warn = this.options.warn ?? console.
|
|
128
|
+
const warn = this.options.warn ?? console.error;
|
|
129
129
|
if (policy === 'never') {
|
|
130
130
|
this.markUnavailable(name, label, 'extension install policy is "never"', warn);
|
|
131
131
|
return false;
|
|
@@ -274,7 +274,7 @@ const doInitLbug = async (dbPath) => {
|
|
|
274
274
|
catch (err) {
|
|
275
275
|
const msg = err instanceof Error ? err.message : String(err);
|
|
276
276
|
if (!msg.includes('already exists')) {
|
|
277
|
-
console.
|
|
277
|
+
console.error(`[gitnexus:lbug] schema creation warning: ${msg.slice(0, 120)}`);
|
|
278
278
|
}
|
|
279
279
|
}
|
|
280
280
|
}
|
|
@@ -889,13 +889,13 @@ export const fetchExistingEmbeddingHashes = async (execQuery) => {
|
|
|
889
889
|
if (nodeId)
|
|
890
890
|
map.set(nodeId, STALE_HASH_SENTINEL);
|
|
891
891
|
}
|
|
892
|
-
console.
|
|
892
|
+
console.error(`[gitnexus:embed] ${map.size} nodes in legacy DB (missing chunk-aware columns) — all treated as stale`);
|
|
893
893
|
return map;
|
|
894
894
|
}
|
|
895
895
|
catch (fallbackErr) {
|
|
896
896
|
const fallbackMsg = fallbackErr?.message ?? '';
|
|
897
897
|
if (isMissingColumnOrTableError(fallbackMsg)) {
|
|
898
|
-
console.
|
|
898
|
+
console.error(`[gitnexus:embed] CodeEmbedding table not yet present — full embedding run (${fallbackMsg})`);
|
|
899
899
|
return undefined;
|
|
900
900
|
}
|
|
901
901
|
throw fallbackErr;
|
|
@@ -31,9 +31,7 @@ type PoolCloseListener = (repoId: string) => void;
|
|
|
31
31
|
* listener (handy for tests).
|
|
32
32
|
*/
|
|
33
33
|
export declare function addPoolCloseListener(listener: PoolCloseListener): () => void;
|
|
34
|
-
|
|
35
|
-
export declare const realStdoutWrite: any;
|
|
36
|
-
export declare const realStderrWrite: any;
|
|
34
|
+
export { realStdoutWrite, realStderrWrite, setActiveStdoutWrite } from '../../mcp/stdio-capture.js';
|
|
37
35
|
/**
|
|
38
36
|
* Touch a repo to reset its idle timeout.
|
|
39
37
|
* Call this during long-running operations to prevent the connection from being closed.
|
|
@@ -90,4 +88,3 @@ export declare const isLbugReady: (repoId: string) => boolean;
|
|
|
90
88
|
export declare const CYPHER_WRITE_RE: RegExp;
|
|
91
89
|
/** Check if a Cypher query contains write operations */
|
|
92
90
|
export declare function isWriteQuery(query: string): boolean;
|
|
93
|
-
export {};
|
|
@@ -38,9 +38,20 @@ const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
38
38
|
/** Max connections per repo (caps concurrent queries per repo) */
|
|
39
39
|
const MAX_CONNS_PER_REPO = 8;
|
|
40
40
|
let idleTimer = null;
|
|
41
|
-
|
|
42
|
-
export
|
|
43
|
-
|
|
41
|
+
// Stdout-capture state lives in `gitnexus/src/mcp/stdio-capture.ts` — a leaf
|
|
42
|
+
// module with zero non-`node:` imports. We re-export the same symbols here
|
|
43
|
+
// so the existing test mock seam (`gitnexus/src/mcp/core/lbug-adapter.ts`
|
|
44
|
+
// re-exports * from this file, and 8+ test files use that path with
|
|
45
|
+
// `vi.mock(...)`) continues to work without churn. The source of truth is
|
|
46
|
+
// the leaf module; this re-export is a compatibility shim.
|
|
47
|
+
//
|
|
48
|
+
// Why the leaf module exists: Codex's adversarial review on PR #1383 found
|
|
49
|
+
// that putting this state in pool-adapter.ts pulled `@ladybugdb/core` into
|
|
50
|
+
// `cli/mcp.ts`'s static-import closure (via stdio-context → pool-adapter →
|
|
51
|
+
// @ladybugdb/core), corrupting stdout in the pre-sentinel window. Routing
|
|
52
|
+
// through the leaf breaks that chain.
|
|
53
|
+
export { realStdoutWrite, realStderrWrite, setActiveStdoutWrite } from '../../mcp/stdio-capture.js';
|
|
54
|
+
import { getActiveStdoutWrite } from '../../mcp/stdio-capture.js';
|
|
44
55
|
let stdoutSilenceCount = 0;
|
|
45
56
|
/** True while pre-warming connections — prevents watchdog from prematurely restoring stdout */
|
|
46
57
|
let preWarmActive = false;
|
|
@@ -155,13 +166,15 @@ let activeQueryCount = 0;
|
|
|
155
166
|
*/
|
|
156
167
|
export function silenceStdout() {
|
|
157
168
|
if (stdoutSilenceCount++ === 0) {
|
|
169
|
+
// eslint-disable-next-line no-restricted-syntax -- silencing infrastructure; replacement is a no-op
|
|
158
170
|
process.stdout.write = (() => true);
|
|
159
171
|
}
|
|
160
172
|
}
|
|
161
173
|
export function restoreStdout() {
|
|
162
174
|
if (--stdoutSilenceCount <= 0) {
|
|
163
175
|
stdoutSilenceCount = 0;
|
|
164
|
-
|
|
176
|
+
// eslint-disable-next-line no-restricted-syntax -- restoring the active stdout-write handler is the silencing API contract
|
|
177
|
+
process.stdout.write = getActiveStdoutWrite();
|
|
165
178
|
}
|
|
166
179
|
}
|
|
167
180
|
// Safety watchdog: restore stdout if it gets stuck silenced (e.g. native crash
|
|
@@ -171,7 +184,8 @@ export function restoreStdout() {
|
|
|
171
184
|
setInterval(() => {
|
|
172
185
|
if (stdoutSilenceCount > 0 && !preWarmActive && activeQueryCount === 0) {
|
|
173
186
|
stdoutSilenceCount = 0;
|
|
174
|
-
|
|
187
|
+
// eslint-disable-next-line no-restricted-syntax -- watchdog recovery for stuck silencing
|
|
188
|
+
process.stdout.write = getActiveStdoutWrite();
|
|
175
189
|
}
|
|
176
190
|
}, 1000).unref();
|
|
177
191
|
function createConnection(db) {
|
|
@@ -114,10 +114,10 @@ const logFailure = (key, result) => {
|
|
|
114
114
|
return;
|
|
115
115
|
logged.add(key);
|
|
116
116
|
const message = `[gitnexus] ${result.note} (${result.error.message})`;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
// Both severities go to stderr — console.warn writes to stderr too, but
|
|
118
|
+
// console.error is the stdout-safe channel we standardize on across
|
|
119
|
+
// MCP-reachable code so the ESLint rule covers this directory.
|
|
120
|
+
console.error(message);
|
|
121
121
|
};
|
|
122
122
|
export const resolveLanguageKey = (language, filePath) => language === SupportedLanguages.TypeScript && filePath?.endsWith('.tsx')
|
|
123
123
|
? `${language}:tsx`
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
2
|
import { JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { withMcpWrite } from './stdio-context.js';
|
|
3
4
|
function deserializeMessage(raw) {
|
|
4
5
|
return JSONRPCMessageSchema.parse(JSON.parse(raw));
|
|
5
6
|
}
|
|
@@ -185,7 +186,12 @@ export class CompatibleStdioServerTransport {
|
|
|
185
186
|
reject(error);
|
|
186
187
|
};
|
|
187
188
|
this._stdout.on('error', onError);
|
|
188
|
-
|
|
189
|
+
// Tag the write with the MCP transport context so the sentinel
|
|
190
|
+
// (server.ts createStdoutSentinel Proxy) recognizes it as a legitimate
|
|
191
|
+
// JSON-RPC frame and passes it through to the real stdout instead of
|
|
192
|
+
// redirecting to stderr.
|
|
193
|
+
const writeOk = withMcpWrite(() => this._stdout.write(payload));
|
|
194
|
+
if (writeOk) {
|
|
189
195
|
this._stdout.removeListener('error', onError);
|
|
190
196
|
resolve();
|
|
191
197
|
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LadybugDB connection pool — re-exported from core.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* KEEP THIS FILE. It is intentionally a shim re-export of
|
|
5
|
+
* `../../core/lbug/pool-adapter.js`. The MCP test suite uses this path as
|
|
6
|
+
* a vi.mock seam so unit tests can stub LadybugDB without affecting other
|
|
7
|
+
* importers of `core/lbug/pool-adapter.js` (which is shared with the
|
|
8
|
+
* analyze pipeline). New non-test code MAY import from `pool-adapter.js`
|
|
9
|
+
* directly, but the shim must continue to exist for the mock seam to work.
|
|
4
10
|
*/
|
|
5
11
|
export * from '../../core/lbug/pool-adapter.js';
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LadybugDB connection pool — re-exported from core.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* KEEP THIS FILE. It is intentionally a shim re-export of
|
|
5
|
+
* `../../core/lbug/pool-adapter.js`. The MCP test suite uses this path as
|
|
6
|
+
* a vi.mock seam so unit tests can stub LadybugDB without affecting other
|
|
7
|
+
* importers of `core/lbug/pool-adapter.js` (which is shared with the
|
|
8
|
+
* analyze pipeline). New non-test code MAY import from `pool-adapter.js`
|
|
9
|
+
* directly, but the shim must continue to exist for the mock seam to work.
|
|
4
10
|
*/
|
|
5
11
|
export * from '../../core/lbug/pool-adapter.js';
|
package/dist/mcp/server.js
CHANGED
|
@@ -15,7 +15,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
15
15
|
import { CompatibleStdioServerTransport } from './compatible-stdio-transport.js';
|
|
16
16
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListResourceTemplatesRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
17
17
|
import { GITNEXUS_TOOLS } from './tools.js';
|
|
18
|
-
import {
|
|
18
|
+
import { installGlobalStdoutSentinel } from './stdio-context.js';
|
|
19
19
|
import { getResourceDefinitions, getResourceTemplates, readResource } from './resources.js';
|
|
20
20
|
/**
|
|
21
21
|
* Next-step hints appended to tool responses.
|
|
@@ -247,19 +247,30 @@ Follow these steps:
|
|
|
247
247
|
*/
|
|
248
248
|
export async function startMCPServer(backend) {
|
|
249
249
|
const server = createMCPServer(backend);
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
250
|
+
// Idempotent global sentinel install. cli/mcp.ts calls this first thing
|
|
251
|
+
// (before warnMissingOptionalGrammars / backend.init can emit to stdout);
|
|
252
|
+
// calling again here is a safety net for direct callers of startMCPServer
|
|
253
|
+
// (tests, future entry points). The transport's _safeStdout Proxy is a
|
|
254
|
+
// second layer that guarantees transport writes reach the sentinel even
|
|
255
|
+
// if anything else re-replaces process.stdout.write later. Tagged
|
|
256
|
+
// transport writes (wrapped in withMcpWrite by compatible-stdio-transport.send)
|
|
257
|
+
// pass through to the captured realStdoutWrite; untagged writes reaching
|
|
258
|
+
// the Proxy or process.stdout get redirected to stderr with the
|
|
259
|
+
// [mcp:stdout-redirect] prefix. See stdio-context.ts.
|
|
260
|
+
const sentinel = installGlobalStdoutSentinel();
|
|
261
|
+
const safeStdout = new Proxy(process.stdout, {
|
|
254
262
|
get(target, prop, receiver) {
|
|
255
263
|
if (prop === 'write')
|
|
256
|
-
return
|
|
264
|
+
return sentinel.write;
|
|
257
265
|
const val = Reflect.get(target, prop, receiver);
|
|
258
266
|
return typeof val === 'function' ? val.bind(target) : val;
|
|
259
267
|
},
|
|
260
268
|
});
|
|
261
|
-
const transport = new CompatibleStdioServerTransport(process.stdin,
|
|
269
|
+
const transport = new CompatibleStdioServerTransport(process.stdin, safeStdout);
|
|
262
270
|
await server.connect(transport);
|
|
271
|
+
// Surface the redirect counter on shutdown so users see the volume of
|
|
272
|
+
// stray writes even when individual payloads were truncated/suppressed.
|
|
273
|
+
process.on('exit', () => sentinel.flushSummary());
|
|
263
274
|
// Graceful shutdown helper
|
|
264
275
|
let shuttingDown = false;
|
|
265
276
|
const shutdown = async (exitCode = 0) => {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stdio capture — leaf module with zero non-`node:` imports.
|
|
3
|
+
*
|
|
4
|
+
* Owns the singleton state that the MCP stdout sentinel needs:
|
|
5
|
+
* - `realStdoutWrite` / `realStderrWrite`: process.stdout.write /
|
|
6
|
+
* process.stderr.write captured at module load, BEFORE anything else
|
|
7
|
+
* can rebind them.
|
|
8
|
+
* - `activeStdoutWrite`: the write handler that silenceStdout/restoreStdout
|
|
9
|
+
* cycles in pool-adapter restore to. Defaults to `realStdoutWrite`;
|
|
10
|
+
* `installGlobalStdoutSentinel` (in stdio-context.ts) registers the
|
|
11
|
+
* sentinel here at MCP startup so silence/restore preserves the sentinel.
|
|
12
|
+
*
|
|
13
|
+
* This module exists separately from `pool-adapter.ts` (which previously
|
|
14
|
+
* owned the same state) so that `cli/mcp.ts`'s static-import closure does
|
|
15
|
+
* NOT transitively pull in `@ladybugdb/core`. Codex's adversarial review on
|
|
16
|
+
* PR #1383 found that the prior structure left a pre-sentinel window where
|
|
17
|
+
* native-module init banners could reach raw stdout: `cli/mcp.ts` →
|
|
18
|
+
* `mcp/stdio-context.ts` → `core/lbug/pool-adapter.ts` → `@ladybugdb/core`.
|
|
19
|
+
* Routing the sentinel state through this leaf module breaks that chain.
|
|
20
|
+
*
|
|
21
|
+
* **Constraint:** keep this module a leaf. No non-`node:` imports — adding
|
|
22
|
+
* any would re-introduce the import-time stdout-corruption hazard.
|
|
23
|
+
*/
|
|
24
|
+
type StdoutWrite = typeof process.stdout.write;
|
|
25
|
+
/** Captured at module load, before any rebinding. */
|
|
26
|
+
export declare const realStdoutWrite: StdoutWrite;
|
|
27
|
+
export declare const realStderrWrite: typeof process.stderr.write;
|
|
28
|
+
/**
|
|
29
|
+
* Register a wrapper (e.g., the MCP sentinel) as the active stdout write.
|
|
30
|
+
* silenceStdout/restoreStdout cycles in pool-adapter will preserve the
|
|
31
|
+
* wrapper instead of unwinding to the raw realStdoutWrite. Returns the
|
|
32
|
+
* previous value so callers can chain or restore.
|
|
33
|
+
*/
|
|
34
|
+
export declare function setActiveStdoutWrite(fn: StdoutWrite): StdoutWrite;
|
|
35
|
+
/**
|
|
36
|
+
* Read the currently-active stdout write handler. Used by pool-adapter's
|
|
37
|
+
* restoreStdout and watchdog so silence/restore preserves the sentinel.
|
|
38
|
+
*/
|
|
39
|
+
export declare function getActiveStdoutWrite(): StdoutWrite;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stdio capture — leaf module with zero non-`node:` imports.
|
|
3
|
+
*
|
|
4
|
+
* Owns the singleton state that the MCP stdout sentinel needs:
|
|
5
|
+
* - `realStdoutWrite` / `realStderrWrite`: process.stdout.write /
|
|
6
|
+
* process.stderr.write captured at module load, BEFORE anything else
|
|
7
|
+
* can rebind them.
|
|
8
|
+
* - `activeStdoutWrite`: the write handler that silenceStdout/restoreStdout
|
|
9
|
+
* cycles in pool-adapter restore to. Defaults to `realStdoutWrite`;
|
|
10
|
+
* `installGlobalStdoutSentinel` (in stdio-context.ts) registers the
|
|
11
|
+
* sentinel here at MCP startup so silence/restore preserves the sentinel.
|
|
12
|
+
*
|
|
13
|
+
* This module exists separately from `pool-adapter.ts` (which previously
|
|
14
|
+
* owned the same state) so that `cli/mcp.ts`'s static-import closure does
|
|
15
|
+
* NOT transitively pull in `@ladybugdb/core`. Codex's adversarial review on
|
|
16
|
+
* PR #1383 found that the prior structure left a pre-sentinel window where
|
|
17
|
+
* native-module init banners could reach raw stdout: `cli/mcp.ts` →
|
|
18
|
+
* `mcp/stdio-context.ts` → `core/lbug/pool-adapter.ts` → `@ladybugdb/core`.
|
|
19
|
+
* Routing the sentinel state through this leaf module breaks that chain.
|
|
20
|
+
*
|
|
21
|
+
* **Constraint:** keep this module a leaf. No non-`node:` imports — adding
|
|
22
|
+
* any would re-introduce the import-time stdout-corruption hazard.
|
|
23
|
+
*/
|
|
24
|
+
/** Captured at module load, before any rebinding. */
|
|
25
|
+
// eslint-disable-next-line no-restricted-syntax -- this IS the captured-real-write infrastructure used by the MCP sentinel
|
|
26
|
+
export const realStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
27
|
+
export const realStderrWrite = process.stderr.write.bind(process.stderr);
|
|
28
|
+
/**
|
|
29
|
+
* The function `restoreStdout` (and the watchdog) in pool-adapter restore
|
|
30
|
+
* *to* when un-silencing. Defaults to the captured real write; the MCP
|
|
31
|
+
* server registers its sentinel here at startMCPServer (via
|
|
32
|
+
* installGlobalStdoutSentinel) so silenceStdout cycles preserve the sentinel
|
|
33
|
+
* instead of unwinding to raw stdout.
|
|
34
|
+
*/
|
|
35
|
+
let activeStdoutWrite = realStdoutWrite;
|
|
36
|
+
/**
|
|
37
|
+
* Register a wrapper (e.g., the MCP sentinel) as the active stdout write.
|
|
38
|
+
* silenceStdout/restoreStdout cycles in pool-adapter will preserve the
|
|
39
|
+
* wrapper instead of unwinding to the raw realStdoutWrite. Returns the
|
|
40
|
+
* previous value so callers can chain or restore.
|
|
41
|
+
*/
|
|
42
|
+
export function setActiveStdoutWrite(fn) {
|
|
43
|
+
const prev = activeStdoutWrite;
|
|
44
|
+
activeStdoutWrite = fn;
|
|
45
|
+
return prev;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Read the currently-active stdout write handler. Used by pool-adapter's
|
|
49
|
+
* restoreStdout and watchdog so silence/restore preserves the sentinel.
|
|
50
|
+
*/
|
|
51
|
+
export function getActiveStdoutWrite() {
|
|
52
|
+
return activeStdoutWrite;
|
|
53
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Stdio Context — AsyncLocalStorage-tagged transport-write detection.
|
|
3
|
+
*
|
|
4
|
+
* The MCP stdio transport writes JSON-RPC frames to stdout. Per spec, the
|
|
5
|
+
* server MUST NOT write anything to stdout that is not a valid MCP message.
|
|
6
|
+
* Stray writes from dependency code corrupt the protocol and present to
|
|
7
|
+
* clients as a hung handshake or `MCP error -32000`.
|
|
8
|
+
*
|
|
9
|
+
* This module provides:
|
|
10
|
+
* - withMcpWrite(fn): runs fn inside an AsyncLocalStorage context tagged
|
|
11
|
+
* `mcp: true`. The transport wraps every send() in this so its writes
|
|
12
|
+
* are recognizable as legitimate.
|
|
13
|
+
* - isMcpWrite(): true when called inside withMcpWrite.
|
|
14
|
+
* - createStdoutSentinel({...}): a write function suitable for installing
|
|
15
|
+
* in a Proxy over process.stdout. Tagged writes pass through to the real
|
|
16
|
+
* stdout; untagged writes are redirected to stderr with a [mcp:stdout-redirect]
|
|
17
|
+
* prefix, truncated to maxBytes per redirect, and rate-limited to maxRedirects
|
|
18
|
+
* per process so a stray loop cannot flood client logs.
|
|
19
|
+
*
|
|
20
|
+
* The sentinel is correctness-by-construction: it identifies legitimate
|
|
21
|
+
* writes by *who* called write(), not by inspecting the bytes. A byte-shape
|
|
22
|
+
* heuristic ("starts with {, ends with \n") would falsely reject Content-Length
|
|
23
|
+
* frames (which start with C and end with }) and misclassify multi-chunk writes.
|
|
24
|
+
*/
|
|
25
|
+
export declare function withMcpWrite<T>(fn: () => T): T;
|
|
26
|
+
export declare function isMcpWrite(): boolean;
|
|
27
|
+
type WriteFn = typeof process.stdout.write;
|
|
28
|
+
export interface SentinelOptions {
|
|
29
|
+
realStdoutWrite: WriteFn;
|
|
30
|
+
realStderrWrite: WriteFn;
|
|
31
|
+
/** Maximum bytes of payload to surface per redirect. Defaults to 200. */
|
|
32
|
+
maxBytes?: number;
|
|
33
|
+
/** Maximum number of redirects per process before suppression. Defaults to 10. */
|
|
34
|
+
maxRedirects?: number;
|
|
35
|
+
}
|
|
36
|
+
export interface SentinelStats {
|
|
37
|
+
redirected: number;
|
|
38
|
+
suppressed: number;
|
|
39
|
+
}
|
|
40
|
+
export interface Sentinel {
|
|
41
|
+
write: WriteFn;
|
|
42
|
+
stats: () => SentinelStats;
|
|
43
|
+
flushSummary: () => void;
|
|
44
|
+
}
|
|
45
|
+
export declare function createStdoutSentinel(opts: SentinelOptions): Sentinel;
|
|
46
|
+
export declare function installGlobalStdoutSentinel(): Sentinel;
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Stdio Context — AsyncLocalStorage-tagged transport-write detection.
|
|
3
|
+
*
|
|
4
|
+
* The MCP stdio transport writes JSON-RPC frames to stdout. Per spec, the
|
|
5
|
+
* server MUST NOT write anything to stdout that is not a valid MCP message.
|
|
6
|
+
* Stray writes from dependency code corrupt the protocol and present to
|
|
7
|
+
* clients as a hung handshake or `MCP error -32000`.
|
|
8
|
+
*
|
|
9
|
+
* This module provides:
|
|
10
|
+
* - withMcpWrite(fn): runs fn inside an AsyncLocalStorage context tagged
|
|
11
|
+
* `mcp: true`. The transport wraps every send() in this so its writes
|
|
12
|
+
* are recognizable as legitimate.
|
|
13
|
+
* - isMcpWrite(): true when called inside withMcpWrite.
|
|
14
|
+
* - createStdoutSentinel({...}): a write function suitable for installing
|
|
15
|
+
* in a Proxy over process.stdout. Tagged writes pass through to the real
|
|
16
|
+
* stdout; untagged writes are redirected to stderr with a [mcp:stdout-redirect]
|
|
17
|
+
* prefix, truncated to maxBytes per redirect, and rate-limited to maxRedirects
|
|
18
|
+
* per process so a stray loop cannot flood client logs.
|
|
19
|
+
*
|
|
20
|
+
* The sentinel is correctness-by-construction: it identifies legitimate
|
|
21
|
+
* writes by *who* called write(), not by inspecting the bytes. A byte-shape
|
|
22
|
+
* heuristic ("starts with {, ends with \n") would falsely reject Content-Length
|
|
23
|
+
* frames (which start with C and end with }) and misclassify multi-chunk writes.
|
|
24
|
+
*/
|
|
25
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
26
|
+
// Import from the leaf module, NOT `core/lbug/pool-adapter.js`. pool-adapter
|
|
27
|
+
// pulls in `@ladybugdb/core`, which would put the native module in
|
|
28
|
+
// `cli/mcp.ts`'s static-import closure — exactly the pre-sentinel window
|
|
29
|
+
// Codex's adversarial review flagged on PR #1383.
|
|
30
|
+
import { realStdoutWrite, realStderrWrite, setActiveStdoutWrite } from './stdio-capture.js';
|
|
31
|
+
const store = new AsyncLocalStorage();
|
|
32
|
+
export function withMcpWrite(fn) {
|
|
33
|
+
return store.run({ mcp: true }, fn);
|
|
34
|
+
}
|
|
35
|
+
export function isMcpWrite() {
|
|
36
|
+
return store.getStore()?.mcp === true;
|
|
37
|
+
}
|
|
38
|
+
const REDIRECT_PREFIX = '[mcp:stdout-redirect] ';
|
|
39
|
+
const STARTUP_WARNING = '[mcp:stdout-redirect] sentinel triggered — stray write redirected to stderr; subsequent redirects logged at exit\n';
|
|
40
|
+
function chunkToBuffer(chunk) {
|
|
41
|
+
if (chunk === undefined || chunk === null)
|
|
42
|
+
return Buffer.alloc(0);
|
|
43
|
+
if (Buffer.isBuffer(chunk))
|
|
44
|
+
return chunk;
|
|
45
|
+
if (typeof chunk === 'string')
|
|
46
|
+
return Buffer.from(chunk, 'utf8');
|
|
47
|
+
// Plain Uint8Array (e.g. from a TypedArray-using producer): copy bytes
|
|
48
|
+
// verbatim instead of falling through to String(chunk), which produces
|
|
49
|
+
// garbage like "1,2,3,...".
|
|
50
|
+
if (chunk instanceof Uint8Array)
|
|
51
|
+
return Buffer.from(chunk);
|
|
52
|
+
return Buffer.from(String(chunk), 'utf8');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Node Writable.write contract: the completion callback, when present, is
|
|
56
|
+
* always the last argument. Match exactly that — don't try to peer past
|
|
57
|
+
* earlier arguments — so future overload shapes (e.g. an options object)
|
|
58
|
+
* do not silently break callback delivery.
|
|
59
|
+
*/
|
|
60
|
+
function extractCallback(rest) {
|
|
61
|
+
const last = rest[rest.length - 1];
|
|
62
|
+
return typeof last === 'function' ? last : undefined;
|
|
63
|
+
}
|
|
64
|
+
export function createStdoutSentinel(opts) {
|
|
65
|
+
const maxBytes = opts.maxBytes ?? 200;
|
|
66
|
+
const maxRedirects = opts.maxRedirects ?? 10;
|
|
67
|
+
let redirected = 0;
|
|
68
|
+
let suppressed = 0;
|
|
69
|
+
let warningEmitted = false;
|
|
70
|
+
const stderr = (s) => opts.realStderrWrite(s);
|
|
71
|
+
const write = (chunk, ...rest) => {
|
|
72
|
+
if (isMcpWrite()) {
|
|
73
|
+
return opts.realStdoutWrite(chunk, ...rest);
|
|
74
|
+
}
|
|
75
|
+
if (!warningEmitted) {
|
|
76
|
+
warningEmitted = true;
|
|
77
|
+
stderr(STARTUP_WARNING);
|
|
78
|
+
}
|
|
79
|
+
if (redirected < maxRedirects) {
|
|
80
|
+
redirected += 1;
|
|
81
|
+
const buf = chunkToBuffer(chunk);
|
|
82
|
+
const truncated = buf.length > maxBytes ? buf.subarray(0, maxBytes) : buf;
|
|
83
|
+
stderr(REDIRECT_PREFIX);
|
|
84
|
+
if (truncated.length > 0)
|
|
85
|
+
stderr(truncated);
|
|
86
|
+
if (buf.length > maxBytes) {
|
|
87
|
+
stderr(` (+${buf.length - maxBytes} bytes truncated)`);
|
|
88
|
+
}
|
|
89
|
+
if (truncated.length === 0 || truncated[truncated.length - 1] !== 0x0a) {
|
|
90
|
+
stderr('\n');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
suppressed += 1;
|
|
95
|
+
}
|
|
96
|
+
// Honor the Writable.write callback contract — fire async to match
|
|
97
|
+
// Node's "next-tick" semantics so callers never observe sync reentry.
|
|
98
|
+
const cb = extractCallback(rest);
|
|
99
|
+
if (cb) {
|
|
100
|
+
process.nextTick(() => cb(null));
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
write,
|
|
106
|
+
stats: () => ({ redirected, suppressed }),
|
|
107
|
+
flushSummary: () => {
|
|
108
|
+
if (redirected === 0 && suppressed === 0)
|
|
109
|
+
return;
|
|
110
|
+
stderr(`[mcp:stdout-redirect] summary: ${redirected} redirected, ${suppressed} suppressed beyond cap\n`);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Install the sentinel as the global stdout interceptor — idempotent.
|
|
116
|
+
*
|
|
117
|
+
* Does three things in order:
|
|
118
|
+
* 1. Creates the sentinel from the captured `realStdoutWrite` / `realStderrWrite`.
|
|
119
|
+
* 2. Replaces `process.stdout.write` with `sentinel.write`.
|
|
120
|
+
* 3. Registers `sentinel.write` as the "active" handler in pool-adapter
|
|
121
|
+
* so silenceStdout/restoreStdout cycles preserve the sentinel
|
|
122
|
+
* instead of unwinding to raw stdout.
|
|
123
|
+
*
|
|
124
|
+
* Idempotent — callers may invoke it multiple times safely (cli/mcp.ts at
|
|
125
|
+
* the top of mcpCommand, and startMCPServer). The earliest caller wins;
|
|
126
|
+
* subsequent calls return the same sentinel handle. Call this BEFORE any
|
|
127
|
+
* other startup work that might emit to stdout: native module loads,
|
|
128
|
+
* `_require()`-style grammar detection, repo registry reads, embedder
|
|
129
|
+
* pipeline initialization. Anything written before the sentinel is in
|
|
130
|
+
* place reaches raw stdout uncaught.
|
|
131
|
+
*
|
|
132
|
+
* Returns the sentinel handle so the earliest caller can register
|
|
133
|
+
* `process.on('exit', sentinel.flushSummary)`.
|
|
134
|
+
*/
|
|
135
|
+
let _installedSentinel = null;
|
|
136
|
+
export function installGlobalStdoutSentinel() {
|
|
137
|
+
if (_installedSentinel)
|
|
138
|
+
return _installedSentinel;
|
|
139
|
+
const sentinel = createStdoutSentinel({ realStdoutWrite, realStderrWrite });
|
|
140
|
+
// eslint-disable-next-line no-restricted-syntax -- installing the global sentinel is the API contract
|
|
141
|
+
process.stdout.write = sentinel.write;
|
|
142
|
+
setActiveStdoutWrite(sentinel.write);
|
|
143
|
+
_installedSentinel = sentinel;
|
|
144
|
+
return sentinel;
|
|
145
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gitnexus",
|
|
3
|
-
"version": "1.6.4-rc.
|
|
3
|
+
"version": "1.6.4-rc.81",
|
|
4
4
|
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
|
|
5
5
|
"author": "Abhigyan Patwari",
|
|
6
6
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"dev": "tsx watch src/cli/index.ts",
|
|
45
45
|
"test": "vitest run",
|
|
46
46
|
"test:unit": "vitest run test/unit",
|
|
47
|
+
"pretest:integration": "node scripts/build.js",
|
|
47
48
|
"test:integration": "vitest run test/integration",
|
|
48
49
|
"test:watch": "vitest",
|
|
49
50
|
"test:coverage": "vitest run --coverage",
|
|
@@ -3,6 +3,17 @@ const fs = require('fs');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const { execSync } = require('child_process');
|
|
5
5
|
|
|
6
|
+
// Opt-out: skip the native rebuild entirely. Dart parsing becomes
|
|
7
|
+
// unavailable but `npm install gitnexus` finishes much faster on machines
|
|
8
|
+
// without a C++ toolchain. Strict `=== '1'` only — '=true', '=yes', '=0'
|
|
9
|
+
// (read as a string), and any other value all fall through to the rebuild.
|
|
10
|
+
if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') {
|
|
11
|
+
console.warn(
|
|
12
|
+
'[tree-sitter-dart] Skipping build (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1). Dart parsing will be unavailable until reinstalled without the env var.',
|
|
13
|
+
);
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
|
|
6
17
|
const dartDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-dart');
|
|
7
18
|
const bindingGyp = path.join(dartDir, 'binding.gyp');
|
|
8
19
|
const bindingNode = path.join(dartDir, 'build', 'Release', 'tree_sitter_dart_binding.node');
|
|
@@ -34,6 +34,17 @@ const fs = require('fs');
|
|
|
34
34
|
const path = require('path');
|
|
35
35
|
const { execSync } = require('child_process');
|
|
36
36
|
|
|
37
|
+
// Opt-out: skip the native rebuild entirely. Proto parsing becomes
|
|
38
|
+
// unavailable but `npm install gitnexus` finishes much faster on machines
|
|
39
|
+
// without a C++ toolchain. Strict `=== '1'` only — '=true', '=yes', '=0'
|
|
40
|
+
// (read as a string), and any other value all fall through to the rebuild.
|
|
41
|
+
if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') {
|
|
42
|
+
console.warn(
|
|
43
|
+
'[tree-sitter-proto] Skipping build (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1). Proto parsing will be unavailable until reinstalled without the env var.',
|
|
44
|
+
);
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
const protoDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-proto');
|
|
38
49
|
const bindingGyp = path.join(protoDir, 'binding.gyp');
|
|
39
50
|
const bindingNode = path.join(protoDir, 'build', 'Release', 'tree_sitter_proto_binding.node');
|